Initial release: self-hostable APT repository server and CLI
urapt is a self-hostable APT repository server with a companion CLI for pushing and managing Debian .deb packages. Server (urapt-server): - REST API + APT endpoint, SQLite storage (pure-Go modernc driver, no CGO) - .deb files stored content-addressed on disk, reference-counted for dedup - Server-managed RSA-4096 OpenPGP signing key (ProtonMail/go-crypto) - APT indices (Release/InRelease/Packages[.gz/.xz]) generated on demand from the DB, cached in memory, signed with the server key - Full APT model: repositories -> distributions -> components -> architectures - Bearer-token auth for REST; HTTP Basic auth for private-repo APT reads - First registrant becomes admin; repo-scoped permissions (read/write/read-write/admin) plus owner and server-admin roles - Multipart package push with control-field extraction, list/show/delete, pool serving, blob ref-count cleanup - Audit log CLI (urapt): - register/login/logout/whoami, token management - repo/distro/component/arch CRUD, member management - push/pull/ls/show/rm for packages - apt-config helper that emits apt setup commands (key, sources.list, auth.conf for private repos) Packaging & docs: - Dockerfile (multi-stage distroless), docker-compose.yml, sample config - README quick start, architecture overview, config reference, security notes - PLAN.md design blueprint, CHANGELOG.md, GPL-3.0 LICENSE - GitHub Actions CI (test, lint, cross-build for linux/darwin amd64/arm64) - Makefile release target producing static binaries + tarballs + checksums Tests cover the data-access layer, auth/permission checks, APT index generation, .deb parsing, GPG signing, the REST API, and the typed API client. Verified end-to-end on a Raspberry Pi (arm64) pushing and installing a real package.
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test (linux/amd64)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
cache: true
|
||||||
|
- name: gofmt
|
||||||
|
run: |
|
||||||
|
unformatted="$(gofmt -l .)"
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "::error::gofmt would modify the following files:"
|
||||||
|
echo "$unformatted"
|
||||||
|
gofmt -d $unformatted
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
- name: go build
|
||||||
|
run: go build ./...
|
||||||
|
- name: go test
|
||||||
|
run: go test -count=1 ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
cache: true
|
||||||
|
- uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
cross-build:
|
||||||
|
name: Cross-build (${{ matrix.goos }}/${{ matrix.goarch }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
cache: true
|
||||||
|
- name: Build urapt-server
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: '0'
|
||||||
|
run: go build -o /dev/null ./cmd/urapt-server
|
||||||
|
- name: Build urapt CLI
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: '0'
|
||||||
|
run: go build -o /dev/null ./cmd/urapt
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Build output
|
||||||
|
/urapt
|
||||||
|
/urapt-server
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
/store/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
/hello/
|
||||||
|
/hello.deb
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
go: "1.22"
|
||||||
|
|
||||||
|
linters:
|
||||||
|
disable-all: true
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- misspell
|
||||||
|
- revive
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-rules:
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
- revive
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to urapt will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-28
|
||||||
|
|
||||||
|
First public release. urapt is a self-hostable APT repository server with a
|
||||||
|
companion CLI for pushing and managing Debian `.deb` packages.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Server** (`urapt-server`): REST API + APT endpoint server.
|
||||||
|
- SQLite storage (pure-Go `modernc.org/sqlite`, no CGO) with embedded
|
||||||
|
migrations. Only `.deb` files are stored on disk, content-addressed by
|
||||||
|
SHA-256 and reference-counted for deduplication.
|
||||||
|
- Server-managed RSA-4096 OpenPGP signing key (`ProtonMail/go-crypto`),
|
||||||
|
generated on first run and stored armored in the database.
|
||||||
|
- APT indices (`Release`, `Release.gpg`, `InRelease`, `Packages` with
|
||||||
|
`.gz`/`.xz` compression) generated on demand from the database and cached
|
||||||
|
in memory; never written to disk.
|
||||||
|
- Full APT model: repositories → distributions → components → architectures.
|
||||||
|
- Bearer-token auth for the REST API; HTTP Basic auth (password = API token)
|
||||||
|
for private-repo APT reads. Public repos allow anonymous APT reads.
|
||||||
|
- First registrant becomes admin; repo-scoped permissions
|
||||||
|
(`read`/`write`/`read-write`/`admin`) plus owner and server-admin roles.
|
||||||
|
- Multipart package push with control-field extraction, list/show/delete,
|
||||||
|
pool serving, and blob ref-count cleanup on delete.
|
||||||
|
- Audit log for mutating actions.
|
||||||
|
- **CLI** (`urapt`): companion tool for pushing packages and managing repos.
|
||||||
|
- `register`, `login`, `logout`, `whoami`, `token` (create/list/revoke).
|
||||||
|
- `repo` (create/list/show/update/delete), `member` (add/update/remove/list).
|
||||||
|
- `distro`, `component`, `arch` CRUD.
|
||||||
|
- `push`, `pull`, `ls`, `show`, `rm` for packages.
|
||||||
|
- `apt-config` helper that prints the exact `apt` setup commands (key
|
||||||
|
install, sources.list entry, and auth.conf snippet for private repos).
|
||||||
|
- **Packaging**: `Dockerfile` (multi-stage distroless static build),
|
||||||
|
`docker-compose.yml`, sample `urapt-server.toml.example`.
|
||||||
|
- **Docs**: `README.md` quick start, architecture overview, configuration
|
||||||
|
reference, and security notes; `PLAN.md` full design blueprint.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Passwords are bcrypt-hashed (cost 12). API tokens are random 32-byte values
|
||||||
|
stored only as SHA-256 hashes with a short display prefix; revocable.
|
||||||
|
- The OpenPGP private signing key is stored unencrypted in the SQLite
|
||||||
|
database. This is acceptable when you control the database file; for
|
||||||
|
stronger protection, restrict file permissions and back up the DB securely.
|
||||||
|
Per-repo keys and key encryption-at-rest are planned.
|
||||||
|
- For internet-facing deployments, run behind a TLS-terminating reverse proxy
|
||||||
|
(Caddy/nginx). Private-repo credentials must never travel over plain HTTP.
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
- Single server-managed signing key (no per-repo keys yet).
|
||||||
|
- No web UI; all management is via the CLI.
|
||||||
|
- No rate limiting or brute-force protection on login endpoints.
|
||||||
|
- No source packages (`.dsc`/`.orig.tar.*`) or AppStream metadata.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/owen/urapt/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/owen/urapt/releases/tag/v0.1.0
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM golang:1.22-bookworm AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/urapt-server ./cmd/urapt-server \
|
||||||
|
&& CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/urapt ./cmd/urapt
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
COPY --from=build /out/urapt-server /usr/local/bin/urapt-server
|
||||||
|
EXPOSE 8080
|
||||||
|
VOLUME ["/data"]
|
||||||
|
ENV URAPT_STORE_DIR=/data/store
|
||||||
|
ENV URAPT_BASE_URL=http://localhost:8080
|
||||||
|
USER nonroot:nonroot
|
||||||
|
ENTRYPOINT ["urapt-server"]
|
||||||
|
CMD ["--bind", "0.0.0.0:8080"]
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
urapt - a self-hostable APT repository server.
|
||||||
|
Copyright (C) 2026 urapt contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that is
|
||||||
|
widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction and
|
||||||
|
control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the
|
||||||
|
conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to the
|
||||||
|
covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors or authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors or authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section,
|
||||||
|
you must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after your
|
||||||
|
receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
BINARY_SERVER := urapt-server
|
||||||
|
BINARY_CLI := urapt
|
||||||
|
GO := go
|
||||||
|
LDFLAGS := -s -w
|
||||||
|
|
||||||
|
# Version is injected into the binary via -X. Falls back to the git describe
|
||||||
|
# output (tag or commit), then to "dev" when not in a git checkout.
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
RELEASE_LDFLAGS := -s -w -X urapt/shared/version.Version=$(VERSION)
|
||||||
|
|
||||||
|
# Release targets: <goos>-<goarch>. Windows is omitted (no apt client there).
|
||||||
|
RELEASE_TARGETS := linux-amd64 linux-arm64 darwin-amd64 darwin-arm64
|
||||||
|
|
||||||
|
.PHONY: all build build-server build-cli test vet fmt lint run-server run-cli clean tidy release release-binaries checksums
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build: build-server build-cli
|
||||||
|
|
||||||
|
build-server:
|
||||||
|
$(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY_SERVER) ./cmd/urapt-server
|
||||||
|
|
||||||
|
build-cli:
|
||||||
|
$(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY_CLI) ./cmd/urapt
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(GO) test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
$(GO) vet ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
$(GO) fmt ./...
|
||||||
|
|
||||||
|
lint: vet
|
||||||
|
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || echo "golangci-lint not installed; skipping"
|
||||||
|
|
||||||
|
run-server: build-server
|
||||||
|
./$(BINARY_SERVER)
|
||||||
|
|
||||||
|
run-cli: build-cli
|
||||||
|
./$(BINARY_CLI)
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
$(GO) mod tidy
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY_SERVER) $(BINARY_CLI)
|
||||||
|
rm -rf bin/ dist/
|
||||||
|
|
||||||
|
# release builds static, CGO-free binaries for all release targets into dist/,
|
||||||
|
# bundles each pair (server + CLI) into a tar.gz, and produces a checksums file.
|
||||||
|
release: release-binaries checksums
|
||||||
|
|
||||||
|
release-binaries:
|
||||||
|
@mkdir -p dist
|
||||||
|
@for target in $(RELEASE_TARGETS); do \
|
||||||
|
os=$${target%-*}; \
|
||||||
|
arch=$${target#*-}; \
|
||||||
|
echo "==> building $$os/$$arch"; \
|
||||||
|
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(RELEASE_LDFLAGS)" -o dist/$(BINARY_SERVER)-$$os-$$arch ./cmd/urapt-server; \
|
||||||
|
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(RELEASE_LDFLAGS)" -o dist/$(BINARY_CLI)-$$os-$$arch ./cmd/urapt; \
|
||||||
|
tar -czf dist/urapt-$(VERSION)-$$os-$$arch.tar.gz -C dist $(BINARY_SERVER)-$$os-$$arch $(BINARY_CLI)-$$os-$$arch; \
|
||||||
|
done
|
||||||
|
@echo "==> release artifacts in dist/"
|
||||||
|
|
||||||
|
checksums:
|
||||||
|
@cd dist && sha256sum *.tar.gz > checksums-$(VERSION).txt
|
||||||
|
@echo "==> wrote dist/checksums-$(VERSION).txt"
|
||||||
@@ -0,0 +1,979 @@
|
|||||||
|
# urapt — Implementation Plan
|
||||||
|
|
||||||
|
A self-hostable APT repository server with a companion CLI for pushing and
|
||||||
|
managing Debian/Ubuntu `.deb` packages under your logged-in user.
|
||||||
|
|
||||||
|
> Status: Planning. This document is the authoritative blueprint for
|
||||||
|
> implementation. Decisions captured here are final unless explicitly marked
|
||||||
|
> "open" or "future".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals & scope
|
||||||
|
|
||||||
|
### In scope (v1)
|
||||||
|
- A self-hostable **server** that:
|
||||||
|
- Exposes a **REST API** for the CLI to manage repositories, users,
|
||||||
|
distributions, components, architectures, and packages.
|
||||||
|
- Exposes a **special APT endpoint** that the standard `apt` client speaks to
|
||||||
|
(serves `dists/.../{Release,InRelease,Release.gpg}`, `Packages` indices, and
|
||||||
|
the `pool/` `.deb` files).
|
||||||
|
- Stores **only uploaded `.deb` files on the filesystem** (`store/packages/`).
|
||||||
|
Everything else lives in a **SQLite database** (`store/database/sqlite.db`).
|
||||||
|
- Generates APT indices **on demand from the database** (indices are never
|
||||||
|
persisted to disk).
|
||||||
|
- Signs `Release`/`InRelease` with a **server-managed GPG key**.
|
||||||
|
- A **CLI** (`urapt`) that:
|
||||||
|
- Logs you in (username/password → API token stored locally).
|
||||||
|
- Pushes `.deb` packages to a repository/distribution/component under your
|
||||||
|
identity.
|
||||||
|
- Pulls, lists, and deletes packages.
|
||||||
|
- Manages repositories, members, distributions, components, architectures.
|
||||||
|
- Emits the `sources.list` line + pubkey + `auth.conf` for client setup.
|
||||||
|
- A **shared utilities component** (`shared/`) used by both server and CLI:
|
||||||
|
config, DB, models, GPG, `.deb` parsing, APT index generation, crypto, and
|
||||||
|
the typed REST API client + DTOs.
|
||||||
|
|
||||||
|
### Out of scope (v1, listed as future)
|
||||||
|
- Source package (`deb-src`) hosting.
|
||||||
|
- AppStream / `dep11` metadata.
|
||||||
|
- `Acquire-By-Hash` indices.
|
||||||
|
- Web UI.
|
||||||
|
- External OAuth/OIDC auth.
|
||||||
|
- Per-repository GPG keys (v1 uses one server-wide key).
|
||||||
|
- OS keychain credential storage (v1 stores token in a 0600 config file).
|
||||||
|
- Multi-arch `Contents` indexes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Confirmed decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Language / stack | **Go** — single static binary for both server and CLI |
|
||||||
|
| GPG signing | **Server-managed key**: server generates & stores its own key, signs `Release`/`InRelease` automatically; admin exports the pubkey for clients |
|
||||||
|
| Auth (CLI ↔ API) | **API tokens**: user logs in with username/password once, gets a token stored locally; CLI sends token in `Authorization: Bearer` |
|
||||||
|
| Bootstrap admin | **First user to register becomes admin** |
|
||||||
|
| Repo model | **Full model**: repositories contain suites/distributions (stable, testing…), each suite has components (main, contrib…) and architectures (amd64, arm64, all) |
|
||||||
|
| Access policy | **Public repo**: APT read is unauthenticated, REST write requires auth. **Private repo**: APT read *and* REST write require auth (APT read via HTTP Basic with token) |
|
||||||
|
| Permissions | **Repository-scoped**. Creator = owner with full read/write. Owner can grant `read` / `write` / `read-write` / `admin` to other users. Only users with access can push. Server admins can manage everything |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture overview
|
||||||
|
|
||||||
|
Three components, one Go module (`urapt`):
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------+ +-------------------+
|
||||||
|
| urapt (CLI) | | urapt-server |
|
||||||
|
| cmd/urapt | | cmd/urapt-server |
|
||||||
|
+---------+---------+ +---------+---------+
|
||||||
|
| |
|
||||||
|
| uses shared/ | uses shared/
|
||||||
|
v v
|
||||||
|
+---------------------------------------------+
|
||||||
|
| shared/ |
|
||||||
|
| config | db | models | gpg | deb | apt | |
|
||||||
|
| crypto | api(types) | apiclient(HTTP) | |
|
||||||
|
+---------------------------------------------+
|
||||||
|
| |
|
||||||
|
+------> SQLite <-----+ |
|
||||||
|
| |
|
||||||
|
store/database/sqlite.db
|
||||||
|
store/packages/*.deb (files only)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **CLI** never touches the DB or filesystem directly; it only talks to the
|
||||||
|
server's REST API via `shared/apiclient`.
|
||||||
|
- The **server** owns the DB and the `store/` directory.
|
||||||
|
- The **shared** component contains pure libraries and the API contract. No
|
||||||
|
component imports upward (no `shared` → `server` or `shared` → `cli`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
urapt/
|
||||||
|
go.mod module path: urapt
|
||||||
|
go.sum
|
||||||
|
README.md
|
||||||
|
PLAN.md
|
||||||
|
.gitignore ignores store/, *.db, built binaries
|
||||||
|
Dockerfile multi-stage build for urapt-server
|
||||||
|
docker-compose.yml example self-hosted deployment
|
||||||
|
Makefile build/test/lint targets
|
||||||
|
|
||||||
|
cmd/
|
||||||
|
urapt-server/main.go server entrypoint: load config, wire app, run
|
||||||
|
urapt/ main.go CLI entrypoint: execute cobra root
|
||||||
|
|
||||||
|
shared/ ---- shared utilities component ----
|
||||||
|
config/ config structs + file/env/flag loading
|
||||||
|
db/ sqlite open (WAL), migrations runner, query helpers
|
||||||
|
models/ domain types (User, Repository, Package, …)
|
||||||
|
api/ REST DTOs + request/response shapes (the contract)
|
||||||
|
apiclient/ typed HTTP client used by the CLI
|
||||||
|
crypto/ password hashing (bcrypt), token gen + hashing
|
||||||
|
gpg/ key generation, clearsign, detached sign, export
|
||||||
|
deb/ .deb (ar) unpack, control.tar parse, control fields
|
||||||
|
apt/ index generation: Packages, Release, InRelease, Release.gpg
|
||||||
|
httputil/ JSON helpers, error rendering, bearer/basic parsing
|
||||||
|
log/ structured logging wrapper
|
||||||
|
version/ build version info
|
||||||
|
|
||||||
|
server/ ---- server component ----
|
||||||
|
app/ wiring: dependencies, store paths, startup, key init
|
||||||
|
restapi/ REST handlers + routes (chi)
|
||||||
|
aptrepo/ APT endpoint handlers + index cache
|
||||||
|
middleware/ auth (bearer), basic-auth (for private APT), logging, recover
|
||||||
|
auth/ token resolution, permission checks, session/identity
|
||||||
|
|
||||||
|
cli/ ---- CLI component ----
|
||||||
|
commands/ cobra commands (login, push, repo, …)
|
||||||
|
config/ local config + token store (~/.config/urapt)
|
||||||
|
output/ table/JSON formatting helpers
|
||||||
|
interact/ prompts (password, confirm)
|
||||||
|
|
||||||
|
migrations/ numbered *.sql files, embedded via go:embed
|
||||||
|
0001_init.sql
|
||||||
|
|
||||||
|
store/ runtime data (gitignored, created at runtime)
|
||||||
|
database/sqlite.db
|
||||||
|
packages/<sha256>.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import rules
|
||||||
|
- `shared/**` imports only stdlib + 3rd-party libs (never `server/` or `cli/`).
|
||||||
|
- `server/**` imports `shared/**`.
|
||||||
|
- `cli/**` imports `shared/**`.
|
||||||
|
- `cmd/**` are thin `main` packages that wire the relevant component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Technology choices (libraries)
|
||||||
|
|
||||||
|
| Concern | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| SQLite driver | `modernc.org/sqlite` | Pure-Go, no CGO → easy static binaries & cross-compile |
|
||||||
|
| HTTP router | `go-chi/chi/v5` | Lightweight, middleware-friendly, stdlib-compatible |
|
||||||
|
| Migrations | `embed` + tiny runner in `shared/db` | No extra tooling; runs on startup |
|
||||||
|
| Password hashing | `golang.org/x/crypto/bcrypt` | Simple, well-understood |
|
||||||
|
| Token generation | `crypto/rand` 32B → base64url; store SHA-256 | Standard, revocable |
|
||||||
|
| GPG / OpenPGP | `github.com/ProtonMail/go-crypto/openpgp` | Maintained OpenPGP in pure Go; keygen, clearsign, detached sig |
|
||||||
|
| `.deb` ar archive | `pault.ag/go/debian/deb` (+ `blakesmith/ar` fallback) | Debian-aware deb reader |
|
||||||
|
| Control parsing | `pault.ag/go/debian/control` | RFC822 control field parsing |
|
||||||
|
| Compression (xz) | `github.com/ulikunitz/xz` | For `control.tar.xz` / `data.tar.xz` |
|
||||||
|
| Compression (zstd) | `github.com/klauspost/compress/zstd` | For `control.tar.zst` (newer debs) |
|
||||||
|
| CLI framework | `github.com/spf13/cobra` | Widely known, subcommands, flags |
|
||||||
|
| Config | `github.com/BurntSushi/toml` + env + flags | TOML file + env overrides + flag overrides |
|
||||||
|
| Logging | `log/slog` (stdlib) | Structured logging, no dep |
|
||||||
|
| Validation | `github.com/go-playground/validator/v10` | DTO validation |
|
||||||
|
| Testing | `testing` + `github.com/stretchr/testify` | Unit + integration |
|
||||||
|
|
||||||
|
Go version: **1.22+** (for `log/slog`, enhanced `ServeMux` if needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Data model (SQLite schema)
|
||||||
|
|
||||||
|
All IDs are text UUIDv4. Timestamps are ISO-8601 UTC text. Booleans are INTEGER
|
||||||
|
0/1. SQLite in **WAL** mode, `busy_timeout=5000`, `foreign_keys=ON`.
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | uuid |
|
||||||
|
| username | TEXT UNIQUE NOT NULL | case-insensitive; store lowercased + original |
|
||||||
|
| password_hash | TEXT NOT NULL | bcrypt |
|
||||||
|
| is_admin | INTEGER NOT NULL DEFAULT 0 | 1 for admins |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| updated_at | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
### `api_tokens`
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | uuid |
|
||||||
|
| user_id | TEXT FK→users.id | |
|
||||||
|
| name | TEXT NOT NULL | user label e.g. "laptop" |
|
||||||
|
| prefix | TEXT NOT NULL | first 8 chars of token (for identification) |
|
||||||
|
| token_hash | TEXT UNIQUE NOT NULL | SHA-256 of full token |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| last_used_at | TEXT | nullable |
|
||||||
|
| revoked_at | TEXT | nullable; if set, invalid |
|
||||||
|
|
||||||
|
### `repositories`
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | |
|
||||||
|
| name | TEXT UNIQUE NOT NULL | URL-safe `[a-z0-9-]+`, lowercase |
|
||||||
|
| owner_user_id | TEXT FK→users.id | implicit full access |
|
||||||
|
| visibility | TEXT NOT NULL | `public` \| `private` |
|
||||||
|
| description | TEXT | nullable |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| updated_at | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
> v1 uses a single server-wide signing key, so no `signing_key_id` column. A
|
||||||
|
> `gpg_keys` table still holds that one key (see below).
|
||||||
|
|
||||||
|
### `repository_members`
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| repository_id | TEXT FK→repositories.id | |
|
||||||
|
| user_id | TEXT FK→users.id | |
|
||||||
|
| access | TEXT NOT NULL | `read` \| `write` \| `read-write` \| `admin` |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| PK | (repository_id, user_id) | |
|
||||||
|
|
||||||
|
- `read`: can read/download (private repos) and list.
|
||||||
|
- `write`: can push packages (no read).
|
||||||
|
- `read-write`: both.
|
||||||
|
- `admin`: both + manage members. Owner is implicitly `admin`.
|
||||||
|
|
||||||
|
### `distributions` (suites)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | |
|
||||||
|
| repository_id | TEXT FK→repositories.id | |
|
||||||
|
| name | TEXT NOT NULL | e.g. `stable`, `testing`, `jammy` |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| UNIQUE | (repository_id, name) | |
|
||||||
|
|
||||||
|
### `components`
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | |
|
||||||
|
| distribution_id | TEXT FK→distributions.id | |
|
||||||
|
| name | TEXT NOT NULL | e.g. `main`, `contrib` |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| UNIQUE | (distribution_id, name) | |
|
||||||
|
|
||||||
|
### `architectures`
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | |
|
||||||
|
| distribution_id | TEXT FK→distributions.id | |
|
||||||
|
| name | TEXT NOT NULL | e.g. `amd64`, `arm64` (not `all` — `all` is implicit) |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| UNIQUE | (distribution_id, name) | |
|
||||||
|
|
||||||
|
> `all` is **not** stored as an architecture row. Architecture-independent
|
||||||
|
> packages (arch=`all`) are listed in **every** binary-`<arch>` Packages index.
|
||||||
|
> The Release `Architectures` field lists the configured architectures.
|
||||||
|
|
||||||
|
### `packages` (one row per uploaded .deb version)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | |
|
||||||
|
| repository_id | TEXT FK | |
|
||||||
|
| distribution_id | TEXT FK | |
|
||||||
|
| component_id | TEXT FK | |
|
||||||
|
| name | TEXT NOT NULL | from control `Package` |
|
||||||
|
| version | TEXT NOT NULL | from control `Version` |
|
||||||
|
| architecture | TEXT NOT NULL | from control `Architecture` (incl. `all`) |
|
||||||
|
| source | TEXT | from control `Source` (source pkg name) |
|
||||||
|
| maintainer | TEXT | |
|
||||||
|
| priority | TEXT | |
|
||||||
|
| section | TEXT | |
|
||||||
|
| origin | TEXT | |
|
||||||
|
| homepage | TEXT | |
|
||||||
|
| description | TEXT | full (extended) |
|
||||||
|
| description_md5 | TEXT | md5 of short description |
|
||||||
|
| depends | TEXT | |
|
||||||
|
| pre_depends | TEXT | |
|
||||||
|
| recommends | TEXT | |
|
||||||
|
| suggests | TEXT | |
|
||||||
|
| conflicts | TEXT | |
|
||||||
|
| breaks | TEXT | |
|
||||||
|
| provides | TEXT | |
|
||||||
|
| replaces | TEXT | |
|
||||||
|
| enhances | TEXT | |
|
||||||
|
| installed_size | INTEGER | kB |
|
||||||
|
| essential | TEXT | nullable |
|
||||||
|
| built_using | TEXT | nullable |
|
||||||
|
| tag | TEXT | nullable |
|
||||||
|
| raw_control | TEXT NOT NULL | full control stanza (re-emitted in index) |
|
||||||
|
| filename | TEXT NOT NULL | real file: `store/packages/<sha256>.deb` |
|
||||||
|
| pool_path | TEXT NOT NULL | virtual: `pool/<component>/<letter>/<src>/<file>.deb` |
|
||||||
|
| size | INTEGER NOT NULL | .deb file size in bytes |
|
||||||
|
| md5sum | TEXT NOT NULL | of .deb |
|
||||||
|
| sha1 | TEXT NOT NULL | of .deb |
|
||||||
|
| sha256 | TEXT NOT NULL | of .deb |
|
||||||
|
| uploaded_by_user_id | TEXT FK→users.id | |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
| UNIQUE | (repository_id, distribution_id, component_id, name, version, architecture) | |
|
||||||
|
|
||||||
|
### `blobs` (content-addressed .deb files; reference counting + dedup)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| sha256 | TEXT PK | |
|
||||||
|
| filename | TEXT NOT NULL | `store/packages/<sha256>.deb` |
|
||||||
|
| size | INTEGER NOT NULL | |
|
||||||
|
| ref_count | INTEGER NOT NULL DEFAULT 0 | |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
> On push: compute sha256 → find-or-create blob (ref_count++) → insert package.
|
||||||
|
> On package delete: ref_count--; when 0, delete the file and the blob row.
|
||||||
|
|
||||||
|
### `gpg_keys` (server-managed signing keys)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | |
|
||||||
|
| fingerprint | TEXT UNIQUE NOT NULL | |
|
||||||
|
| user_id | TEXT NOT NULL | OpenPGP user-id string, e.g. `urapt-server <host>` |
|
||||||
|
| public_key_armored | TEXT NOT NULL | exported ASCII pubkey (served to clients) |
|
||||||
|
| private_key_armored | TEXT NOT NULL | armored private key (see security note) |
|
||||||
|
| is_default | INTEGER NOT NULL DEFAULT 0 | the one default server key |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
> **Security note:** the private key is stored in the SQLite DB. v1 stores it
|
||||||
|
> armored without passphrase (acceptable for a self-hosted single-binary where
|
||||||
|
> the operator controls the DB file). Future: encrypt at rest with a passphrase
|
||||||
|
> from config/env (AES-GCM), and/or support per-repo keys. Document the
|
||||||
|
> tradeoff in README.
|
||||||
|
|
||||||
|
### `audit_log` (lightweight, best-effort)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | TEXT PK | |
|
||||||
|
| user_id | TEXT | nullable (system events) |
|
||||||
|
| repository_id | TEXT | nullable |
|
||||||
|
| action | TEXT NOT NULL | e.g. `package.push`, `repo.create`, `member.add` |
|
||||||
|
| target | TEXT | human-readable subject |
|
||||||
|
| details | TEXT | JSON blob |
|
||||||
|
| created_at | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
### `schema_migrations`
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| version | INTEGER PK | migration number |
|
||||||
|
| applied_at | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. APT repository layout & serving
|
||||||
|
|
||||||
|
The APT endpoint base path is **`/apt/<repository>/`**. apt clients use:
|
||||||
|
|
||||||
|
```
|
||||||
|
deb https://<host>/apt/<repo>/ <suite> <component>[ <component>...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### On-disk vs virtual
|
||||||
|
- **Virtual** paths (served, never on disk): everything under `dists/` and the
|
||||||
|
`pool/` tree. Generated from the DB on demand.
|
||||||
|
- **Real** files on disk: only `store/packages/<sha256>.deb`.
|
||||||
|
|
||||||
|
### Routes served by the APT endpoint
|
||||||
|
| Route | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `GET /apt/:repo/dists/:suite/InRelease` | clearsigned Release (preferred by apt) |
|
||||||
|
| `GET /apt/:repo/dists/:suite/Release` | unsigned Release |
|
||||||
|
| `GET /apt/:repo/dists/:suite/Release.gpg` | detached signature of Release |
|
||||||
|
| `GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages` | package index (text) |
|
||||||
|
| `GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages.gz` | gzip |
|
||||||
|
| `GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages.xz` | xz (optional) |
|
||||||
|
| `GET /apt/:repo/pool/:component/:letter/:src/:filename` | the `.deb` file (streamed, Range support) |
|
||||||
|
|
||||||
|
- `:arch` excludes `all`; `all`-arch packages are merged into each real arch's
|
||||||
|
index.
|
||||||
|
- Pool path resolution: `pool/<component>/<letter>/<src>/<filename>` → DB lookup
|
||||||
|
by `pool_path` within `:repo` → serve `store/packages/<sha256>.deb` via
|
||||||
|
`http.ServeContent` (supports Range, ETag, Last-Modified).
|
||||||
|
- `:letter`/`:src` follow Debian convention: if source name starts with `lib`,
|
||||||
|
prefix = `lib` + first char after `lib` (e.g. `liba` for `libapache2…`);
|
||||||
|
else prefix = first char of source/package name. This is cosmetic — only
|
||||||
|
internal consistency matters.
|
||||||
|
|
||||||
|
### Auth on the APT endpoint
|
||||||
|
- **Public repo:** no auth for any GET.
|
||||||
|
- **Private repo:** every GET requires **HTTP Basic auth** with
|
||||||
|
`username = <username>`, `password = <api token>`. Server resolves the token,
|
||||||
|
checks the user has at least `read`/`write`/`read-write`/`admin` access (or is
|
||||||
|
owner/admin). On missing/invalid creds, respond `401` with
|
||||||
|
`WWW-Authenticate: Basic realm="urapt <repo>"` so apt's `auth.conf` triggers.
|
||||||
|
|
||||||
|
Client `auth.conf` example:
|
||||||
|
```
|
||||||
|
machine <host>
|
||||||
|
login <username>
|
||||||
|
password <api token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Index generation (`shared/apt`)
|
||||||
|
|
||||||
|
All indices are built **in memory from the DB** and may be cached (see §11).
|
||||||
|
Never written to disk.
|
||||||
|
|
||||||
|
### Packages index (per component + arch)
|
||||||
|
For `(repo, suite, component, arch)`:
|
||||||
|
1. Query all `packages` rows matching `distribution_id`, `component_id`, and
|
||||||
|
`(architecture = arch OR architecture = 'all')`.
|
||||||
|
2. For each row, emit a stanza starting with the control fields (from
|
||||||
|
`raw_control`, filtered/normalized) plus the file fields:
|
||||||
|
```
|
||||||
|
Package: <name>
|
||||||
|
Version: <version>
|
||||||
|
Architecture: <arch as stored>
|
||||||
|
Filename: <pool_path> # e.g. pool/main/f/foo/foo_1.0_amd64.deb
|
||||||
|
Size: <size>
|
||||||
|
MD5sum: <md5sum>
|
||||||
|
SHA1: <sha1>
|
||||||
|
SHA256: <sha256>
|
||||||
|
...other control fields...
|
||||||
|
Description: <description>
|
||||||
|
```
|
||||||
|
3. Entries separated by a blank line; file ends with a blank line.
|
||||||
|
4. Serve as `Packages`; also serve gzip (`Packages.gz`) and xz (`Packages.xz`).
|
||||||
|
|
||||||
|
### Release file (per suite)
|
||||||
|
1. Determine components (list of component names for the suite) and
|
||||||
|
architectures (configured arch names).
|
||||||
|
2. For each `(component, arch)` generate the `Packages`, `Packages.gz`,
|
||||||
|
`Packages.xz` bytes (reuse from the per-arch generation).
|
||||||
|
3. Compute checksums + sizes of each, keyed by their **path relative to the
|
||||||
|
suite**, e.g. `main/binary-amd64/Packages`.
|
||||||
|
4. Emit:
|
||||||
|
```
|
||||||
|
Origin: urapt <repo>
|
||||||
|
Label: urapt <repo>
|
||||||
|
Suite: <suite>
|
||||||
|
Codename: <suite>
|
||||||
|
Date: <RFC2822 now>
|
||||||
|
Architectures: amd64 arm64
|
||||||
|
Components: main contrib
|
||||||
|
Description: <repo description>
|
||||||
|
MD5Sum:
|
||||||
|
<md5> <size> main/binary-amd64/Packages
|
||||||
|
...
|
||||||
|
SHA1:
|
||||||
|
<sha1> <size> main/binary-amd64/Packages
|
||||||
|
...
|
||||||
|
SHA256:
|
||||||
|
<sha256> <size> main/binary-amd64/Packages
|
||||||
|
...
|
||||||
|
```
|
||||||
|
- No `Valid-Until` (avoid expiry on quiet self-hosted repos).
|
||||||
|
- `Date` is regenerated on cache invalidation.
|
||||||
|
|
||||||
|
### InRelease
|
||||||
|
- `InRelease` = **clearsigned** `Release` (inline OpenPGP signature) using the
|
||||||
|
server's default GPG key. Preferred by modern apt.
|
||||||
|
|
||||||
|
### Release.gpg
|
||||||
|
- **Detached** signature of `Release` using the same key. For older apt flows.
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
- A per-`(repo, suite)` cache holds: `{Packages map, Release bytes, InRelease
|
||||||
|
bytes, Release.gpg bytes, generation int}`.
|
||||||
|
- Generation is bumped on any mutation affecting that `(repo, suite)`:
|
||||||
|
package push/delete, component/arch add/remove, distribution rename/delete,
|
||||||
|
repo visibility change.
|
||||||
|
- Cache is in-memory only; rebuilt lazily on first request after a bump or after
|
||||||
|
server restart. Mutex per key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. GPG signing (`shared/gpg`)
|
||||||
|
|
||||||
|
- Uses `github.com/ProtonMail/go-crypto/openpgp`.
|
||||||
|
- **Key generation** (on first server startup if no default key exists):
|
||||||
|
- Key type: **RSA-4096** (broad apt/gpg compatibility; configurable later).
|
||||||
|
- User-id: configurable, default `urapt-server <hostname>`.
|
||||||
|
- No passphrase (v1; stored armored in DB).
|
||||||
|
- Persist armored public + private key to `gpg_keys` with `is_default=1`.
|
||||||
|
- **Operations:**
|
||||||
|
- `Clearsign(data) → InRelease`
|
||||||
|
- `DetachedSign(data) → Release.gpg`
|
||||||
|
- `ExportPublic() → ascii-armored pubkey` (served at `/api/v1/server/pubkey`
|
||||||
|
and `/apt/:repo/...` is signed by it).
|
||||||
|
- The pubkey is also exposed via the CLI (`urapt repo pubkey`) and the
|
||||||
|
`apt-config` helper prints instructions to install it
|
||||||
|
(`gpg --dearmor | tee /usr/share/keyrings/urapt.gpg` + signed-by line).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `.deb` parsing (`shared/deb`)
|
||||||
|
|
||||||
|
On push, the server must extract control metadata **without executing anything
|
||||||
|
from the package**.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
1. Open the `.deb` as an `ar` archive (`pault.ag/go/debian/deb` or raw ar).
|
||||||
|
2. Read the `debian-binary` member (validate version `2.0`).
|
||||||
|
3. Locate the `control.tar.*` member; decompress (gzip / xz / zstd by magic
|
||||||
|
bytes).
|
||||||
|
4. From the tar, read the `control` file (and optional `shlibs`, `symbols`,
|
||||||
|
`triggers` — ignored for v1).
|
||||||
|
5. Parse the control stanza (`pault.ag/go/debian/control`): RFC822-style fields.
|
||||||
|
6. Extract all fields needed for the `packages` row (see §6) and keep the full
|
||||||
|
`raw_control` for re-emission.
|
||||||
|
7. Compute file hashes (MD5, SHA1, SHA256) and size **of the whole `.deb`**.
|
||||||
|
|
||||||
|
### Validation on push
|
||||||
|
- Must be a valid `ar` archive with `debian-binary` + `control.tar.*`.
|
||||||
|
- `Package`, `Version`, `Architecture` must be present and non-empty.
|
||||||
|
- `Architecture` must be one of the distribution's configured arches **or**
|
||||||
|
`all`.
|
||||||
|
- Version must be a valid Debian version string (basic sanity).
|
||||||
|
- No duplicate of `(repo, distro, component, name, version, arch)` — else 409.
|
||||||
|
- Reject if `.deb` size > configured max (default 1 GiB).
|
||||||
|
|
||||||
|
### Filename / pool path
|
||||||
|
- Real filename: `store/packages/<sha256>.deb`.
|
||||||
|
- Pool path: `pool/<component>/<letter>/<src>/<original-filename>` where
|
||||||
|
`<original-filename>` is the client-supplied `.deb` basename (sanitized) and
|
||||||
|
`<src>` is the `Source` package name (or `Package` name if absent), and
|
||||||
|
`<letter>` per the Debian convention above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Storage layout
|
||||||
|
|
||||||
|
```
|
||||||
|
store/
|
||||||
|
database/
|
||||||
|
sqlite.db SQLite (WAL: sqlite.db-wal, sqlite.db-shm)
|
||||||
|
packages/
|
||||||
|
<sha256>.deb content-addressed; one file per unique .deb content
|
||||||
|
```
|
||||||
|
|
||||||
|
- Only `store/packages/*.deb` are filesystem user data.
|
||||||
|
- The server creates `store/`, `store/database/`, `store/packages/` on startup
|
||||||
|
if missing.
|
||||||
|
- Paths are configurable (`store_dir`, `db_path`, `packages_dir`).
|
||||||
|
- Backups: snapshot `sqlite.db` (with WAL checkpoint) + `packages/` dir.
|
||||||
|
|
||||||
|
### Caching & invalidation (server)
|
||||||
|
- **Index cache**: in-memory, per `(repo, suite)`, generation-tagged (§8).
|
||||||
|
- **Mutation hooks**: every package/component/arch/distro mutation calls
|
||||||
|
`cache.Invalidate(repo, suite)` which bumps generation; next read rebuilds.
|
||||||
|
- No on-disk cache → restart simply rebuilds on first request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Authentication & authorization (`server/auth`)
|
||||||
|
|
||||||
|
### Identity resolution
|
||||||
|
- **REST API:** `Authorization: Bearer <token>` → SHA-256 → look up
|
||||||
|
`api_tokens` (non-revoked) → load user. Update `last_used_at` (throttled).
|
||||||
|
Missing/invalid → `401`.
|
||||||
|
- **APT endpoint (private repos):** `Authorization: Basic ...` → decode →
|
||||||
|
`password` is the token → same resolution.
|
||||||
|
|
||||||
|
### Permission helpers
|
||||||
|
- `CanRead(user, repo)`: repo public → true; else owner / member with
|
||||||
|
`read`/`write`/`read-write`/`admin` / server admin.
|
||||||
|
- `CanWrite(user, repo)`: owner / member with `write`/`read-write`/`admin` /
|
||||||
|
server admin.
|
||||||
|
- `CanManage(user, repo)`: owner / member with `admin` / server admin.
|
||||||
|
- Server admin (`users.is_admin=1`) bypasses all checks.
|
||||||
|
|
||||||
|
### Endpoint-level enforcement
|
||||||
|
- All `/api/v1/**` except `auth/register`, `auth/login`, `server/info`,
|
||||||
|
`server/pubkey` require a valid identity.
|
||||||
|
- Write operations require `CanWrite`; member management requires `CanManage`;
|
||||||
|
user-management requires server admin.
|
||||||
|
- APT read: `CanRead` (and for public repos, anonymous allowed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. REST API specification
|
||||||
|
|
||||||
|
Base: `/api/v1`. JSON in/out unless noted. All list endpoints support
|
||||||
|
`?page=&per_page=` (default 25, max 100) and return `{items, page, per_page,
|
||||||
|
total}`.
|
||||||
|
|
||||||
|
### Server / setup
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/server/info` | none | `{version, needsSetup, defaultKeyFingerprint}` |
|
||||||
|
| GET | `/server/pubkey` | none | ASCII-armored default pubkey (`Content-Type: application/pgp-keys`) |
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
| Method | Path | Auth | Body / Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/auth/register` | none | `{username, password}` → `{user, token}`. First registration → admin. Disabled once `users` non-empty? No — open registration configurable; default open. |
|
||||||
|
| POST | `/auth/login` | none | `{username, password}` → `{user, token}` (creates a session token) |
|
||||||
|
| POST | `/auth/logout` | bearer | revokes the current token |
|
||||||
|
| GET | `/me` | bearer | current user |
|
||||||
|
| GET | `/me/tokens` | bearer | list the user's tokens |
|
||||||
|
| POST | `/me/tokens` | bearer | `{name}` → `{id, prefix, token}` (plain token returned once) |
|
||||||
|
| DELETE | `/me/tokens/:id` | bearer | revoke |
|
||||||
|
|
||||||
|
> `register`/`login` both return a plain token once; the CLI stores it.
|
||||||
|
|
||||||
|
### Users (admin)
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/users` | admin | list users |
|
||||||
|
| GET | `/users/:id` | admin | get user |
|
||||||
|
| PATCH | `/users/:id` | admin | `{is_admin?}` |
|
||||||
|
| DELETE | `/users/:id` | admin | delete user (cannot delete self) |
|
||||||
|
|
||||||
|
### Repositories
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/repositories` | bearer | list repos visible to caller (owned + member + public) |
|
||||||
|
| POST | `/repositories` | bearer | `{name, visibility, description?}` → owner=caller |
|
||||||
|
| GET | `/repositories/:repo` | bearer* | repo detail + distros summary (*read check for private) |
|
||||||
|
| PATCH | `/repositories/:repo` | manage | `{visibility?, description?, name?}` |
|
||||||
|
| DELETE | `/repositories/:repo` | manage | delete repo + cascade |
|
||||||
|
| GET | `/repositories/:repo/members` | read | list members |
|
||||||
|
| POST | `/repositories/:repo/members` | manage | `{username, access}` |
|
||||||
|
| PATCH | `/repositories/:repo/members/:username` | manage | `{access}` |
|
||||||
|
| DELETE | `/repositories/:repo/members/:username` | manage | remove member |
|
||||||
|
| GET | `/repositories/:repo/pubkey` | read | ASCII-armored server pubkey (convenience) |
|
||||||
|
|
||||||
|
### Distributions / components / architectures
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/repositories/:repo/distributions` | read | list |
|
||||||
|
| POST | `/repositories/:repo/distributions` | write | `{name}` |
|
||||||
|
| DELETE | `/repositories/:repo/distributions/:dist` | write | cascade delete its packages |
|
||||||
|
| GET | `/repositories/:repo/distributions/:dist/components` | read | |
|
||||||
|
| POST | `/repositories/:repo/distributions/:dist/components` | write | `{name}` |
|
||||||
|
| DELETE | `/repositories/:repo/distributions/:dist/components/:comp` | write | cascade |
|
||||||
|
| GET | `/repositories/:repo/distributions/:dist/architectures` | read | |
|
||||||
|
| POST | `/repositories/:repo/distributions/:dist/architectures` | write | `{name}` |
|
||||||
|
| DELETE | `/repositories/:repo/distributions/:dist/architectures/:arch` | write | (reject if packages reference it and arch!=all) |
|
||||||
|
|
||||||
|
### Packages
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/repositories/:repo/distributions/:dist/packages` | read | filters: `?component=&arch=&name=&q=` |
|
||||||
|
| GET | `/repositories/:repo/packages/:id` | read | package metadata |
|
||||||
|
| POST | `/repositories/:repo/distributions/:dist/packages` | write | **multipart/form-data**: `file` = .deb, `component` = name. Parses, validates, stores. → `{package}` |
|
||||||
|
| GET | `/repositories/:repo/packages/:id/file` | read | stream the `.deb` (CLI `pull`) |
|
||||||
|
| DELETE | `/repositories/:repo/packages/:id` | write | delete version (decrements blob ref) |
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
Uniform `{error: {code, message, details?}}` with appropriate HTTP status
|
||||||
|
(`400` validation, `401` unauth, `403` forbidden, `404` not found, `409`
|
||||||
|
conflict/duplicate, `500` server).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. APT endpoint specification
|
||||||
|
|
||||||
|
Routes (see §7). Behavior summary:
|
||||||
|
- All under `/apt/:repo/...`.
|
||||||
|
- Public repo: anonymous GETs.
|
||||||
|
- Private repo: HTTP Basic required (token as password); `401` + challenge
|
||||||
|
otherwise.
|
||||||
|
- Indices generated via `shared/apt` with the in-memory cache.
|
||||||
|
- Pool files served via `http.ServeContent` (Range, ETag by sha256).
|
||||||
|
- `404` for unknown repo/suite/component/arch.
|
||||||
|
- The APT endpoint is mounted on the **same HTTP server** as the REST API, just
|
||||||
|
a different path prefix and a different auth mode (Basic vs Bearer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. CLI design (`cmd/urapt`)
|
||||||
|
|
||||||
|
### Local config & token store
|
||||||
|
- File: `~/.config/urapt/config.toml` (perm `0600`).
|
||||||
|
- Contents:
|
||||||
|
```toml
|
||||||
|
[default]
|
||||||
|
server = "https://apt.example.com"
|
||||||
|
user = "alice"
|
||||||
|
token = "urapt_..." # stored; future: OS keychain
|
||||||
|
```
|
||||||
|
- `--server`, `--user`, `--token` flags override; env `URAPT_SERVER`,
|
||||||
|
`URAPT_TOKEN` also supported.
|
||||||
|
- Future: integrate `go-keyring` for the token.
|
||||||
|
|
||||||
|
### Commands (cobra)
|
||||||
|
```
|
||||||
|
urapt version
|
||||||
|
urapt login [<server>] [--username] # prompts password; stores token
|
||||||
|
urapt logout
|
||||||
|
urapt whoami
|
||||||
|
urapt register [<server>] [--username] # create account (first → admin)
|
||||||
|
|
||||||
|
urapt token create [--name]
|
||||||
|
urapt token list
|
||||||
|
urapt token revoke <id|prefix>
|
||||||
|
|
||||||
|
urapt repo create <name> [--public|--private] [--description]
|
||||||
|
urapt repo list
|
||||||
|
urapt repo info <name>
|
||||||
|
urapt repo set-visibility <name> --public|--private
|
||||||
|
urapt repo delete <name>
|
||||||
|
urapt repo pubkey <name> [-o file]
|
||||||
|
|
||||||
|
urapt repo members list <name>
|
||||||
|
urapt repo members add <name> <username> --access=read|write|read-write|admin
|
||||||
|
urapt repo members update <name> <username> --access=...
|
||||||
|
urapt repo members remove <name> <username>
|
||||||
|
|
||||||
|
urapt distro create <repo> <distro>
|
||||||
|
urapt distro list <repo>
|
||||||
|
urapt distro delete <repo> <distro>
|
||||||
|
|
||||||
|
urapt component create <repo> <distro> <component>
|
||||||
|
urapt component list <repo> <distro>
|
||||||
|
urapt component delete <repo> <distro> <component>
|
||||||
|
|
||||||
|
urapt arch add <repo> <distro> <arch>
|
||||||
|
urapt arch list <repo> <distro>
|
||||||
|
urapt arch remove <repo> <distro> <arch>
|
||||||
|
|
||||||
|
urapt push <repo> <distro> <component> <file.deb> [--arch=<override>]
|
||||||
|
urapt pull <repo> <name>[@<version>][:<arch>] [-o file] # or by id via --id
|
||||||
|
urapt ls <repo> <distro> [--component] [--arch] [--name] [-q]
|
||||||
|
urapt show <repo> <id|name[@version][:arch]>
|
||||||
|
urapt rm <repo> <id|name[@version][:arch]>
|
||||||
|
|
||||||
|
urapt apt-config <repo> <distro> [--component=main] [--signed-by=/usr/share/keyrings/urapt.gpg]
|
||||||
|
# prints: sources.list line, pubkey install steps, and (if private) auth.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output
|
||||||
|
- Human-readable tables by default; `--json` for scripting.
|
||||||
|
- `urapt apt-config` is the key UX helper: it fetches the pubkey, prints the
|
||||||
|
signed-by sources line, and (for private repos) the `auth.conf` snippet.
|
||||||
|
|
||||||
|
### Push flow (detailed)
|
||||||
|
1. Resolve token from config.
|
||||||
|
2. Verify the `.deb` locally (`shared/deb`) to give early, clear errors.
|
||||||
|
3. `POST /repositories/:repo/distributions/:dist/packages` (multipart) with
|
||||||
|
`component` and the file.
|
||||||
|
4. Server parses/validates/stores, returns the package record.
|
||||||
|
5. CLI prints the new package's `name_version_arch` and pool path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Server configuration
|
||||||
|
|
||||||
|
Sources, precedence **low → high**: defaults → TOML file (`--config`, default
|
||||||
|
`./urapt-server.toml`) → env (`URAPT_*`) → CLI flags.
|
||||||
|
|
||||||
|
| Key | Env | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bind` | `URAPT_BIND` | `0.0.0.0:8080` | listen address |
|
||||||
|
| `base_url` | `URAPT_BASE_URL` | `http://localhost:8080` | external URL for generated apt config / pubkey links |
|
||||||
|
| `store_dir` | `URAPT_STORE_DIR` | `./store` | |
|
||||||
|
| `db_path` | `URAPT_DB_PATH` | `<store_dir>/database/sqlite.db` | |
|
||||||
|
| `packages_dir` | `URAPT_PACKAGES_DIR` | `<store_dir>/packages` | |
|
||||||
|
| `log_level` | `URAPT_LOG_LEVEL` | `info` | debug/info/warn/error |
|
||||||
|
| `signing_key_type` | `URAPT_SIGNING_KEY_TYPE` | `rsa` | |
|
||||||
|
| `signing_key_bits` | `URAPT_SIGNING_KEY_BITS` | `4096` | |
|
||||||
|
| `signing_key_user_id` | `URAPT_SIGNING_KEY_USER_ID` | `urapt-server <hostname>` | |
|
||||||
|
| `max_package_size` | `URAPT_MAX_PACKAGE_SIZE` | `1073741824` (1 GiB) | |
|
||||||
|
| `open_registration` | `URAPT_OPEN_REGISTRATION` | `true` | allow new account registration |
|
||||||
|
| `tls_enabled` | `URAPT_TLS_ENABLED` | `false` | optional built-in TLS |
|
||||||
|
| `tls_cert` / `tls_key` | … | — | paths if TLS enabled |
|
||||||
|
|
||||||
|
> For internet-facing deployments, recommend a TLS-terminating reverse proxy
|
||||||
|
> (Caddy/nginx) over built-in TLS.
|
||||||
|
|
||||||
|
### Startup sequence (`server/app`)
|
||||||
|
1. Load config.
|
||||||
|
2. Ensure `store_dir`, `db_path` parent, `packages_dir` exist.
|
||||||
|
3. Open SQLite (WAL, foreign_keys, busy_timeout); run migrations.
|
||||||
|
4. Ensure a default GPG key exists (generate if missing).
|
||||||
|
5. Build index cache; wire router (REST + APT); start HTTP server.
|
||||||
|
6. Log `base_url`, `needsSetup` (no users yet), and pubkey fingerprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Security considerations
|
||||||
|
|
||||||
|
- Passwords: bcrypt (cost 12).
|
||||||
|
- Tokens: 32-byte `crypto/rand`, base64url, prefixed `urapt_`; store only
|
||||||
|
SHA-256; `prefix` column for display; revocable; `last_used_at` tracking.
|
||||||
|
- Private repo APT reads: HTTP Basic over **TLS only** (warn if plain HTTP +
|
||||||
|
private repo).
|
||||||
|
- GPG private key in DB: documented tradeoff; future encryption-at-rest.
|
||||||
|
- File uploads: stream to a temp file, hash, then move to
|
||||||
|
`store/packages/<sha256>.deb` (no execution, no path traversal — pool paths
|
||||||
|
are generated server-side, not from client input).
|
||||||
|
- Input validation: repo/distro/component/arch names restricted to safe
|
||||||
|
charset (`[a-z0-9][a-z0-9-+.]*`, lowercase); usernames `[a-z0-9_-]{3,32}`.
|
||||||
|
- Rate limiting on `auth/login` and `auth/register` (future; note in README).
|
||||||
|
- No CORS by default (API-only; future web UI would add it).
|
||||||
|
- `ServeContent` for pool files prevents directory traversal (DB lookup by
|
||||||
|
exact `pool_path`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Testing strategy
|
||||||
|
|
||||||
|
### Unit (`shared/**`)
|
||||||
|
- `shared/crypto`: hash/verify, token round-trip.
|
||||||
|
- `shared/deb`: parse a fixture `.deb` (gzip/xz/zstd control.tar), assert
|
||||||
|
fields, hashes, sizes.
|
||||||
|
- `shared/apt`: given in-memory package rows, assert `Packages`, `Release`,
|
||||||
|
`InRelease`, `Release.gpg` byte content and checksum correctness.
|
||||||
|
- `shared/gpg`: generate key, clearsign + verify, detached sign + verify
|
||||||
|
round-trip using the same library.
|
||||||
|
|
||||||
|
### Integration (`server/**`, `cli/**`)
|
||||||
|
- `httptest.Server` + temp SQLite + temp `store/`.
|
||||||
|
- REST: register → login → create repo → add distro/component/arch → push
|
||||||
|
fixture `.deb` → list → pull → delete; permission matrix tests.
|
||||||
|
- APT: push a fixture, then `GET` `InRelease`/`Release`/`Packages`/pool and
|
||||||
|
assert content + signature verification with the server pubkey.
|
||||||
|
- Private repo: assert `401` without Basic, `200` with token Basic.
|
||||||
|
- CLI: thin tests using a fake `apiclient` (interface-based) for command
|
||||||
|
output; plus an end-to-end test spinning the server against a temp dir.
|
||||||
|
|
||||||
|
### Full apt integration (CI, optional)
|
||||||
|
- A Debian-based Docker container test: install a built `urapt-server`, push a
|
||||||
|
real `.deb`, configure apt against it, run `apt-get update && apt-get install
|
||||||
|
<pkg>`, assert the package installs. Runs in CI matrix (not required for
|
||||||
|
`go test`).
|
||||||
|
|
||||||
|
### Lint / format
|
||||||
|
- `go vet ./...`, `gofmt -l`, `golangci-lint run` (Makefile targets).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Deployment
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- Multi-stage `Dockerfile`: `golang:1.22` build → `gcr.io/distroless/static`
|
||||||
|
(or `alpine`) runtime. Expose `8080`. Volume `/data` mapped to `store_dir`.
|
||||||
|
- `docker-compose.yml`: one service, persistent volume, env for `base_url`,
|
||||||
|
reverse-proxy example.
|
||||||
|
|
||||||
|
### Releases
|
||||||
|
- `Makefile` builds `urapt-server` and `urapt` for
|
||||||
|
`linux/amd64,linux/arm64,darwin/amd64,darwin/arm64` (CGO disabled via
|
||||||
|
`modernc.org/sqlite`).
|
||||||
|
- GitHub Releases with archive + checksums (future CI).
|
||||||
|
|
||||||
|
### First-run UX (documented in README)
|
||||||
|
1. Run `urapt-server` (or docker compose up).
|
||||||
|
2. `urapt register` → you become admin.
|
||||||
|
3. `urapt repo create myrepo --public`.
|
||||||
|
4. `urapt distro create myrepo stable`.
|
||||||
|
5. `urapt component create myrepo stable main`.
|
||||||
|
6. `urapt arch add myrepo stable amd64`.
|
||||||
|
7. `urapt push myrepo stable main ./foo_1.0_amd64.deb`.
|
||||||
|
8. `urapt apt-config myrepo stable` → paste into client `/etc/apt/sources.list.d/myrepo.list`, install pubkey.
|
||||||
|
9. `sudo apt-get update && sudo apt-get install foo`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Implementation phases & tasks
|
||||||
|
|
||||||
|
Each phase ends with `go build ./...` + `go test ./...` green.
|
||||||
|
|
||||||
|
### Phase 0 — Scaffold
|
||||||
|
- [ ] `go mod init urapt`; Go 1.22.
|
||||||
|
- [ ] Directory tree (§4); empty packages with doc comments.
|
||||||
|
- [ ] `Makefile` (`build`, `test`, `vet`, `fmt`, `lint`, `run-server`, `run-cli`).
|
||||||
|
- [ ] `.gitignore` (`store/`, `*.db*`, `urapt`, `urapt-server`).
|
||||||
|
- [ ] `shared/version`, `cmd/urapt-server/main.go` + `cmd/urapt/main.go` stubs.
|
||||||
|
|
||||||
|
### Phase 1 — Shared core
|
||||||
|
- [ ] `shared/config`: struct + TOML + env + flag loading; defaults.
|
||||||
|
- [ ] `shared/db`: open sqlite (WAL/FK/busy), `embed` migrations, runner,
|
||||||
|
query helpers (`*sql.DB` wrapper).
|
||||||
|
- [ ] `migrations/0001_init.sql` (all tables in §6).
|
||||||
|
- [ ] `shared/models`: Go structs for every table.
|
||||||
|
- [ ] `shared/crypto`: bcrypt password, token gen + SHA-256, prefix.
|
||||||
|
- [ ] `shared/log`: slog wrapper.
|
||||||
|
- [ ] `shared/httputil`: JSON read/write, error rendering, bearer/basic parse.
|
||||||
|
|
||||||
|
### Phase 2 — Shared domain utilities
|
||||||
|
- [ ] `shared/gpg`: keygen, export pubkey, clearsign, detached sign (round-trip
|
||||||
|
tests).
|
||||||
|
- [ ] `shared/deb`: ar open, control.tar extract (gz/xz/zstd), control parse,
|
||||||
|
full-field extraction + hashes/size (fixture tests).
|
||||||
|
- [ ] `shared/apt`: `Packages`, `Release`, `InRelease`, `Release.gpg`
|
||||||
|
generation + cache struct (unit tests with fake rows).
|
||||||
|
|
||||||
|
### Phase 3 — Server: auth & users
|
||||||
|
- [ ] `server/app`: wiring, startup sequence, key init.
|
||||||
|
- [ ] `server/middleware`: recover, logging, bearer auth, basic auth.
|
||||||
|
- [ ] `server/auth`: identity resolution, `CanRead/Write/Manage`.
|
||||||
|
- [ ] `server/restapi`: `/server/info`, `/server/pubkey`, `/auth/register`,
|
||||||
|
`/auth/login`, `/auth/logout`, `/me`, `/me/tokens`, `/users*`.
|
||||||
|
- [ ] First-registration-is-admin logic.
|
||||||
|
|
||||||
|
### Phase 4 — Server: repos & structure
|
||||||
|
- [ ] Repositories CRUD + members CRUD + visibility + pubkey.
|
||||||
|
- [ ] Distributions / components / architectures CRUD.
|
||||||
|
- [ ] Permission enforcement on all of the above.
|
||||||
|
- [ ] Cache invalidation hooks wired.
|
||||||
|
|
||||||
|
### Phase 5 — Server: packages
|
||||||
|
- [ ] `POST .../packages`: multipart receive → `shared/deb` parse → validate →
|
||||||
|
store blob → insert row → invalidate cache.
|
||||||
|
- [ ] `GET .../packages` (list) + `GET .../packages/:id` + `GET .../file`
|
||||||
|
(stream) + `DELETE .../packages/:id` (blob refcount).
|
||||||
|
- [ ] Blob dedup + cleanup on refcount 0.
|
||||||
|
|
||||||
|
### Phase 6 — Server: APT endpoint
|
||||||
|
- [ ] `server/aptrepo`: routes (§7), cache-backed handlers, pool serving via
|
||||||
|
`ServeContent`.
|
||||||
|
- [ ] Private-repo Basic auth + `401` challenge.
|
||||||
|
- [ ] Signature of Release/InRelease with default key.
|
||||||
|
- [ ] Integration tests: push → fetch indices → verify sigs → fetch pool.
|
||||||
|
|
||||||
|
### Phase 7 — CLI: config & auth
|
||||||
|
- [ ] `cli/config`: load/save TOML, token store, flag/env overrides.
|
||||||
|
- [ ] `cli/commands`: `login`, `logout`, `whoami`, `register`, `version`,
|
||||||
|
`token *`.
|
||||||
|
- [ ] `shared/apiclient`: typed client for all endpoints used so far.
|
||||||
|
|
||||||
|
### Phase 8 — CLI: repos & structure
|
||||||
|
- [ ] `repo *`, `repo members *`, `repo pubkey`, `distro *`, `component *`,
|
||||||
|
`arch *` commands.
|
||||||
|
- [ ] Extend `shared/apiclient`.
|
||||||
|
|
||||||
|
### Phase 9 — CLI: packages & apt-config
|
||||||
|
- [ ] `push`, `pull`, `ls`, `show`, `rm`.
|
||||||
|
- [ ] `apt-config` helper (fetch pubkey, print sources + signed-by + auth.conf).
|
||||||
|
- [ ] End-to-end CLI↔server test (temp dir).
|
||||||
|
|
||||||
|
### Phase 10 — Hardening, docs, packaging
|
||||||
|
- [ ] README quickstart + architecture summary.
|
||||||
|
- [ ] `Dockerfile` (multi-stage) + `docker-compose.yml`.
|
||||||
|
- [ ] Cross-compile Makefile targets.
|
||||||
|
- [ ] golangci-lint config; fix findings.
|
||||||
|
- [ ] Optional: Debian-container apt integration test.
|
||||||
|
- [ ] Security review pass (input validation, path traversal, authz matrix).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Open / future
|
||||||
|
|
||||||
|
- **Per-repo GPG keys** (each repo its own trust root) — schema already
|
||||||
|
extensible; add `repositories.signing_key_id`.
|
||||||
|
- **GPG private key encryption at rest** (passphrase from config/env).
|
||||||
|
- **OS keychain** for CLI token (`go-keyring`).
|
||||||
|
- **Source packages** (`deb-src`, `source/` indices).
|
||||||
|
- **AppStream / `dep11`** metadata; **`Contents`** indexes.
|
||||||
|
- **`Acquire-By-Hash`** for atomic updates.
|
||||||
|
- **Web UI** (small SPA) + CORS.
|
||||||
|
- **Rate limiting** on auth endpoints.
|
||||||
|
- **Built-in TLS** (Let's Encrypt autocert option).
|
||||||
|
- **Token scopes** (per-repo, read-only tokens).
|
||||||
|
- **Webhook on package push** (for downstream CI).
|
||||||
|
- **Open registration toggle / invite-only mode** (config exists; add invites).
|
||||||
|
- **Retention policies** (keep last N versions per package).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. Glossary
|
||||||
|
|
||||||
|
- **Suite / Distribution** — e.g. `stable`, `testing`, `jammy`. The apt
|
||||||
|
`deb ... <suite> <component>` line.
|
||||||
|
- **Component** — e.g. `main`, `contrib`, `non-free`. A section within a suite.
|
||||||
|
- **Architecture** — e.g. `amd64`, `arm64`. `all` = architecture-independent.
|
||||||
|
- **Packages index** — `dists/<suite>/<component>/binary-<arch>/Packages`:
|
||||||
|
lists every `.deb` in that slice with metadata + download path.
|
||||||
|
- **Release / InRelease / Release.gpg** — suite-level metadata + checksums of
|
||||||
|
the indices, signed so apt can trust them.
|
||||||
|
- **Pool** — flat directory tree where the actual `.deb` files live
|
||||||
|
(`pool/<component>/<letter>/<src>/<file>.deb`).
|
||||||
|
- **Blob** — a content-addressed `.deb` file in `store/packages/<sha256>.deb`,
|
||||||
|
reference-counted so identical uploads deduplicate.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# urapt
|
||||||
|
|
||||||
|
A self-hostable APT repository server with a companion CLI for pushing and
|
||||||
|
managing Debian/Ubuntu `.deb` packages under your logged-in user.
|
||||||
|
|
||||||
|
urapt gives you your own `apt` server: run the server, log in with the CLI,
|
||||||
|
create repositories, and push `.deb` files. Clients configure `apt` against it
|
||||||
|
and install packages normally. Packages are stored as content-addressed files
|
||||||
|
on disk; everything else lives in a SQLite database. APT indices
|
||||||
|
(`Release`, `InRelease`, `Packages`) are generated on demand from the database
|
||||||
|
and signed with a server-managed OpenPGP key.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **`urapt-server`** (`cmd/urapt-server`) — the REST API + APT endpoint server.
|
||||||
|
- **`urapt`** (`cmd/urapt`) — the CLI for pushing packages and managing repos.
|
||||||
|
- **`shared/`** — shared utilities (config, db, models, gpg, deb parsing, apt
|
||||||
|
index generation, the typed API client) used by both server and CLI.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Run the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
./urapt-server --bind 0.0.0.0:8080 --base-url https://apt.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # see docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The server creates `store/database/sqlite.db` and `store/packages/` on first
|
||||||
|
run and generates an RSA-4096 signing key stored in the database.
|
||||||
|
|
||||||
|
### Set up the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./urapt register https://apt.example.com # first account becomes admin
|
||||||
|
./urapt repo create myrepo --public
|
||||||
|
./urapt distro create myrepo stable
|
||||||
|
./urapt component create myrepo stable main
|
||||||
|
./urapt arch add myrepo stable amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
### Push a package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./urapt push myrepo stable main ./hello_1.0.0_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure apt clients
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./urapt apt-config myrepo stable
|
||||||
|
```
|
||||||
|
|
||||||
|
This prints the exact commands to install the signing key and add the
|
||||||
|
repository, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://apt.example.com/api/v1/server/pubkey \
|
||||||
|
| sudo gpg --dearmor -o /usr/share/keyrings/urapt-myrepo.gpg
|
||||||
|
echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/urapt-myrepo.gpg] https://apt.example.com/apt/myrepo/ stable main' \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/myrepo.list
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install hello
|
||||||
|
```
|
||||||
|
|
||||||
|
For **private** repositories, `apt-config` also prints an
|
||||||
|
`/etc/apt/auth.conf.d/...` snippet using your API token as the password.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------+ +-------------------+
|
||||||
|
| urapt (CLI) | | urapt-server |
|
||||||
|
+---------+---------+ +---------+---------+
|
||||||
|
| shared/ | shared/
|
||||||
|
v v
|
||||||
|
+---------------------------------------------+
|
||||||
|
| shared/ |
|
||||||
|
| config | db | models | gpg | deb | apt | |
|
||||||
|
| crypto | api(DTOs) | apiclient | httputil |
|
||||||
|
+---------------------------------------------+
|
||||||
|
| |
|
||||||
|
+--> SQLite <---+ store/database/sqlite.db
|
||||||
|
store/packages/<sha256>.deb (files only)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The CLI never touches the DB or filesystem; it only talks to the REST API.
|
||||||
|
- Only uploaded `.deb` files are stored on disk (`store/packages/`), content-
|
||||||
|
addressed by SHA-256 and reference-counted for deduplication.
|
||||||
|
- APT indices are generated in memory from the DB and cached (invalidated on
|
||||||
|
any mutation); they are never written to disk.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
- **REST API** at `/api/v1/**` — auth, users, repositories, members,
|
||||||
|
distributions/components/architectures, packages. Bearer-token auth.
|
||||||
|
- **APT endpoint** at `/apt/:repo/**` — serves `dists/.../{Release,InRelease,
|
||||||
|
Release.gpg}`, `Packages[.gz|.xz]`, and `pool/.../*.deb`. Public repos allow
|
||||||
|
anonymous reads; private repos require HTTP Basic auth (password = API token).
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
Each repository has an owner with full access. The owner can grant `read`,
|
||||||
|
`write`, `read-write`, or `admin` to other users. Only users with access can
|
||||||
|
push. Server admins can manage everything.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Server config is loaded from defaults → TOML file (`--config`, default
|
||||||
|
`./urapt-server.toml`) → environment (`URAPT_*`) → flags. Key options:
|
||||||
|
|
||||||
|
| Key | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `bind` | `0.0.0.0:8080` | listen address |
|
||||||
|
| `base_url` | `http://localhost:8080` | external URL for apt-config output |
|
||||||
|
| `store_dir` | `./store` | data directory |
|
||||||
|
| `signing_key_bits` | `4096` | RSA signing key size |
|
||||||
|
| `max_package_size` | `1073741824` | 1 GiB upload limit |
|
||||||
|
| `open_registration` | `true` | allow new account registration |
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires Go 1.22+. CGO is not required (SQLite is the pure-Go `modernc`
|
||||||
|
driver), so binaries are static and cross-compilable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # builds urapt-server and urapt
|
||||||
|
make test # go test ./...
|
||||||
|
make vet # go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Passwords are bcrypt-hashed; API tokens are random 32-byte values stored only
|
||||||
|
as SHA-256 hashes (revocable, with a display prefix).
|
||||||
|
- The OpenPGP **private signing key is stored unencrypted in the SQLite
|
||||||
|
database**. This is acceptable when you control the database file; for
|
||||||
|
stronger protection, restrict file permissions and back up the DB securely.
|
||||||
|
Per-repo keys and key encryption-at-rest are planned.
|
||||||
|
- For internet-facing deployments, run behind a TLS-terminating reverse proxy
|
||||||
|
(Caddy/nginx). Private-repo credentials must never travel over plain HTTP.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
See `CHANGELOG.md` for release history and `PLAN.md` for the full design and
|
||||||
|
roadmap. Future work includes per-repo signing keys, key encryption-at-rest,
|
||||||
|
OS keychain token storage, source packages, AppStream metadata, and a web UI.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
urapt is free software released under the terms of the
|
||||||
|
[GNU General Public License v3.0 or later](LICENSE). See `LICENSE` for the
|
||||||
|
full text.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Root) aptConfigCmd() *cobra.Command {
|
||||||
|
var component, signedBy string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "apt-config <repo> <distro>",
|
||||||
|
Short: "Print apt client configuration (sources.list, key, and auth) for a repository",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
repoName, dist := args[0], args[1]
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
repo, err := c.GetRepository(repoName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
comps, err := c.ListComponents(repoName, dist)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
arches, err := c.ListArchitectures(repoName, dist)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := r.server()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if signedBy == "" {
|
||||||
|
signedBy = "/usr/share/keyrings/urapt-" + repoName + ".gpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
compList := component
|
||||||
|
if compList == "" {
|
||||||
|
var names []string
|
||||||
|
for _, comp := range comps {
|
||||||
|
names = append(names, comp.Name)
|
||||||
|
}
|
||||||
|
compList = strings.Join(names, " ")
|
||||||
|
}
|
||||||
|
if compList == "" {
|
||||||
|
compList = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
var archStr string
|
||||||
|
if len(arches) > 0 {
|
||||||
|
var names []string
|
||||||
|
for _, a := range arches {
|
||||||
|
names = append(names, a.Name)
|
||||||
|
}
|
||||||
|
archStr = strings.Join(names, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("# 1. Install the repository signing key:")
|
||||||
|
fmt.Printf("curl -fsSL %s/api/v1/server/pubkey | sudo gpg --dearmor -o %s\n\n", server, signedBy)
|
||||||
|
|
||||||
|
fmt.Println("# 2. Add the repository to apt:")
|
||||||
|
line := fmt.Sprintf("deb [signed-by=%s]", signedBy)
|
||||||
|
if archStr != "" {
|
||||||
|
line = fmt.Sprintf("deb [arch=%s signed-by=%s]", archStr, signedBy)
|
||||||
|
}
|
||||||
|
line += fmt.Sprintf(" %s/apt/%s/ %s %s", server, repoName, dist, compList)
|
||||||
|
fmt.Printf("echo '%s' | sudo tee /etc/apt/sources.list.d/%s.list\n\n", line, repoName)
|
||||||
|
|
||||||
|
fmt.Println("# 3. Update apt:")
|
||||||
|
fmt.Println("sudo apt update")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if repo.Visibility == models.VisibilityPrivate {
|
||||||
|
host := hostOf(server)
|
||||||
|
fmt.Println("# 4. This repository is private. Configure apt credentials:")
|
||||||
|
fmt.Printf("sudo tee /etc/apt/auth.conf.d/%s.conf <<EOF\n", repoName)
|
||||||
|
fmt.Printf("machine %s\n", host)
|
||||||
|
fmt.Printf("login %s\n", r.cfg.Default.User)
|
||||||
|
tok, _ := r.token()
|
||||||
|
fmt.Printf("password %s\n", tok)
|
||||||
|
fmt.Println("EOF")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&component, "component", "", "components to enable (default: all in distribution)")
|
||||||
|
cmd.Flags().StringVar(&signedBy, "signed-by", "", "path for the dearmored keyring")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostOf extracts the host (and port) from a server URL for auth.conf.
|
||||||
|
func hostOf(server string) string {
|
||||||
|
u, err := url.Parse(server)
|
||||||
|
if err != nil {
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
return u.Host
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"urapt/cli/interact"
|
||||||
|
"urapt/cli/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Root) loginCmd() *cobra.Command {
|
||||||
|
var username, password string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "login [server]",
|
||||||
|
Short: "Log in to a urapt server and save an API token",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
server := r.flagServer
|
||||||
|
if len(args) == 1 {
|
||||||
|
server = args[0]
|
||||||
|
}
|
||||||
|
if server == "" {
|
||||||
|
return fmt.Errorf("server URL required: pass as an argument or --server")
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
username = r.flagUser
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
var err error
|
||||||
|
username, err = interact.ReadLine("Username: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw, err := r.resolvePassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := r.unauthClient(server)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user, token, err := c.Login(username, pw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.saveProfile(server, user.Username, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(user)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Logged in as %s (server: %s)\n", user.Username, server)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&username, "username", "u", "", "username")
|
||||||
|
cmd.Flags().StringVarP(&password, "password", "p", "", "password (prompts if omitted)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) logoutCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "logout",
|
||||||
|
Short: "Revoke the current API token and clear the saved profile",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.Logout(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.clearProfile(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Logged out")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) whoamiCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "whoami",
|
||||||
|
Short: "Show the currently authenticated user",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user, err := c.Me()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(user)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s (admin: %v)\n", user.Username, user.IsAdmin)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) registerCmd() *cobra.Command {
|
||||||
|
var username, password string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "register [server]",
|
||||||
|
Short: "Create a new account on a urapt server (first account becomes admin)",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
server := r.flagServer
|
||||||
|
if len(args) == 1 {
|
||||||
|
server = args[0]
|
||||||
|
}
|
||||||
|
if server == "" {
|
||||||
|
return fmt.Errorf("server URL required: pass as an argument or --server")
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
username = r.flagUser
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
var err error
|
||||||
|
username, err = interact.ReadLine("Username: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw, err := r.resolvePassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := r.unauthClient(server)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user, token, err := c.Register(username, pw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.saveProfile(server, user.Username, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(user)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Registered and logged in as %s (admin: %v)\n", user.Username, user.IsAdmin)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&username, "username", "u", "", "username")
|
||||||
|
cmd.Flags().StringVarP(&password, "password", "p", "", "password (prompts if omitted)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) tokenCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "token",
|
||||||
|
Short: "Manage API tokens",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
r.tokenCreateCmd(),
|
||||||
|
r.tokenListCmd(),
|
||||||
|
r.tokenRevokeCmd(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) tokenCreateCmd() *cobra.Command {
|
||||||
|
var name string
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a new API token",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
client, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t, err := client.CreateToken(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(t)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Token created (name: %s)\n", t.Name)
|
||||||
|
fmt.Printf(" %s\n", t.Token)
|
||||||
|
fmt.Println(" Save this token; it will not be shown again.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Flags().StringVarP(&name, "name", "n", "default", "token label")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) tokenListCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List your API tokens",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
client, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tokens, err := client.ListTokens()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(tokens)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
fmt.Println("No tokens.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%-12s %-20s %-10s %s\n", "PREFIX", "NAME", "CREATED", "STATUS")
|
||||||
|
for _, t := range tokens {
|
||||||
|
status := "active"
|
||||||
|
if t.RevokedAt != nil {
|
||||||
|
status = "revoked"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-12s %-20s %-10s %s\n", t.Prefix, t.Name, t.CreatedAt[:10], status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) tokenRevokeCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "revoke <id>",
|
||||||
|
Short: "Revoke an API token by id",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
client, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.RevokeToken(args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Token revoked")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"urapt/cli/interact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolvePassword returns the password from a flag, the URAPT_PASSWORD env
|
||||||
|
// variable, or an interactive prompt (in that order).
|
||||||
|
func (r *Root) resolvePassword(flag string) (string, error) {
|
||||||
|
if flag != "" {
|
||||||
|
return flag, nil
|
||||||
|
}
|
||||||
|
if v := os.Getenv("URAPT_PASSWORD"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return interact.ReadPassword("Password: ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"urapt/cli/output"
|
||||||
|
"urapt/shared/apiclient"
|
||||||
|
"urapt/shared/deb"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Root) pushCmd() *cobra.Command {
|
||||||
|
var archOverride string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "push <repo> <distro> <component> <file.deb>",
|
||||||
|
Short: "Upload a .deb package to a repository",
|
||||||
|
Args: cobra.ExactArgs(4),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
repo, dist, component, file := args[0], args[1], args[2], args[3]
|
||||||
|
|
||||||
|
// Local pre-validation for early, clear errors.
|
||||||
|
inspected, err := deb.Inspect(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid .deb: %w", err)
|
||||||
|
}
|
||||||
|
ctrl := inspected.Control
|
||||||
|
pkgName, ver, arch := ctrl.Get("Package"), ctrl.Get("Version"), ctrl.Get("Architecture")
|
||||||
|
if archOverride != "" {
|
||||||
|
arch = archOverride
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
// no-op: keep flag accepted
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Pushing %s_%s_%s (%d bytes)\n", pkgName, ver, arch, inspected.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pkg, err := c.PushPackage(repo, dist, component, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(pkg)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Pushed %s_%s_%s to %s/%s/%s\n", pkg.Name, pkg.Version, pkg.Architecture, repo, dist, component)
|
||||||
|
fmt.Printf(" id: %s\n", pkg.ID)
|
||||||
|
fmt.Printf(" pool: %s\n", pkg.PoolPath)
|
||||||
|
fmt.Printf(" sha256: %s\n", pkg.SHA256)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&archOverride, "arch", "", "override architecture (rarely needed)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) lsCmd() *cobra.Command {
|
||||||
|
var component, arch, name, query string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls <repo> <distro>",
|
||||||
|
Short: "List packages in a repository/distribution",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.ListPackages(args[0], args[1], map[string]string{
|
||||||
|
"component": component, "arch": arch, "name": name, "q": query,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(resp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(resp.Items) == 0 {
|
||||||
|
fmt.Println("No packages.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%-38s %-20s %-12s %-10s %s\n", "ID", "NAME", "VERSION", "ARCH", "SIZE")
|
||||||
|
for _, p := range resp.Items {
|
||||||
|
fmt.Printf("%-38s %-20s %-12s %-10s %d\n", shortID(p.ID), p.Name, p.Version, p.Architecture, p.Size)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d package(s)\n", resp.Total)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&component, "component", "", "filter by component")
|
||||||
|
cmd.Flags().StringVar(&arch, "arch", "", "filter by architecture")
|
||||||
|
cmd.Flags().StringVar(&name, "name", "", "filter by exact name")
|
||||||
|
cmd.Flags().StringVarP(&query, "query", "q", "", "substring search on name/description")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) showCmd() *cobra.Command {
|
||||||
|
var dist string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "show <repo> <id|name[@version][:arch]>",
|
||||||
|
Short: "Show package metadata",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := r.resolvePackageID(c, args[0], dist, args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p, err := c.GetPackage(args[0], id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
printPackage(p)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&dist, "dist", "", "distribution (required for name-based specs)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) pullCmd() *cobra.Command {
|
||||||
|
var dist, outFile string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "pull <repo> <id|name[@version][:arch]>",
|
||||||
|
Short: "Download a package's .deb file",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := r.resolvePackageID(c, args[0], dist, args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pkg, err := c.GetPackage(args[0], id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := outFile
|
||||||
|
if out == "" {
|
||||||
|
out = baseName(pkg.PoolPath)
|
||||||
|
}
|
||||||
|
if err := c.DownloadPackage(args[0], id, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outFile != "-" && !r.flagJSON {
|
||||||
|
fmt.Printf("Downloaded %s\n", out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&dist, "dist", "", "distribution (required for name-based specs)")
|
||||||
|
cmd.Flags().StringVarP(&outFile, "out", "o", "", "output file (default: original filename; - for stdout)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) rmCmd() *cobra.Command {
|
||||||
|
var dist string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm <repo> <id|name[@version][:arch]>",
|
||||||
|
Short: "Delete a package",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := r.resolvePackageID(c, args[0], dist, args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.DeletePackage(args[0], id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted package %s\n", id)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&dist, "dist", "", "distribution (required for name-based specs)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePackageID resolves a spec ("id" or "name[@version][:arch]") to a
|
||||||
|
// package id. UUID specs are returned as-is. Name specs require --dist.
|
||||||
|
func (r *Root) resolvePackageID(c *apiclient.Client, repo, dist, spec string) (string, error) {
|
||||||
|
id, name, version, arch := apiclient.ParsePackageSpec(spec)
|
||||||
|
if id != "" {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
if dist == "" {
|
||||||
|
return "", fmt.Errorf("name-based spec %q requires --dist", spec)
|
||||||
|
}
|
||||||
|
filters := map[string]string{"name": name}
|
||||||
|
resp, err := c.ListPackages(repo, dist, filters)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, p := range resp.Items {
|
||||||
|
if version != "" && p.Version != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if arch != "" && p.Architecture != arch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return p.ID, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no package matching %s in %s/%s", spec, repo, dist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPackage prints a package's metadata in a readable key: value form.
|
||||||
|
func printPackage(p *models.Package) {
|
||||||
|
fmt.Printf("id: %s\n", p.ID)
|
||||||
|
fmt.Printf("name: %s\n", p.Name)
|
||||||
|
fmt.Printf("version: %s\n", p.Version)
|
||||||
|
fmt.Printf("architecture: %s\n", p.Architecture)
|
||||||
|
fmt.Printf("source: %s\n", p.Source)
|
||||||
|
fmt.Printf("maintainer: %s\n", p.Maintainer)
|
||||||
|
fmt.Printf("section: %s\n", p.Section)
|
||||||
|
fmt.Printf("priority: %s\n", p.Priority)
|
||||||
|
fmt.Printf("homepage: %s\n", p.Homepage)
|
||||||
|
fmt.Printf("depends: %s\n", p.Depends)
|
||||||
|
fmt.Printf("description: %s\n", p.Description)
|
||||||
|
fmt.Printf("pool_path: %s\n", p.PoolPath)
|
||||||
|
fmt.Printf("size: %d\n", p.Size)
|
||||||
|
fmt.Printf("sha256: %s\n", p.SHA256)
|
||||||
|
fmt.Printf("created_at: %s\n", p.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortID returns the first 8 chars of a UUID for compact display.
|
||||||
|
func shortID(id string) string {
|
||||||
|
if len(id) >= 8 {
|
||||||
|
return id[:8]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseName returns the last path segment.
|
||||||
|
func baseName(p string) string {
|
||||||
|
if i := strings.LastIndexByte(p, '/'); i >= 0 {
|
||||||
|
return p[i+1:]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"urapt/cli/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Root) repoCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "repo",
|
||||||
|
Short: "Manage repositories",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
r.repoCreateCmd(),
|
||||||
|
r.repoListCmd(),
|
||||||
|
r.repoInfoCmd(),
|
||||||
|
r.repoSetVisibilityCmd(),
|
||||||
|
r.repoDeleteCmd(),
|
||||||
|
r.repoPubkeyCmd(),
|
||||||
|
r.repoMembersCmd(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoCreateCmd() *cobra.Command {
|
||||||
|
var visibility, description string
|
||||||
|
var public, private bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create <name>",
|
||||||
|
Short: "Create a new repository",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
vis := visibility
|
||||||
|
if public {
|
||||||
|
vis = "public"
|
||||||
|
} else if private {
|
||||||
|
vis = "private"
|
||||||
|
}
|
||||||
|
if vis == "" {
|
||||||
|
vis = "private"
|
||||||
|
}
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
repo, err := c.CreateRepository(args[0], vis, description)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(repo)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Created repository %s (%s)\n", repo.Name, repo.Visibility)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&visibility, "visibility", "", "public or private")
|
||||||
|
cmd.Flags().BoolVar(&public, "public", false, "shorthand for --visibility=public")
|
||||||
|
cmd.Flags().BoolVar(&private, "private", false, "shorthand for --visibility=private")
|
||||||
|
cmd.Flags().StringVarP(&description, "description", "d", "", "repository description")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List repositories visible to you",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
repos, err := c.ListRepositories()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(repos)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(repos) == 0 {
|
||||||
|
fmt.Println("No repositories.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s %-10s %s\n", "NAME", "VISIBILITY", "DESCRIPTION")
|
||||||
|
for _, repo := range repos {
|
||||||
|
fmt.Printf("%-20s %-10s %s\n", repo.Name, repo.Visibility, repo.Description)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoInfoCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "info <name>",
|
||||||
|
Short: "Show details of a repository",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
repo, err := c.GetRepository(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(repo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("name: %s\n", repo.Name)
|
||||||
|
fmt.Printf("visibility: %s\n", repo.Visibility)
|
||||||
|
fmt.Printf("description: %s\n", repo.Description)
|
||||||
|
fmt.Printf("created: %s\n", repo.CreatedAt)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoSetVisibilityCmd() *cobra.Command {
|
||||||
|
var public, private bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "set-visibility <name>",
|
||||||
|
Short: "Change a repository's visibility",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
vis := ""
|
||||||
|
if public {
|
||||||
|
vis = "public"
|
||||||
|
} else if private {
|
||||||
|
vis = "private"
|
||||||
|
}
|
||||||
|
if vis == "" {
|
||||||
|
return fmt.Errorf("pass --public or --private")
|
||||||
|
}
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
repo, err := c.UpdateRepository(args[0], nil, &vis, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(repo)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Updated %s: visibility=%s\n", repo.Name, repo.Visibility)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&public, "public", false, "make public")
|
||||||
|
cmd.Flags().BoolVar(&private, "private", false, "make private")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "delete <name>",
|
||||||
|
Short: "Delete a repository and all its packages",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.DeleteRepository(args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted repository %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoPubkeyCmd() *cobra.Command {
|
||||||
|
var outFile string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "pubkey <name>",
|
||||||
|
Short: "Print the server's armored public key for a repository",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, err := c.RepositoryPubkey(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outFile != "" {
|
||||||
|
return os.WriteFile(outFile, []byte(pub), 0o644)
|
||||||
|
}
|
||||||
|
fmt.Print(pub)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&outFile, "out", "o", "", "write to file instead of stdout")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoMembersCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "members",
|
||||||
|
Short: "Manage repository members",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
r.repoMembersListCmd(),
|
||||||
|
r.repoMembersAddCmd(),
|
||||||
|
r.repoMembersUpdateCmd(),
|
||||||
|
r.repoMembersRemoveCmd(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoMembersListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list <repo>",
|
||||||
|
Short: "List members of a repository",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
members, err := c.ListMembers(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(members)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s %s\n", "USER", "ACCESS")
|
||||||
|
for _, m := range members {
|
||||||
|
fmt.Printf("%-20s %s\n", m.User.Username, m.Access)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoMembersAddCmd() *cobra.Command {
|
||||||
|
var access string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add <repo> <username>",
|
||||||
|
Short: "Grant a user access to a repository",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := c.AddMember(args[0], args[1], access)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(m)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Granted %s access=%s on %s\n", m.User.Username, m.Access, args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&access, "access", "a", "read", "read, write, read-write, or admin")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoMembersUpdateCmd() *cobra.Command {
|
||||||
|
var access string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update <repo> <username>",
|
||||||
|
Short: "Change a member's access level",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := c.UpdateMember(args[0], args[1], access)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(m)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Updated %s access=%s on %s\n", m.User.Username, m.Access, args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&access, "access", "a", "", "read, write, read-write, or admin")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) repoMembersRemoveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove <repo> <username>",
|
||||||
|
Short: "Revoke a user's access to a repository",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.RemoveMember(args[0], args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Removed %s from %s\n", args[1], args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Package commands implements the urapt CLI command tree using cobra.
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"urapt/cli/config"
|
||||||
|
"urapt/shared/apiclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Root holds the urapt CLI runtime state: the version, loaded config, and
|
||||||
|
// flag overrides. It builds and executes the cobra command tree.
|
||||||
|
type Root struct {
|
||||||
|
version string
|
||||||
|
|
||||||
|
cfg *config.File
|
||||||
|
|
||||||
|
flagServer string
|
||||||
|
flagUser string
|
||||||
|
flagToken string
|
||||||
|
flagJSON bool
|
||||||
|
|
||||||
|
rootCmd *cobra.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the CLI root.
|
||||||
|
func New(version string) *Root {
|
||||||
|
r := &Root{version: version}
|
||||||
|
r.build()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the command tree with the given args.
|
||||||
|
func (r *Root) Execute(args []string) error {
|
||||||
|
r.rootCmd.SetArgs(args)
|
||||||
|
err := r.rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// build constructs the cobra root and attaches subcommands.
|
||||||
|
func (r *Root) build() {
|
||||||
|
r.rootCmd = &cobra.Command{
|
||||||
|
Use: "urapt",
|
||||||
|
Short: "urapt is a CLI for managing packages on a self-hosted urapt APT server",
|
||||||
|
Long: "urapt pushes and manages Debian packages on a self-hosted urapt\n" +
|
||||||
|
"server. Log in once, then push .deb files to your repositories.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
}
|
||||||
|
r.rootCmd.PersistentFlags().StringVar(&r.flagServer, "server", "", "urapt server URL (overrides config)")
|
||||||
|
r.rootCmd.PersistentFlags().StringVar(&r.flagUser, "user", "", "username (overrides config)")
|
||||||
|
r.rootCmd.PersistentFlags().StringVar(&r.flagToken, "token", "", "API token (overrides config)")
|
||||||
|
r.rootCmd.PersistentFlags().BoolVar(&r.flagJSON, "json", false, "output JSON")
|
||||||
|
|
||||||
|
r.rootCmd.AddCommand(
|
||||||
|
r.versionCmd(),
|
||||||
|
r.loginCmd(),
|
||||||
|
r.logoutCmd(),
|
||||||
|
r.whoamiCmd(),
|
||||||
|
r.registerCmd(),
|
||||||
|
r.tokenCmd(),
|
||||||
|
r.repoCmd(),
|
||||||
|
r.distroCmd(),
|
||||||
|
r.componentCmd(),
|
||||||
|
r.archCmd(),
|
||||||
|
r.pushCmd(),
|
||||||
|
r.pullCmd(),
|
||||||
|
r.lsCmd(),
|
||||||
|
r.showCmd(),
|
||||||
|
r.rmCmd(),
|
||||||
|
r.aptConfigCmd(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig lazily loads the CLI config file (once).
|
||||||
|
func (r *Root) loadConfig() error {
|
||||||
|
if r.cfg != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.cfg = cfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// server resolves the effective server URL (flag > env > config).
|
||||||
|
func (r *Root) server() (string, error) {
|
||||||
|
if err := r.loadConfig(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if r.flagServer != "" {
|
||||||
|
return r.flagServer, nil
|
||||||
|
}
|
||||||
|
if v := os.Getenv("URAPT_SERVER"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if r.cfg.Default.Server != "" {
|
||||||
|
return r.cfg.Default.Server, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no server configured: run `urapt login <server>` or pass --server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// token resolves the effective API token (flag > env > config).
|
||||||
|
func (r *Root) token() (string, error) {
|
||||||
|
if r.flagToken != "" {
|
||||||
|
return r.flagToken, nil
|
||||||
|
}
|
||||||
|
if v := os.Getenv("URAPT_TOKEN"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if err := r.loadConfig(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if r.cfg.Default.Token != "" {
|
||||||
|
return r.cfg.Default.Token, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("not logged in: run `urapt login`")
|
||||||
|
}
|
||||||
|
|
||||||
|
// client builds an authenticated client using the resolved server + token.
|
||||||
|
func (r *Root) client() (*apiclient.Client, error) {
|
||||||
|
server, err := r.server()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tok, _ := r.token()
|
||||||
|
return apiclient.New(server, tok), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unauthClient builds a client with only the server resolved (for login/register).
|
||||||
|
func (r *Root) unauthClient(server string) (*apiclient.Client, error) {
|
||||||
|
if server == "" {
|
||||||
|
var err error
|
||||||
|
server, err = r.server()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apiclient.New(server, ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveProfile persists the given server/user/token as the default profile.
|
||||||
|
func (r *Root) saveProfile(server, user, token string) error {
|
||||||
|
if err := r.loadConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.cfg.Default.Server = server
|
||||||
|
r.cfg.Default.User = user
|
||||||
|
r.cfg.Default.Token = token
|
||||||
|
return config.Save(r.cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearProfile removes the stored token (used by logout).
|
||||||
|
func (r *Root) clearProfile() error {
|
||||||
|
if err := r.loadConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.cfg.Default.Token = ""
|
||||||
|
return config.Save(r.cfg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"urapt/cli/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Root) distroCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "distro",
|
||||||
|
Short: "Manage distributions (suites) within a repository",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(r.distroCreateCmd(), r.distroListCmd(), r.distroDeleteCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) distroCreateCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "create <repo> <distro>",
|
||||||
|
Short: "Add a distribution to a repository",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, err := c.CreateDistribution(args[0], args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(d)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Created distribution %s in %s\n", d.Name, args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) distroListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list <repo>",
|
||||||
|
Short: "List distributions in a repository",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dists, err := c.ListDistributions(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(dists)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, d := range dists {
|
||||||
|
fmt.Println(d.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) distroDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "delete <repo> <distro>",
|
||||||
|
Short: "Delete a distribution and its packages",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.DeleteDistribution(args[0], args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted distribution %s in %s\n", args[1], args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) componentCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "component",
|
||||||
|
Short: "Manage components within a distribution",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(r.componentCreateCmd(), r.componentListCmd(), r.componentDeleteCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) componentCreateCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "create <repo> <distro> <component>",
|
||||||
|
Short: "Add a component to a distribution",
|
||||||
|
Args: cobra.ExactArgs(3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
comp, err := c.CreateComponent(args[0], args[1], args[2])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(comp)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Created component %s in %s/%s\n", comp.Name, args[0], args[1])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) componentListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list <repo> <distro>",
|
||||||
|
Short: "List components in a distribution",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
comps, err := c.ListComponents(args[0], args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(comps)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, comp := range comps {
|
||||||
|
fmt.Println(comp.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) componentDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "delete <repo> <distro> <component>",
|
||||||
|
Short: "Delete a component",
|
||||||
|
Args: cobra.ExactArgs(3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.DeleteComponent(args[0], args[1], args[2]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted component %s in %s/%s\n", args[2], args[0], args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) archCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "arch",
|
||||||
|
Short: "Manage architectures within a distribution",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(r.archAddCmd(), r.archListCmd(), r.archRemoveCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) archAddCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "add <repo> <distro> <arch>",
|
||||||
|
Short: "Add an architecture to a distribution",
|
||||||
|
Args: cobra.ExactArgs(3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a, err := c.CreateArchitecture(args[0], args[1], args[2])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(a)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Added architecture %s to %s/%s\n", a.Name, args[0], args[1])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) archListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list <repo> <distro>",
|
||||||
|
Short: "List architectures in a distribution",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
arches, err := c.ListArchitectures(args[0], args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.flagJSON {
|
||||||
|
output.JSON(arches)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, a := range arches {
|
||||||
|
fmt.Println(a.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Root) archRemoveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove <repo> <distro> <arch>",
|
||||||
|
Short: "Remove an architecture from a distribution",
|
||||||
|
Args: cobra.ExactArgs(3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := r.client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.DeleteArchitecture(args[0], args[1], args[2]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Removed architecture %s from %s/%s\n", args[2], args[0], args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Root) versionCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print the urapt CLI version",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Println(r.version)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Package config manages the urapt CLI's local configuration: the server URL,
|
||||||
|
// username, and API token stored in ~/.config/urapt/config.toml (perm 0600).
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// File is the on-disk CLI config shape.
|
||||||
|
type File struct {
|
||||||
|
Default Profile `toml:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile is the active connection profile.
|
||||||
|
type Profile struct {
|
||||||
|
Server string `toml:"server"`
|
||||||
|
User string `toml:"user"`
|
||||||
|
Token string `toml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// configPath returns the path to the CLI config file.
|
||||||
|
func configPath() (string, error) {
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = os.Getenv("HOME")
|
||||||
|
dir = filepath.Join(dir, ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "urapt", "config.toml"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the CLI config, returning an empty config if none exists.
|
||||||
|
func Load() (*File, error) {
|
||||||
|
path, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &File{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
var f File
|
||||||
|
if err := toml.Unmarshal(data, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
f.Default.Server = strings.TrimRight(f.Default.Server, "/")
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the CLI config with 0600 permissions.
|
||||||
|
func Save(f *File) error {
|
||||||
|
path, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
enc := toml.NewEncoder(&buf)
|
||||||
|
if err := enc.Encode(f); err != nil {
|
||||||
|
return fmt.Errorf("encode config: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(buf.String()), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the config file path (for display).
|
||||||
|
func Path() (string, error) { return configPath() }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Package interact provides simple interactive prompts (passwords, confirms).
|
||||||
|
package interact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadPassword prompts for a password with input hidden.
|
||||||
|
func ReadPassword(prompt string) (string, error) {
|
||||||
|
fmt.Fprint(os.Stderr, prompt)
|
||||||
|
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLine prompts and reads a single line of input.
|
||||||
|
func ReadLine(prompt string) (string, error) {
|
||||||
|
fmt.Fprint(os.Stderr, prompt)
|
||||||
|
var s string
|
||||||
|
if _, err := fmt.Fscanln(os.Stdin, &s); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm prompts a yes/no question, returning the boolean answer.
|
||||||
|
func Confirm(prompt string) bool {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s [y/N]: ", prompt)
|
||||||
|
var s string
|
||||||
|
fmt.Fscanln(os.Stdin, &s)
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
return s == "y" || s == "yes"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Package output provides small formatting helpers for CLI commands.
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSON prints v as indented JSON.
|
||||||
|
func JSON(v any) {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printf is a thin wrapper around fmt.Printf.
|
||||||
|
func Printf(format string, args ...any) { fmt.Printf(format, args...) }
|
||||||
|
|
||||||
|
// Println prints a line.
|
||||||
|
func Println(args ...any) { fmt.Println(args...) }
|
||||||
|
|
||||||
|
// Errorf prints to stderr.
|
||||||
|
func Errorf(format string, args ...any) { fmt.Fprintf(os.Stderr, format, args...) }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Package main is the urapt-server entrypoint.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"urapt/server/app"
|
||||||
|
"urapt/shared/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
a := app.New(os.Args[1:], version.Version)
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := a.Run(ctx); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Package main is the urapt CLI entrypoint.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"urapt/cli/commands"
|
||||||
|
"urapt/shared/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := commands.New(version.Version).Execute(os.Args[1:]); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
urapt-server:
|
||||||
|
build: .
|
||||||
|
image: urapt-server:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- urapt-data:/data
|
||||||
|
environment:
|
||||||
|
URAPT_BIND: "0.0.0.0:8080"
|
||||||
|
URAPT_BASE_URL: "http://localhost:8080"
|
||||||
|
URAPT_STORE_DIR: "/data/store"
|
||||||
|
# URAPT_OPEN_REGISTRATION: "true"
|
||||||
|
# URAPT_SIGNING_KEY_USER_ID: "urapt-server <apt.example.com>"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
urapt-data:
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
module urapt
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
modernc.org/sqlite v1.53.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cloudflare/circl v1.6.2 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/term v0.44.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
|
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
||||||
|
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Package app wires the urapt-server dependencies and runs the HTTP server.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"urapt/server/aptrepo"
|
||||||
|
"urapt/server/auth"
|
||||||
|
"urapt/server/cache"
|
||||||
|
"urapt/server/restapi"
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/config"
|
||||||
|
"urapt/shared/db"
|
||||||
|
"urapt/shared/gpg"
|
||||||
|
"urapt/shared/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App holds the server configuration and runtime dependencies.
|
||||||
|
type App struct {
|
||||||
|
args []string
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs an App from the given command-line args and version string.
|
||||||
|
func New(args []string, version string) *App {
|
||||||
|
return &App{args: args, version: version}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signerProvider implements restapi.SignerProvider backed by a *gpg.Key.
|
||||||
|
type signerProvider struct {
|
||||||
|
key *gpg.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *signerProvider) PublicKeyArmored() (string, error) {
|
||||||
|
if s.key == nil {
|
||||||
|
return "", fmt.Errorf("no key")
|
||||||
|
}
|
||||||
|
return s.key.ArmoredPublic()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *signerProvider) Fingerprint() string {
|
||||||
|
if s.key == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.key.Fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns the signing key for the APT endpoint (Phase 6).
|
||||||
|
func (s *signerProvider) Key() *gpg.Key { return s.key }
|
||||||
|
|
||||||
|
// Run loads config, opens the database, ensures a signing key, and serves
|
||||||
|
// HTTP until the context is cancelled.
|
||||||
|
func (a *App) Run(ctx context.Context) error {
|
||||||
|
cfg, err := a.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger := log.New(cfg.LogLevel)
|
||||||
|
logger.Info("starting urapt-server", "version", a.version, "bind", cfg.Bind, "base_url", cfg.BaseURL)
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
logger.Error("invalid config", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureDirs(cfg); err != nil {
|
||||||
|
logger.Error("setup store dirs", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := db.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("open database", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
st := store.New(database)
|
||||||
|
authSvc := auth.NewService(st)
|
||||||
|
|
||||||
|
signer, err := ensureSigningKey(ctx, st, cfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("ensure signing key", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sp := &signerProvider{key: signer}
|
||||||
|
logger.Info("signing key ready", "fingerprint", sp.Fingerprint())
|
||||||
|
|
||||||
|
idxCache := cache.New()
|
||||||
|
|
||||||
|
root := chi.NewRouter()
|
||||||
|
root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache))
|
||||||
|
root.Mount("/apt", aptrepo.New(st, authSvc, sp.Key(), idxCache))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Bind,
|
||||||
|
Handler: root,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logger.Info("listening", "addr", cfg.Bind)
|
||||||
|
if cfg.TLSEnabled {
|
||||||
|
errCh <- srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey)
|
||||||
|
} else {
|
||||||
|
errCh <- srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info("shutting down")
|
||||||
|
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return srv.Shutdown(shutCtx)
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
logger.Error("server error", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig parses flags and builds the effective Config.
|
||||||
|
func (a *App) loadConfig() (config.Config, error) {
|
||||||
|
fs := flag.NewFlagSet("urapt-server", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
configPath := fs.String("config", config.Defaults.ConfigPath, "path to config file")
|
||||||
|
fs.String("bind", "", "bind address")
|
||||||
|
fs.String("base-url", "", "external base URL")
|
||||||
|
fs.String("store-dir", "", "store directory")
|
||||||
|
fs.String("db-path", "", "sqlite db path")
|
||||||
|
fs.String("packages-dir", "", "packages directory")
|
||||||
|
fs.String("log-level", "", "log level")
|
||||||
|
fs.String("signing-key-type", "", "signing key type")
|
||||||
|
fs.Int("signing-key-bits", 0, "signing key bits")
|
||||||
|
fs.String("signing-key-user-id", "", "signing key user id")
|
||||||
|
fs.Int64("max-package-size", 0, "max package size in bytes")
|
||||||
|
fs.Bool("open-registration", true, "allow new account registration")
|
||||||
|
fs.Bool("tls-enabled", false, "enable TLS")
|
||||||
|
fs.String("tls-cert", "", "TLS cert path")
|
||||||
|
fs.String("tls-key", "", "TLS key path")
|
||||||
|
if err := fs.Parse(a.args); err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := map[string]string{}
|
||||||
|
fs.Visit(func(f *flag.Flag) {
|
||||||
|
flags[f.Name] = f.Value.String()
|
||||||
|
})
|
||||||
|
return config.Load(*configPath, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDirs creates the store, database, and packages directories.
|
||||||
|
func ensureDirs(cfg config.Config) error {
|
||||||
|
for _, dir := range []string{cfg.StoreDir, filepath.Dir(cfg.DBPath), cfg.PackagesDir} {
|
||||||
|
if dir == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSigningKey loads the default key from the DB or generates one.
|
||||||
|
func ensureSigningKey(ctx context.Context, st *store.Store, cfg config.Config) (*gpg.Key, error) {
|
||||||
|
existing, err := st.GetDefaultGPGKey(ctx)
|
||||||
|
if err == nil {
|
||||||
|
key, err := gpg.ParseArmoredPrivate(existing.PrivateKeyArmored)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse stored key: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("load key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := gpg.GenerateKey(cfg.SigningKeyUserID, cfg.SigningKeyBits)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate key: %w", err)
|
||||||
|
}
|
||||||
|
pub, err := key.ArmoredPublic()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
priv, err := key.ArmoredPrivate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := st.SaveGPGKey(ctx, key.Fingerprint, key.UserID, pub, priv, true); err != nil {
|
||||||
|
return nil, fmt.Errorf("save key: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdownTimeout for graceful HTTP shutdown.
|
||||||
|
const shutdownTimeout = 30 * time.Second
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// Package aptrepo implements the APT repository endpoint served under /apt/:repo.
|
||||||
|
// It generates Release/InRelease/Packages indices on demand from the database
|
||||||
|
// (cached in memory) and streams .deb files from the content-addressed store.
|
||||||
|
// Public repositories allow anonymous reads; private repositories require HTTP
|
||||||
|
// Basic auth where the password is an API token.
|
||||||
|
package aptrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"urapt/server/auth"
|
||||||
|
"urapt/server/cache"
|
||||||
|
"urapt/server/middleware"
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/apt"
|
||||||
|
"urapt/shared/gpg"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APTRepo is the APT endpoint handler.
|
||||||
|
type APTRepo struct {
|
||||||
|
Store *store.Store
|
||||||
|
Auth *auth.Service
|
||||||
|
Signer *gpg.Key
|
||||||
|
Cache *cache.IndexCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the APT endpoint http.Handler (to be mounted at /apt).
|
||||||
|
func New(st *store.Store, authSvc *auth.Service, signer *gpg.Key, c *cache.IndexCache) http.Handler {
|
||||||
|
a := &APTRepo{Store: st, Auth: authSvc, Signer: signer, Cache: c}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Recover)
|
||||||
|
r.Use(middleware.Log)
|
||||||
|
|
||||||
|
r.Get("/{repo}/dists/{suite}/InRelease", a.InRelease)
|
||||||
|
r.Get("/{repo}/dists/{suite}/Release", a.Release)
|
||||||
|
r.Get("/{repo}/dists/{suite}/Release.gpg", a.ReleaseGpg)
|
||||||
|
r.Get("/{repo}/dists/{suite}/{component}/{binary}/Packages", a.Packages)
|
||||||
|
r.Get("/{repo}/dists/{suite}/{component}/{binary}/Packages.gz", a.PackagesGz)
|
||||||
|
r.Get("/{repo}/dists/{suite}/{component}/{binary}/Packages.xz", a.PackagesXz)
|
||||||
|
r.Get("/{repo}/pool/{component}/{letter}/{src}/{filename}", a.Pool)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorize loads the repository and enforces read access. For public repos
|
||||||
|
// anonymous access is allowed; for private repos HTTP Basic (password = token)
|
||||||
|
// is required. Returns the repo and true on success; on failure an error has
|
||||||
|
// been written.
|
||||||
|
func (a *APTRepo) authorize(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||||
|
repo, err := a.Store.GetRepositoryByName(r.Context(), r.PathValue("repo"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "repository not found")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read repository")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if repo.Visibility == models.VisibilityPublic {
|
||||||
|
return repo, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private repository: require HTTP Basic with token as password.
|
||||||
|
id, err := a.Auth.ResolveBasic(r.Context(), r.Header)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ChallengeBasic(w, "urapt "+repo.Name)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ok, err := a.Auth.CanRead(r.Context(), id.User, repo)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
httputil.ChallengeBasic(w, "urapt "+repo.Name)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return repo, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSuite returns the generated indices for (repo, suite), building and
|
||||||
|
// caching them on first access or after invalidation.
|
||||||
|
func (a *APTRepo) loadSuite(r *http.Request, repo *models.Repository, suiteName string) (*apt.Indices, error) {
|
||||||
|
if idx := a.Cache.Get(repo.ID, suiteName); idx != nil {
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dist, err := a.Store.GetDistributionByName(r.Context(), repo.ID, suiteName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errMiss{err}
|
||||||
|
}
|
||||||
|
comps, err := a.Store.ListComponents(r.Context(), dist.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arches, err := a.Store.ListArchitectures(r.Context(), dist.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
suitePkgs, err := a.Store.ListSuitePackages(r.Context(), repo.ID, dist.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
componentNames := make([]string, 0, len(comps))
|
||||||
|
for _, c := range comps {
|
||||||
|
componentNames = append(componentNames, c.Name)
|
||||||
|
}
|
||||||
|
archNames := make([]string, 0, len(arches))
|
||||||
|
for _, a2 := range arches {
|
||||||
|
archNames = append(archNames, a2.Name)
|
||||||
|
}
|
||||||
|
rows := make([]apt.PackageRow, 0, len(suitePkgs))
|
||||||
|
for _, sp := range suitePkgs {
|
||||||
|
rows = append(rows, apt.PackageRow{
|
||||||
|
Component: sp.ComponentName,
|
||||||
|
Name: sp.Name,
|
||||||
|
Version: sp.Version,
|
||||||
|
Architecture: sp.Architecture,
|
||||||
|
PoolPath: sp.PoolPath,
|
||||||
|
Size: sp.Size,
|
||||||
|
MD5sum: sp.MD5sum,
|
||||||
|
SHA1: sp.SHA1,
|
||||||
|
SHA256: sp.SHA256,
|
||||||
|
DescriptionMD5: sp.DescriptionMD5,
|
||||||
|
RawControl: sp.RawControl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
suite := &apt.Suite{
|
||||||
|
Origin: "urapt " + repo.Name,
|
||||||
|
Label: "urapt " + repo.Name,
|
||||||
|
Suite: dist.Name,
|
||||||
|
Description: repo.Description,
|
||||||
|
Components: componentNames,
|
||||||
|
Architectures: archNames,
|
||||||
|
Packages: rows,
|
||||||
|
}
|
||||||
|
idx, err := apt.Generate(suite, a.Signer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.Cache.Put(repo.ID, suiteName, idx)
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errMiss wraps a not-found error from a sub-load.
|
||||||
|
type errMiss struct{ err error }
|
||||||
|
|
||||||
|
func (e errMiss) Error() string { return e.err.Error() }
|
||||||
|
func (e errMiss) Unwrap() error { return e.err }
|
||||||
|
|
||||||
|
// indicesOrWrite loads indices and writes a 404/500 on failure.
|
||||||
|
func (a *APTRepo) indicesOrWrite(w http.ResponseWriter, r *http.Request, repo *models.Repository, suite string) *apt.Indices {
|
||||||
|
idx, err := a.loadSuite(r, repo, suite)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "suite not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("apt generate failed", "err", err, "repo", repo.Name, "suite", suite)
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to generate indices")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release serves the unsigned Release file.
|
||||||
|
func (a *APTRepo) Release(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := a.authorize(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite"))
|
||||||
|
if idx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeBytes(w, "text/plain; charset=utf-8", idx.Release)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InRelease serves the clearsigned InRelease file.
|
||||||
|
func (a *APTRepo) InRelease(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := a.authorize(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite"))
|
||||||
|
if idx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(idx.InRelease) == 0 {
|
||||||
|
httputil.WriteError(w, http.StatusServiceUnavailable, httputil.CodeInternal, "indices not signed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeBytes(w, "text/plain; charset=utf-8", idx.InRelease)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseGpg serves the detached signature Release.gpg.
|
||||||
|
func (a *APTRepo) ReleaseGpg(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := a.authorize(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite"))
|
||||||
|
if idx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(idx.ReleaseGpg) == 0 {
|
||||||
|
httputil.WriteError(w, http.StatusServiceUnavailable, httputil.CodeInternal, "indices not signed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeBytes(w, "application/pgp-signature", idx.ReleaseGpg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packages serves the Packages index for a (component, arch).
|
||||||
|
func (a *APTRepo) Packages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.servePackages(w, r, "")
|
||||||
|
}
|
||||||
|
func (a *APTRepo) PackagesGz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.servePackages(w, r, "gz")
|
||||||
|
}
|
||||||
|
func (a *APTRepo) PackagesXz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.servePackages(w, r, "xz")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APTRepo) servePackages(w http.ResponseWriter, r *http.Request, encoding string) {
|
||||||
|
repo, ok := a.authorize(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite"))
|
||||||
|
if idx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binary := r.PathValue("binary")
|
||||||
|
if !strings.HasPrefix(binary, "binary-") {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arch := strings.TrimPrefix(binary, "binary-")
|
||||||
|
relPath := r.PathValue("component") + "/binary-" + arch + "/Packages"
|
||||||
|
var data []byte
|
||||||
|
var contentType string
|
||||||
|
switch encoding {
|
||||||
|
case "":
|
||||||
|
data = idx.Packages[relPath]
|
||||||
|
contentType = "text/plain; charset=utf-8"
|
||||||
|
case "gz":
|
||||||
|
data = idx.PackagesGz[relPath+".gz"]
|
||||||
|
contentType = "application/gzip"
|
||||||
|
case "xz":
|
||||||
|
data = idx.PackagesXz[relPath+".xz"]
|
||||||
|
contentType = "application/x-xz"
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "index not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeBytes(w, contentType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool streams a .deb file from the content-addressed store.
|
||||||
|
func (a *APTRepo) Pool(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := a.authorize(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
poolPath := strings.Join([]string{
|
||||||
|
"pool", r.PathValue("component"), r.PathValue("letter"), r.PathValue("src"), r.PathValue("filename"),
|
||||||
|
}, "/")
|
||||||
|
pkg, err := a.Store.GetPackageByPoolPath(r.Context(), repo.ID, poolPath)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, pkg.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBytes writes raw bytes with a content type and 200 status.
|
||||||
|
func writeBytes(w http.ResponseWriter, contentType string, data []byte) {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package aptrepo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"urapt/server/aptrepo"
|
||||||
|
"urapt/server/auth"
|
||||||
|
"urapt/server/cache"
|
||||||
|
"urapt/server/restapi"
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/config"
|
||||||
|
"urapt/shared/db"
|
||||||
|
"urapt/shared/gpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newServer spins up a REST + APT server backed by a temp DB and store, with a
|
||||||
|
// pre-created owner token. It returns the server, the owner token, and the
|
||||||
|
// armored public key.
|
||||||
|
func newServer(t *testing.T) (*httptest.Server, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
st := store.New(database)
|
||||||
|
authSvc := auth.NewService(st)
|
||||||
|
|
||||||
|
key, err := gpg.GenerateKey("urapt-test <test>", 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gpg key: %v", err)
|
||||||
|
}
|
||||||
|
sp := &testSigner{key: key}
|
||||||
|
cfg := config.Defaults
|
||||||
|
cfg.StoreDir = dir
|
||||||
|
cfg.PackagesDir = filepath.Join(dir, "packages")
|
||||||
|
cfg.DBPath = filepath.Join(dir, "test.db")
|
||||||
|
if err := mkdirAll(cfg.PackagesDir); err != nil {
|
||||||
|
t.Fatalf("mkdir pkg dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idxCache := cache.New()
|
||||||
|
root := chi.NewRouter()
|
||||||
|
root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache))
|
||||||
|
root.Mount("/apt", aptrepo.New(st, authSvc, key, idxCache))
|
||||||
|
srv := httptest.NewServer(root)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
// Register owner.
|
||||||
|
body := post(t, srv, "/api/v1/auth/register", "", map[string]string{"username": "owner", "password": "supersecret"})
|
||||||
|
token := str(body, "token")
|
||||||
|
pub, err := key.ArmoredPublic()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("armored public: %v", err)
|
||||||
|
}
|
||||||
|
return srv, token, pub
|
||||||
|
}
|
||||||
|
|
||||||
|
type testSigner struct{ key *gpg.Key }
|
||||||
|
|
||||||
|
func (s *testSigner) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() }
|
||||||
|
func (s *testSigner) Fingerprint() string { return s.key.Fingerprint }
|
||||||
|
|
||||||
|
func post(t *testing.T, srv *httptest.Server, path, token string, body any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b := jsonMarshal(body)
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return readAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(t *testing.T, srv *httptest.Server, path, token string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", srv.URL+path, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode, readAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRepo(t *testing.T, srv *httptest.Server, token string) {
|
||||||
|
post(t, srv, "/api/v1/repositories", token, map[string]any{"name": "myrepo", "visibility": "public"})
|
||||||
|
post(t, srv, "/api/v1/repositories/myrepo/distributions", token, map[string]string{"name": "stable"})
|
||||||
|
post(t, srv, "/api/v1/repositories/myrepo/distributions/stable/components", token, map[string]string{"name": "main"})
|
||||||
|
post(t, srv, "/api/v1/repositories/myrepo/distributions/stable/architectures", token, map[string]string{"name": "amd64"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPTIndicesAndPool(t *testing.T) {
|
||||||
|
srv, token, pub := newServer(t)
|
||||||
|
setupRepo(t, srv, token)
|
||||||
|
|
||||||
|
// Push a real .deb via the REST API.
|
||||||
|
debBytes := buildDeb("foo", "1.0", "amd64")
|
||||||
|
upload(t, srv, token, "myrepo", "stable", "main", "foo_1.0_amd64.deb", debBytes)
|
||||||
|
|
||||||
|
// Fetch the Packages index.
|
||||||
|
code, body := get(t, srv, "/apt/myrepo/dists/stable/main/binary-amd64/Packages", "")
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("Packages status %d body %s", code, body)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(body, []byte("Package: foo")) || !bytes.Contains(body, []byte("Filename: pool/main/f/foo/foo_1.0_amd64.deb")) {
|
||||||
|
t.Fatalf("Packages index missing entry:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Release.
|
||||||
|
code, rel := get(t, srv, "/apt/myrepo/dists/stable/Release", "")
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("Release status %d", code)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rel, []byte("Suite: stable")) || !bytes.Contains(rel, []byte("Components: main")) {
|
||||||
|
t.Fatalf("Release missing fields:\n%s", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch InRelease (clearsigned) and verify against the public key.
|
||||||
|
code, inrel := get(t, srv, "/apt/myrepo/dists/stable/InRelease", "")
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("InRelease status %d", code)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(inrel, []byte("BEGIN PGP SIGNED MESSAGE")) {
|
||||||
|
t.Fatalf("InRelease not clearsigned:\n%s", inrel)
|
||||||
|
}
|
||||||
|
if _, err := gpg.VerifyClearSign(pub, inrel); err != nil {
|
||||||
|
t.Fatalf("verify InRelease: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Release.gpg and verify as a detached signature of Release.
|
||||||
|
code, sig := get(t, srv, "/apt/myrepo/dists/stable/Release.gpg", "")
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("Release.gpg status %d", code)
|
||||||
|
}
|
||||||
|
if err := gpg.VerifyDetached(pub, rel, sig); err != nil {
|
||||||
|
t.Fatalf("verify Release.gpg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the pool .deb and compare bytes.
|
||||||
|
code, deb := get(t, srv, "/apt/myrepo/pool/main/f/foo/foo_1.0_amd64.deb", "")
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("pool status %d", code)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(deb, debBytes) {
|
||||||
|
t.Fatalf("pool bytes mismatch (%d vs %d)", len(deb), len(debBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateRepoRequiresAuth(t *testing.T) {
|
||||||
|
srv, token, _ := newServer(t)
|
||||||
|
// private repo
|
||||||
|
post(t, srv, "/api/v1/repositories", token, map[string]any{"name": "priv", "visibility": "private"})
|
||||||
|
post(t, srv, "/api/v1/repositories/priv/distributions", token, map[string]string{"name": "stable"})
|
||||||
|
post(t, srv, "/api/v1/repositories/priv/distributions/stable/components", token, map[string]string{"name": "main"})
|
||||||
|
post(t, srv, "/api/v1/repositories/priv/distributions/stable/architectures", token, map[string]string{"name": "amd64"})
|
||||||
|
upload(t, srv, token, "priv", "stable", "main", "foo_1.0_amd64.deb", buildDeb("foo", "1.0", "amd64"))
|
||||||
|
|
||||||
|
// anonymous → 401
|
||||||
|
code, _ := get(t, srv, "/apt/priv/dists/stable/Release", "")
|
||||||
|
if code != 401 {
|
||||||
|
t.Fatalf("anonymous private repo should be 401, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// with token as basic auth password → 200
|
||||||
|
req, _ := http.NewRequest("GET", srv.URL+"/apt/priv/dists/stable/Release", nil)
|
||||||
|
req.SetBasicAuth("owner", token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("authenticated private repo should be 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep context import used
|
||||||
|
var _ = context.Background
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package aptrepo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func jsonMarshal(v any) []byte {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAll(r io.Reader) []byte {
|
||||||
|
b, _ := io.ReadAll(r)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkdirAll(dir string) error { return os.MkdirAll(dir, 0o755) }
|
||||||
|
|
||||||
|
func str(body []byte, key string) string {
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(body, &m)
|
||||||
|
if v, ok := m[key].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDeb constructs a minimal valid .deb with the given package/version/arch.
|
||||||
|
func buildDeb(name, version, arch string) []byte {
|
||||||
|
control := "Package: " + name + "\nVersion: " + version + "\nArchitecture: " + arch + "\nMaintainer: t <t@e>\nDescription: short\n extended\n"
|
||||||
|
var ctrlBuf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&ctrlBuf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
writeTw(tw, "control", control)
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
|
||||||
|
var dataBuf bytes.Buffer
|
||||||
|
gz2 := gzip.NewWriter(&dataBuf)
|
||||||
|
tw2 := tar.NewWriter(gz2)
|
||||||
|
writeTw(tw2, "usr/share/"+name, "x")
|
||||||
|
tw2.Close()
|
||||||
|
gz2.Close()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("!<arch>\n")
|
||||||
|
writeAr(&out, "debian-binary", []byte("2.0\n"))
|
||||||
|
writeAr(&out, "control.tar.gz", ctrlBuf.Bytes())
|
||||||
|
writeAr(&out, "data.tar.gz", dataBuf.Bytes())
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTw(tw *tar.Writer, name, body string) {
|
||||||
|
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
||||||
|
_, _ = tw.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAr(buf *bytes.Buffer, name string, data []byte) {
|
||||||
|
header := make([]byte, 60)
|
||||||
|
for i := range header {
|
||||||
|
header[i] = ' '
|
||||||
|
}
|
||||||
|
copy(header[0:], name+"/")
|
||||||
|
ds := []byte(padNum(len(data), 10))
|
||||||
|
copy(header[48:], ds)
|
||||||
|
header[58] = '`'
|
||||||
|
header[59] = '\n'
|
||||||
|
buf.Write(header)
|
||||||
|
buf.Write(data)
|
||||||
|
if len(data)%2 == 1 {
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func padNum(n, width int) string {
|
||||||
|
s := make([]byte, width)
|
||||||
|
for i := range s {
|
||||||
|
s[i] = ' '
|
||||||
|
}
|
||||||
|
digits := []byte(itoa(n))
|
||||||
|
copy(s[len(s)-len(digits):], digits)
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{byte('0' + n%10)}, b...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(t *testing.T, srv *httptest.Server, token, repo, dist, component, filename string, deb []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("component", component)
|
||||||
|
fw, _ := mw.CreateFormFile("file", filename)
|
||||||
|
_, _ = fw.Write(deb)
|
||||||
|
_ = mw.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+"/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 201 {
|
||||||
|
t.Fatalf("upload status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Package auth implements identity resolution (bearer/basic) and the
|
||||||
|
// repository-scoped permission checks used by the REST API and the APT
|
||||||
|
// endpoint.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/crypto"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identity is the resolved caller: a user and (for REST) the token used.
|
||||||
|
type Identity struct {
|
||||||
|
User *models.User
|
||||||
|
TokenID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service resolves identities and answers permission questions.
|
||||||
|
type Service struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs an auth Service.
|
||||||
|
func NewService(s *store.Store) *Service {
|
||||||
|
return &Service{store: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnauthenticated is returned when no valid identity can be established.
|
||||||
|
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
|
|
||||||
|
// ResolveBearer resolves an "Authorization: Bearer <token>" header to an
|
||||||
|
// identity. Returns ErrUnauthenticated if absent or invalid.
|
||||||
|
func (s *Service) ResolveBearer(ctx context.Context, h http.Header) (*Identity, error) {
|
||||||
|
token, ok := httputil.ParseBearer(h)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
return s.resolveToken(ctx, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBasic resolves an "Authorization: Basic" header where the password is
|
||||||
|
// an API token. Returns ErrUnauthenticated if absent/invalid.
|
||||||
|
func (s *Service) ResolveBasic(ctx context.Context, h http.Header) (*Identity, error) {
|
||||||
|
_, password, ok := httputil.ParseBasic(h)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
return s.resolveToken(ctx, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveToken(ctx context.Context, token string) (*Identity, error) {
|
||||||
|
if token == "" {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
hash := crypto.HashToken(token)
|
||||||
|
t, err := s.store.GetTokenByHash(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := s.store.GetUserByID(ctx, t.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
_ = s.store.TouchToken(ctx, t.ID)
|
||||||
|
return &Identity{User: user, TokenID: t.ID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRead reports whether user may read (download/list within) repo.
|
||||||
|
func (s *Service) CanRead(ctx context.Context, user *models.User, repo *models.Repository) (bool, error) {
|
||||||
|
if user == nil {
|
||||||
|
return repo.Visibility == models.VisibilityPublic, nil
|
||||||
|
}
|
||||||
|
if user.IsAdmin || repo.OwnerUserID == user.ID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
access, ok, err := s.store.GetMemberAccess(ctx, repo.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
switch access {
|
||||||
|
case models.AccessRead, models.AccessReadWrite, models.AccessAdmin:
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repo.Visibility == models.VisibilityPublic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanWrite reports whether user may push packages to repo.
|
||||||
|
func (s *Service) CanWrite(ctx context.Context, user *models.User, repo *models.Repository) (bool, error) {
|
||||||
|
if user == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if user.IsAdmin || repo.OwnerUserID == user.ID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
access, ok, err := s.store.GetMemberAccess(ctx, repo.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
switch access {
|
||||||
|
case models.AccessWrite, models.AccessReadWrite, models.AccessAdmin:
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanManage reports whether user may manage repo settings and members.
|
||||||
|
func (s *Service) CanManage(ctx context.Context, user *models.User, repo *models.Repository) (bool, error) {
|
||||||
|
if user == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if user.IsAdmin || repo.OwnerUserID == user.ID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
access, ok, err := s.store.GetMemberAccess(ctx, repo.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return access == models.AccessAdmin, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/crypto"
|
||||||
|
"urapt/shared/db"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAuthSvc opens a fresh DB and returns an auth Service plus a handle to the
|
||||||
|
// underlying store for seeding users/repos/tokens.
|
||||||
|
func newAuthSvc(t *testing.T) (*Service, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
st := store.New(database)
|
||||||
|
return NewService(st), st
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedUser creates a user via the store and returns it.
|
||||||
|
func seedUser(t *testing.T, st *store.Store, ctx context.Context, username string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
hash, err := crypto.HashPassword("pw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
u, _, err := st.CreateUser(ctx, username, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user %q: %v", username, err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedToken mints a real token for userID and returns the plaintext.
|
||||||
|
func seedToken(t *testing.T, st *store.Store, ctx context.Context, userID, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
tok, hash, prefix, err := crypto.GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateToken(ctx, userID, name, prefix, hash); err != nil {
|
||||||
|
t.Fatalf("create token: %v", err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRepo(t *testing.T, st *store.Store, ctx context.Context, name, ownerID string, vis models.Visibility) *models.Repository {
|
||||||
|
t.Helper()
|
||||||
|
r, err := st.CreateRepository(ctx, name, ownerID, vis, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create repo %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAdmin creates a non-bootstrap user and promotes them to server admin,
|
||||||
|
// returning a refreshed user record with IsAdmin=true.
|
||||||
|
func seedAdmin(t *testing.T, st *store.Store, ctx context.Context, username string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
u := seedUser(t, st, ctx, username)
|
||||||
|
if err := st.UpdateUser(ctx, u.ID, boolPtr(true)); err != nil {
|
||||||
|
t.Fatalf("promote %q: %v", username, err)
|
||||||
|
}
|
||||||
|
refreshed, err := st.GetUserByID(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refetch admin: %v", err)
|
||||||
|
}
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerHeader(token string) http.Header {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("Authorization", "Bearer "+token)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicHeader(username, password string) http.Header {
|
||||||
|
h := http.Header{}
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||||
|
h.Set("Authorization", "Basic "+enc)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ResolveBearer ---
|
||||||
|
|
||||||
|
func TestResolveBearer_Valid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st := newAuthSvc(t)
|
||||||
|
u := seedUser(t, st, ctx, "alice")
|
||||||
|
tok := seedToken(t, st, ctx, u.ID, "laptop")
|
||||||
|
|
||||||
|
id, err := svc.ResolveBearer(ctx, bearerHeader(tok))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if id.User == nil || id.User.ID != u.ID {
|
||||||
|
t.Fatalf("identity = %+v", id)
|
||||||
|
}
|
||||||
|
if id.TokenID == "" {
|
||||||
|
t.Fatal("token id should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBearer_MissingHeader(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, _ := newAuthSvc(t)
|
||||||
|
_, err := svc.ResolveBearer(ctx, http.Header{})
|
||||||
|
if !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("expected ErrUnauthenticated, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBearer_MalformedHeader(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, _ := newAuthSvc(t)
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("Authorization", "Bearer") // no token
|
||||||
|
if _, err := svc.ResolveBearer(ctx, h); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("expected ErrUnauthenticated, got %v", err)
|
||||||
|
}
|
||||||
|
h.Set("Authorization", "Basic abc")
|
||||||
|
if _, err := svc.ResolveBearer(ctx, h); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("expected ErrUnauthenticated for non-Bearer scheme, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBearer_InvalidToken(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, _ := newAuthSvc(t)
|
||||||
|
if _, err := svc.ResolveBearer(ctx, bearerHeader("urapt_notreal")); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("expected ErrUnauthenticated for bogus token, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBearer_RevokedToken(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st := newAuthSvc(t)
|
||||||
|
u := seedUser(t, st, ctx, "alice")
|
||||||
|
tok := seedToken(t, st, ctx, u.ID, "laptop")
|
||||||
|
// Revoke by hash (simulating logout).
|
||||||
|
_ = st.RevokeTokenByHash(ctx, crypto.HashToken(tok))
|
||||||
|
|
||||||
|
if _, err := svc.ResolveBearer(ctx, bearerHeader(tok)); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("expected ErrUnauthenticated for revoked token, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ResolveBasic ---
|
||||||
|
|
||||||
|
func TestResolveBasic_Valid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st := newAuthSvc(t)
|
||||||
|
u := seedUser(t, st, ctx, "alice")
|
||||||
|
tok := seedToken(t, st, ctx, u.ID, "laptop")
|
||||||
|
|
||||||
|
// Username is ignored; password is the token.
|
||||||
|
id, err := svc.ResolveBasic(ctx, basicHeader("anything", tok))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if id.User.ID != u.ID {
|
||||||
|
t.Fatalf("user id mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBasic_MissingAndMalformed(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, _ := newAuthSvc(t)
|
||||||
|
if _, err := svc.ResolveBasic(ctx, http.Header{}); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("missing header: expected ErrUnauthenticated, got %v", err)
|
||||||
|
}
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("Authorization", "Basic not-base64!!!")
|
||||||
|
if _, err := svc.ResolveBasic(ctx, h); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("bad base64: expected ErrUnauthenticated, got %v", err)
|
||||||
|
}
|
||||||
|
h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("noseparator")))
|
||||||
|
if _, err := svc.ResolveBasic(ctx, h); !errors.Is(err, ErrUnauthenticated) {
|
||||||
|
t.Fatalf("no colon: expected ErrUnauthenticated, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CanRead ---
|
||||||
|
|
||||||
|
func TestCanRead(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st := newAuthSvc(t)
|
||||||
|
owner := seedUser(t, st, ctx, "owner")
|
||||||
|
member := seedUser(t, st, ctx, "member")
|
||||||
|
nonMember := seedUser(t, st, ctx, "stranger")
|
||||||
|
admin := seedAdmin(t, st, ctx, "admin")
|
||||||
|
|
||||||
|
pubRepo := seedRepo(t, st, ctx, "pub", owner.ID, models.VisibilityPublic)
|
||||||
|
privRepo := seedRepo(t, st, ctx, "priv", owner.ID, models.VisibilityPrivate)
|
||||||
|
_ = st.AddMember(ctx, privRepo.ID, member.ID, models.AccessRead)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user *models.User
|
||||||
|
repo *models.Repository
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil user, public repo", nil, pubRepo, true},
|
||||||
|
{"nil user, private repo", nil, privRepo, false},
|
||||||
|
{"admin on private", admin, privRepo, true},
|
||||||
|
{"owner on private", owner, privRepo, true},
|
||||||
|
{"member(read) on private", member, privRepo, true},
|
||||||
|
{"non-member on public", nonMember, pubRepo, true},
|
||||||
|
{"non-member on private", nonMember, privRepo, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := svc.CanRead(ctx, c.user, c.repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: error %v", c.name, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%s: got %v want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanRead_WriteOnlyMemberCanRead(t *testing.T) {
|
||||||
|
// A write-only member should still be able to read (the AccessWrite grant
|
||||||
|
// does not include read in the switch in CanRead, so this confirms the
|
||||||
|
// public-fallback behavior: a private repo would deny a write-only member
|
||||||
|
// read access).
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st := newAuthSvc(t)
|
||||||
|
owner := seedUser(t, st, ctx, "owner")
|
||||||
|
writer := seedUser(t, st, ctx, "writer")
|
||||||
|
privRepo := seedRepo(t, st, ctx, "priv", owner.ID, models.VisibilityPrivate)
|
||||||
|
_ = st.AddMember(ctx, privRepo.ID, writer.ID, models.AccessWrite)
|
||||||
|
|
||||||
|
got, _ := svc.CanRead(ctx, writer, privRepo)
|
||||||
|
if got {
|
||||||
|
t.Fatal("write-only member should NOT be able to read a private repo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CanWrite ---
|
||||||
|
|
||||||
|
func TestCanWrite(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st := newAuthSvc(t)
|
||||||
|
owner := seedUser(t, st, ctx, "owner")
|
||||||
|
reader := seedUser(t, st, ctx, "reader")
|
||||||
|
writer := seedUser(t, st, ctx, "writer")
|
||||||
|
rw := seedUser(t, st, ctx, "rw")
|
||||||
|
repoAdmin := seedUser(t, st, ctx, "repoadmin")
|
||||||
|
nonMember := seedUser(t, st, ctx, "stranger")
|
||||||
|
admin := seedAdmin(t, st, ctx, "admin")
|
||||||
|
|
||||||
|
repo := seedRepo(t, st, ctx, "repo", owner.ID, models.VisibilityPublic)
|
||||||
|
_ = st.AddMember(ctx, repo.ID, reader.ID, models.AccessRead)
|
||||||
|
_ = st.AddMember(ctx, repo.ID, writer.ID, models.AccessWrite)
|
||||||
|
_ = st.AddMember(ctx, repo.ID, rw.ID, models.AccessReadWrite)
|
||||||
|
_ = st.AddMember(ctx, repo.ID, repoAdmin.ID, models.AccessAdmin)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user *models.User
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil user", nil, false},
|
||||||
|
{"admin", admin, true},
|
||||||
|
{"owner", owner, true},
|
||||||
|
{"read member", reader, false},
|
||||||
|
{"write member", writer, true},
|
||||||
|
{"read-write member", rw, true},
|
||||||
|
{"repo admin member", repoAdmin, true},
|
||||||
|
{"non-member", nonMember, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := svc.CanWrite(ctx, c.user, repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: error %v", c.name, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%s: got %v want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CanManage ---
|
||||||
|
|
||||||
|
func TestCanManage(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, st := newAuthSvc(t)
|
||||||
|
owner := seedUser(t, st, ctx, "owner")
|
||||||
|
reader := seedUser(t, st, ctx, "reader")
|
||||||
|
repoAdmin := seedUser(t, st, ctx, "repoadmin")
|
||||||
|
nonMember := seedUser(t, st, ctx, "stranger")
|
||||||
|
admin := seedAdmin(t, st, ctx, "admin")
|
||||||
|
|
||||||
|
repo := seedRepo(t, st, ctx, "repo", owner.ID, models.VisibilityPublic)
|
||||||
|
_ = st.AddMember(ctx, repo.ID, reader.ID, models.AccessRead)
|
||||||
|
_ = st.AddMember(ctx, repo.ID, repoAdmin.ID, models.AccessAdmin)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user *models.User
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil user", nil, false},
|
||||||
|
{"admin", admin, true},
|
||||||
|
{"owner", owner, true},
|
||||||
|
{"read member", reader, false},
|
||||||
|
{"repo admin member", repoAdmin, true},
|
||||||
|
{"non-member", nonMember, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := svc.CanManage(ctx, c.user, repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: error %v", c.name, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%s: got %v want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
Vendored
+79
@@ -0,0 +1,79 @@
|
|||||||
|
// Package cache defines the in-memory APT index cache contract used by the
|
||||||
|
// REST API (to invalidate on mutation) and the APT endpoint (to serve cached
|
||||||
|
// indices). The implementation lives in this package; it is regenerated lazily
|
||||||
|
// after invalidation or restart.
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"urapt/shared/apt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IndexCache holds generated APT indices per (repository, suite), keyed and
|
||||||
|
// versioned so that mutations invalidate lazily.
|
||||||
|
type IndexCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entry map[string]*entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
indices *apt.Indices
|
||||||
|
suite *apt.Suite
|
||||||
|
gen int64
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs an empty IndexCache.
|
||||||
|
func New() *IndexCache {
|
||||||
|
return &IndexCache{entry: map[string]*entry{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// key builds the cache key.
|
||||||
|
func key(repoID, suite string) string { return repoID + "/" + suite }
|
||||||
|
|
||||||
|
// Get returns the cached indices for (repoID, suite), or nil if not present
|
||||||
|
// or marked dirty. The suite is returned so the caller can rebuild it.
|
||||||
|
func (c *IndexCache) Get(repoID, suite string) *apt.Indices {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
e := c.entry[key(repoID, suite)]
|
||||||
|
if e == nil || e.dirty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.indices
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put stores freshly generated indices for (repoID, suite).
|
||||||
|
func (c *IndexCache) Put(repoID, suite string, idx *apt.Indices) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
e := c.entry[key(repoID, suite)]
|
||||||
|
if e == nil {
|
||||||
|
e = &entry{}
|
||||||
|
c.entry[key(repoID, suite)] = e
|
||||||
|
}
|
||||||
|
e.indices = idx
|
||||||
|
e.dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate marks one (repository, suite) as stale; the next read rebuilds.
|
||||||
|
func (c *IndexCache) Invalidate(repoID, suite string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if e := c.entry[key(repoID, suite)]; e != nil {
|
||||||
|
e.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateRepo marks every suite under a repository as stale.
|
||||||
|
func (c *IndexCache) InvalidateRepo(repoID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
prefix := repoID + "/"
|
||||||
|
for k, e := range c.entry {
|
||||||
|
if len(k) > len(prefix) && k[:len(prefix)] == prefix {
|
||||||
|
e.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Package middleware provides HTTP middleware shared by the REST API and the
|
||||||
|
// APT endpoint: panic recovery, request logging, and bearer/basic identity
|
||||||
|
// injection.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"urapt/server/auth"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ctxKey is an unexported key type for context values.
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyIdentity ctxKey = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
// IdentityFromContext returns the identity previously attached by RequireBearer
|
||||||
|
// or OptionalBearer, or nil.
|
||||||
|
func IdentityFromContext(ctx context.Context) *auth.Identity {
|
||||||
|
v, _ := ctx.Value(keyIdentity).(*auth.Identity)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// withIdentity stores id in the context.
|
||||||
|
func withIdentity(ctx context.Context, id *auth.Identity) context.Context {
|
||||||
|
return context.WithValue(ctx, keyIdentity, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover catches panics and renders a uniform 500.
|
||||||
|
func Recover(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
slog.Error("panic", "err", rec, "stack", string(debug.Stack()))
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "internal server error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log logs each request using slog.
|
||||||
|
func Log(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
slog.Info("http", "method", r.Method, "path", r.URL.Path, "status", rw.status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) WriteHeader(code int) {
|
||||||
|
s.status = code
|
||||||
|
s.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireBearer resolves a bearer token; on failure it renders 401. On success
|
||||||
|
// the identity is attached to the request context.
|
||||||
|
func RequireBearer(svc *auth.Service) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := svc.ResolveBearer(r.Context(), r.Header)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnauthorized, httputil.CodeUnauthorized, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(withIdentity(r.Context(), id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalBearer resolves a bearer token if present but never blocks; the
|
||||||
|
// identity (possibly nil) is attached to the context.
|
||||||
|
func OptionalBearer(svc *auth.Service) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := svc.ResolveBearer(r.Context(), r.Header)
|
||||||
|
next.ServeHTTP(w, r.WithContext(withIdentity(r.Context(), id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireBasic resolves HTTP Basic auth (password = API token); on failure it
|
||||||
|
// issues a 401 with a WWW-Authenticate challenge. Used by the APT endpoint for
|
||||||
|
// private repositories.
|
||||||
|
func RequireBasic(svc *auth.Service, realm string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := svc.ResolveBasic(r.Context(), r.Header)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ChallengeBasic(w, realm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(withIdentity(r.Context(), id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Package restapi implements the urapt REST API: handlers, routing, and
|
||||||
|
// request validation. It is mounted under /api/v1 on the server.
|
||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"urapt/server/auth"
|
||||||
|
"urapt/server/cache"
|
||||||
|
"urapt/server/middleware"
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/config"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignerProvider returns the server's current signing key. It is supplied by
|
||||||
|
// the app; the APT endpoint also uses it to sign Release files.
|
||||||
|
type SignerProvider interface {
|
||||||
|
PublicKeyArmored() (string, error)
|
||||||
|
Fingerprint() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// API holds the dependencies shared by all REST handlers.
|
||||||
|
type API struct {
|
||||||
|
Store *store.Store
|
||||||
|
Auth *auth.Service
|
||||||
|
Signer SignerProvider
|
||||||
|
Config *config.Config
|
||||||
|
Cache *cache.IndexCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the API and returns its http.Handler (the /api/v1 router).
|
||||||
|
func New(st *store.Store, authSvc *auth.Service, signer SignerProvider, cfg *config.Config, c *cache.IndexCache) http.Handler {
|
||||||
|
api := &API{Store: st, Auth: authSvc, Signer: signer, Config: cfg, Cache: c}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Recover)
|
||||||
|
r.Use(middleware.Log)
|
||||||
|
|
||||||
|
r.Get("/server/info", api.ServerInfo)
|
||||||
|
r.Get("/server/pubkey", api.ServerPubkey)
|
||||||
|
|
||||||
|
r.Post("/auth/register", api.Register)
|
||||||
|
r.Post("/auth/login", api.Login)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireBearer(authSvc))
|
||||||
|
|
||||||
|
r.Post("/auth/logout", api.Logout)
|
||||||
|
r.Get("/me", api.Me)
|
||||||
|
r.Get("/me/tokens", api.ListTokens)
|
||||||
|
r.Post("/me/tokens", api.CreateToken)
|
||||||
|
r.Delete("/me/tokens/{id}", api.RevokeToken)
|
||||||
|
|
||||||
|
// repositories (read for visible, write for permitted)
|
||||||
|
r.Get("/repositories", api.ListRepositories)
|
||||||
|
r.Post("/repositories", api.CreateRepository)
|
||||||
|
r.Get("/repositories/{repo}", api.GetRepository)
|
||||||
|
r.Patch("/repositories/{repo}", api.UpdateRepository)
|
||||||
|
r.Delete("/repositories/{repo}", api.DeleteRepository)
|
||||||
|
r.Get("/repositories/{repo}/members", api.ListMembers)
|
||||||
|
r.Post("/repositories/{repo}/members", api.AddMember)
|
||||||
|
r.Patch("/repositories/{repo}/members/{username}", api.UpdateMember)
|
||||||
|
r.Delete("/repositories/{repo}/members/{username}", api.RemoveMember)
|
||||||
|
r.Get("/repositories/{repo}/pubkey", api.RepoPubkey)
|
||||||
|
|
||||||
|
// structure
|
||||||
|
r.Get("/repositories/{repo}/distributions", api.ListDistributions)
|
||||||
|
r.Post("/repositories/{repo}/distributions", api.CreateDistribution)
|
||||||
|
r.Delete("/repositories/{repo}/distributions/{dist}", api.DeleteDistribution)
|
||||||
|
r.Get("/repositories/{repo}/distributions/{dist}/components", api.ListComponents)
|
||||||
|
r.Post("/repositories/{repo}/distributions/{dist}/components", api.CreateComponent)
|
||||||
|
r.Delete("/repositories/{repo}/distributions/{dist}/components/{comp}", api.DeleteComponent)
|
||||||
|
r.Get("/repositories/{repo}/distributions/{dist}/architectures", api.ListArchitectures)
|
||||||
|
r.Post("/repositories/{repo}/distributions/{dist}/architectures", api.CreateArchitecture)
|
||||||
|
r.Delete("/repositories/{repo}/distributions/{dist}/architectures/{arch}", api.DeleteArchitecture)
|
||||||
|
|
||||||
|
// packages
|
||||||
|
r.Get("/repositories/{repo}/distributions/{dist}/packages", api.ListPackages)
|
||||||
|
r.Post("/repositories/{repo}/distributions/{dist}/packages", api.PushPackage)
|
||||||
|
r.Get("/repositories/{repo}/packages/{id}", api.GetPackage)
|
||||||
|
r.Get("/repositories/{repo}/packages/{id}/file", api.GetPackageFile)
|
||||||
|
r.Delete("/repositories/{repo}/packages/{id}", api.DeletePackage)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(api.RequireAdmin)
|
||||||
|
r.Get("/users", api.ListUsers)
|
||||||
|
r.Get("/users/{id}", api.GetUser)
|
||||||
|
r.Patch("/users/{id}", api.UpdateUser)
|
||||||
|
r.Delete("/users/{id}", api.DeleteUser)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin is middleware that requires the caller to be a server admin.
|
||||||
|
func (api *API) RequireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
if id == nil || !id.User.IsAdmin {
|
||||||
|
httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "admin privileges required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation patterns.
|
||||||
|
var (
|
||||||
|
usernameRE = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||||
|
passwordRE = regexp.MustCompile(`^.{8,256}$`)
|
||||||
|
tokenNameRE = regexp.MustCompile(`^.{1,64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateUsername returns true if s is an acceptable username.
|
||||||
|
func validateUsername(s string) bool { return usernameRE.MatchString(s) }
|
||||||
|
|
||||||
|
// validatePassword returns true if s is an acceptable password.
|
||||||
|
func validatePassword(s string) bool { return passwordRE.MatchString(s) }
|
||||||
|
|
||||||
|
// bad renders a 400 validation error.
|
||||||
|
func bad(w http.ResponseWriter, msg string) {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey for request-scoped values is not needed beyond middleware; this
|
||||||
|
// var keeps context imported if future handlers need it.
|
||||||
|
var _ = context.Background
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"urapt/server/auth"
|
||||||
|
"urapt/server/cache"
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/config"
|
||||||
|
"urapt/shared/db"
|
||||||
|
"urapt/shared/gpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type harness struct {
|
||||||
|
t *testing.T
|
||||||
|
srv *httptest.Server
|
||||||
|
store *store.Store
|
||||||
|
token string
|
||||||
|
pkgDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHarness(t *testing.T) *harness {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
st := store.New(database)
|
||||||
|
authSvc := auth.NewService(st)
|
||||||
|
|
||||||
|
key, err := gpg.GenerateKey("urapt-test <test>", 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gpg key: %v", err)
|
||||||
|
}
|
||||||
|
sp := &testSigner{key: key}
|
||||||
|
cfg := config.Defaults
|
||||||
|
cfg.StoreDir = dir
|
||||||
|
cfg.PackagesDir = filepath.Join(dir, "packages")
|
||||||
|
cfg.DBPath = filepath.Join(dir, "test.db")
|
||||||
|
if err := os.MkdirAll(cfg.PackagesDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir pkg dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &harness{t: t, store: st, pkgDir: cfg.PackagesDir}
|
||||||
|
mux := New(st, authSvc, sp, &cfg, cache.New())
|
||||||
|
root := chi.NewRouter()
|
||||||
|
root.Mount("/api/v1", mux)
|
||||||
|
h.srv = httptest.NewServer(root)
|
||||||
|
t.Cleanup(h.srv.Close)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *harness) do(method, path, token string, body any) (int, []byte) {
|
||||||
|
h.t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
r = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, h.srv.URL+path, r)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("new req: %v", err)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("do %s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *harness) serverInfo() (int, map[string]any) {
|
||||||
|
code, body := h.do("GET", "/api/v1/server/info", "", nil)
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(body, &m)
|
||||||
|
return code, m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerInfoNeedsSetup(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code, m := h.serverInfo()
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("status %d", code)
|
||||||
|
}
|
||||||
|
if m["needs_setup"] != true {
|
||||||
|
t.Fatalf("expected needs_setup=true, got %v", m["needs_setup"])
|
||||||
|
}
|
||||||
|
if m["default_key_fingerprint"] == "" {
|
||||||
|
t.Fatal("expected fingerprint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterFirstUserIsAdmin(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{
|
||||||
|
"username": "alice", "password": "supersecret",
|
||||||
|
})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("register status %d body %s", code, body)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
User map[string]any `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Token == "" {
|
||||||
|
t.Fatal("empty token")
|
||||||
|
}
|
||||||
|
if resp.User["is_admin"] != true {
|
||||||
|
t.Fatalf("first user should be admin, got %v", resp.User["is_admin"])
|
||||||
|
}
|
||||||
|
h.token = resp.Token
|
||||||
|
|
||||||
|
// /me with token
|
||||||
|
code, body = h.do("GET", "/api/v1/me", h.token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("me status %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// needs_setup should now be false
|
||||||
|
_, m := h.serverInfo()
|
||||||
|
if m["needs_setup"] != false {
|
||||||
|
t.Fatalf("expected needs_setup=false after register")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsBadUsername(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code, _ := h.do("POST", "/api/v1/auth/register", "", map[string]string{
|
||||||
|
"username": "A", "password": "supersecret",
|
||||||
|
})
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("expected 400 for short username, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginAndAuthFlow(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
h.do("POST", "/api/v1/auth/register", "", map[string]string{
|
||||||
|
"username": "bob", "password": "supersecret",
|
||||||
|
})
|
||||||
|
|
||||||
|
code, body := h.do("POST", "/api/v1/auth/login", "", map[string]string{
|
||||||
|
"username": "bob", "password": "supersecret",
|
||||||
|
})
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("login status %d", code)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &resp)
|
||||||
|
if resp.Token == "" {
|
||||||
|
t.Fatal("empty token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrong password
|
||||||
|
code, _ = h.do("POST", "/api/v1/auth/login", "", map[string]string{
|
||||||
|
"username": "bob", "password": "wrongpassword",
|
||||||
|
})
|
||||||
|
if code != 401 {
|
||||||
|
t.Fatalf("expected 401 for wrong password, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// me without token
|
||||||
|
code, _ = h.do("GET", "/api/v1/me", "", nil)
|
||||||
|
if code != 401 {
|
||||||
|
t.Fatalf("expected 401 without token, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokens list
|
||||||
|
code, _ = h.do("GET", "/api/v1/me/tokens", resp.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("tokens list status %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create token
|
||||||
|
code, body = h.do("POST", "/api/v1/me/tokens", resp.Token, map[string]string{"name": "laptop"})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("create token status %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logout
|
||||||
|
code, _ = h.do("POST", "/api/v1/auth/logout", resp.Token, nil)
|
||||||
|
if code != 204 {
|
||||||
|
t.Fatalf("logout status %d", code)
|
||||||
|
}
|
||||||
|
// token now revoked
|
||||||
|
code, _ = h.do("GET", "/api/v1/me", resp.Token, nil)
|
||||||
|
if code != 401 {
|
||||||
|
t.Fatalf("expected 401 after logout, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersAdminOnly(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
// register two users; first is admin
|
||||||
|
_, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "admin", "password": "supersecret"})
|
||||||
|
var admin struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &admin)
|
||||||
|
|
||||||
|
_, body = h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "carol", "password": "supersecret"})
|
||||||
|
var carol struct {
|
||||||
|
User map[string]any `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &carol)
|
||||||
|
if carol.User["is_admin"] == true {
|
||||||
|
t.Fatal("second user should not be admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// carol cannot list users
|
||||||
|
code, _ := h.do("GET", "/api/v1/users", carol.Token, nil)
|
||||||
|
if code != 403 {
|
||||||
|
t.Fatalf("non-admin list users should be 403, got %d", code)
|
||||||
|
}
|
||||||
|
// admin can list users
|
||||||
|
code, body = h.do("GET", "/api/v1/users", admin.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("admin list users should be 200, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin cannot delete self
|
||||||
|
code, _ = h.do("DELETE", "/api/v1/users/"+carol.User["id"].(string), admin.Token, nil)
|
||||||
|
if code != 204 {
|
||||||
|
t.Fatalf("admin delete carol should be 204, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep context import used in case of future expansion
|
||||||
|
var _ = context.Background
|
||||||
|
|
||||||
|
// testSigner implements restapi.SignerProvider for tests.
|
||||||
|
type testSigner struct{ key *gpg.Key }
|
||||||
|
|
||||||
|
func (s *testSigner) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() }
|
||||||
|
func (s *testSigner) Fingerprint() string { return s.key.Fingerprint }
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"urapt/server/middleware"
|
||||||
|
"urapt/server/store"
|
||||||
|
apitypes "urapt/shared/api"
|
||||||
|
"urapt/shared/crypto"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register creates a new account. The first account becomes the admin. When
|
||||||
|
// open_registration is false and an account already exists, registration is
|
||||||
|
// closed to non-admins.
|
||||||
|
func (api *API) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req apitypes.RegisterRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if !validateUsername(req.Username) {
|
||||||
|
bad(w, "username must be 3-32 chars of [a-z0-9_-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validatePassword(req.Password) {
|
||||||
|
bad(w, "password must be 8-256 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := api.Store.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(users) > 0 && !api.Config.OpenRegistration {
|
||||||
|
httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "registration is closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := api.Store.GetUserByUsername(r.Context(), req.Username); err == nil {
|
||||||
|
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "username already taken")
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := crypto.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _, err := api.Store.CreateUser(r.Context(), req.Username, hash)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := api.issueToken(r.Context(), user.ID, "login")
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &user.ID, nil, "user.register", user.Username, "")
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, apitypes.AuthResponse{User: user, Token: token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user and issues a new API token.
|
||||||
|
func (api *API) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req apitypes.LoginRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := api.Store.GetUserByUsername(r.Context(), req.Username)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusUnauthorized, httputil.CodeUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Need the password hash; fetch via a dedicated method.
|
||||||
|
hash, err := api.Store.GetUserPasswordHash(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !crypto.VerifyPassword(hash, req.Password) {
|
||||||
|
httputil.WriteError(w, http.StatusUnauthorized, httputil.CodeUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := api.issueToken(r.Context(), user.ID, "login")
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &user.ID, nil, "user.login", user.Username, "")
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, apitypes.AuthResponse{User: user, Token: token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes the caller's current token.
|
||||||
|
func (api *API) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
if err := api.Store.RevokeToken(r.Context(), id.User.ID, id.TokenID); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to revoke token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me returns the caller's user record.
|
||||||
|
func (api *API) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, id.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens returns the caller's tokens.
|
||||||
|
func (api *API) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
tokens, err := api.Store.ListTokens(r.Context(), id.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tokens == nil {
|
||||||
|
tokens = []*models.APIToken{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, apitypes.ListResponse[*models.APIToken]{Items: tokens, Page: 1, PerPage: 100, Total: len(tokens)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken issues a new named token for the caller.
|
||||||
|
func (api *API) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req apitypes.CreateTokenRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tokenNameRE.MatchString(req.Name) {
|
||||||
|
bad(w, "name must be 1-64 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
token, row, err := api.issueTokenRow(r.Context(), id.User.ID, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.Token = token
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &id.User.ID, nil, "token.create", req.Name, "")
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeToken revokes one of the caller's tokens by id.
|
||||||
|
func (api *API) RevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
tokenID := r.PathValue("id")
|
||||||
|
if err := api.Store.RevokeToken(r.Context(), id.User.ID, tokenID); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "token not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to revoke token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueToken generates a token, persists its hash, and returns the plaintext.
|
||||||
|
func (api *API) issueToken(ctx context.Context, userID, name string) (string, error) {
|
||||||
|
token, hash, prefix, err := crypto.GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := api.Store.CreateToken(ctx, userID, name, prefix, hash); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueTokenRow is like issueToken but also returns the persisted token row.
|
||||||
|
func (api *API) issueTokenRow(ctx context.Context, userID, name string) (string, *models.APIToken, error) {
|
||||||
|
token, hash, prefix, err := crypto.GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
row, err := api.Store.CreateToken(ctx, userID, name, prefix, hash)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return token, row, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"urapt/server/middleware"
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/apt"
|
||||||
|
"urapt/shared/deb"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListPackages lists packages in a (repo, distribution) with optional filters.
|
||||||
|
func (api *API) ListPackages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, dist := api.loadDistro(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := store.PackageFilters{
|
||||||
|
ComponentID: r.URL.Query().Get("component"),
|
||||||
|
Arch: r.URL.Query().Get("arch"),
|
||||||
|
Name: r.URL.Query().Get("name"),
|
||||||
|
Query: r.URL.Query().Get("q"),
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
||||||
|
pkgs, total, err := api.Store.ListPackages(r.Context(), repo.ID, dist.ID, f, page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list packages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkgs == nil {
|
||||||
|
pkgs = []*models.Package{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": pkgs, "page": pageOr(page), "per_page": perPageOr(perPage), "total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushPackage receives a multipart .deb upload, validates it, stores the blob,
|
||||||
|
// and records the package.
|
||||||
|
func (api *API) PushPackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, dist := api.requireDistroWrite(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
|
||||||
|
// Stream the multipart upload to a temp file in the packages dir.
|
||||||
|
tempPath, origName, componentName, err := api.receiveUpload(r)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanup := func() { _ = os.Remove(tempPath) }
|
||||||
|
defer func() { _ = os.Remove(tempPath) }()
|
||||||
|
|
||||||
|
if componentName == "" {
|
||||||
|
bad(w, "component field is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
component, err := api.Store.GetComponentByName(r.Context(), dist.ID, componentName)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "component not found in distribution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and hash the uploaded .deb.
|
||||||
|
inspected, err := deb.Inspect(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "invalid .deb: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctrl := inspected.Control
|
||||||
|
if ctrl.Get("Package") == "" || ctrl.Get("Version") == "" || ctrl.Get("Architecture") == "" {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "control missing Package/Version/Architecture")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate architecture is configured (or "all").
|
||||||
|
pkgArch := ctrl.Get("Architecture")
|
||||||
|
if pkgArch != "all" {
|
||||||
|
ok, err := api.Store.HasArchitecture(r.Context(), dist.ID, pkgArch)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check architecture")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "architecture "+pkgArch+" not configured for distribution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup on (repo, distro, component, name, version, arch).
|
||||||
|
existing, err := api.Store.GetPackageByPoolPath(r.Context(), repo.ID,
|
||||||
|
apt.PoolPath(component.Name, ctrl.Get("Source"), ctrl.Get("Package"), origName))
|
||||||
|
_ = existing
|
||||||
|
if err == nil {
|
||||||
|
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict,
|
||||||
|
"package "+ctrl.Get("Package")+"_"+ctrl.Get("Version")+"_"+pkgArch+" already exists")
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check duplicate")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-create the content-addressed blob.
|
||||||
|
blobFileName := api.blobFilePath(inspected.SHA256)
|
||||||
|
created, err := api.Store.CreateBlob(r.Context(), inspected.SHA256, blobFileName, inspected.Size)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to record blob")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
// New blob: move the temp file into place.
|
||||||
|
if err := os.Rename(tempPath, blobFileName); err != nil {
|
||||||
|
_ = api.Store.DeleteBlob(r.Context(), inspected.SHA256)
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to store package file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Existing blob: increment ref count and discard the temp upload.
|
||||||
|
if _, err := api.Store.IncBlobRef(r.Context(), inspected.SHA256); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to increment blob ref")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := apt.PoolPath(component.Name, ctrl.Get("Source"), ctrl.Get("Package"), origName)
|
||||||
|
pkg := &models.Package{
|
||||||
|
RepositoryID: repo.ID,
|
||||||
|
DistributionID: dist.ID,
|
||||||
|
ComponentID: component.ID,
|
||||||
|
Name: ctrl.Get("Package"),
|
||||||
|
Version: ctrl.Get("Version"),
|
||||||
|
Architecture: pkgArch,
|
||||||
|
Source: ctrl.Get("Source"),
|
||||||
|
Maintainer: ctrl.Get("Maintainer"),
|
||||||
|
Priority: ctrl.Get("Priority"),
|
||||||
|
Section: ctrl.Get("Section"),
|
||||||
|
Origin: ctrl.Get("Origin"),
|
||||||
|
Homepage: ctrl.Get("Homepage"),
|
||||||
|
Description: ctrl.Get("Description"),
|
||||||
|
DescriptionMD5: ctrl.DescriptionMD5(),
|
||||||
|
Depends: ctrl.Get("Depends"),
|
||||||
|
PreDepends: ctrl.Get("Pre-Depends"),
|
||||||
|
Recommends: ctrl.Get("Recommends"),
|
||||||
|
Suggests: ctrl.Get("Suggests"),
|
||||||
|
Conflicts: ctrl.Get("Conflicts"),
|
||||||
|
Breaks: ctrl.Get("Breaks"),
|
||||||
|
Provides: ctrl.Get("Provides"),
|
||||||
|
Replaces: ctrl.Get("Replaces"),
|
||||||
|
Enhances: ctrl.Get("Enhances"),
|
||||||
|
InstalledSize: parseInt64(ctrl.Get("Installed-Size")),
|
||||||
|
Essential: ctrl.Get("Essential"),
|
||||||
|
BuiltUsing: ctrl.Get("Built-Using"),
|
||||||
|
Tag: ctrl.Get("Tag"),
|
||||||
|
RawControl: ctrl.Raw,
|
||||||
|
Filename: blobFileName,
|
||||||
|
PoolPath: pool,
|
||||||
|
Size: inspected.Size,
|
||||||
|
MD5sum: inspected.MD5sum,
|
||||||
|
SHA1: inspected.SHA1,
|
||||||
|
SHA256: inspected.SHA256,
|
||||||
|
UploadedByUserID: id.User.ID,
|
||||||
|
}
|
||||||
|
if err := api.Store.CreatePackage(r.Context(), pkg); err != nil {
|
||||||
|
// Roll back the blob ref we added.
|
||||||
|
if rc, _ := api.Store.DecBlobRef(r.Context(), inspected.SHA256); rc == 0 {
|
||||||
|
_ = api.Store.DeleteBlob(r.Context(), inspected.SHA256)
|
||||||
|
_ = os.Remove(blobFileName)
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "package already exists or invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Cache.Invalidate(repo.ID, dist.Name)
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "package.push", pkg.Name+"_"+pkg.Version, pkg.Architecture)
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackage returns a single package by id.
|
||||||
|
func (api *API) GetPackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireRead(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkg, err := api.Store.GetPackageByID(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read package")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.RepositoryID != repo.ID {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageFile streams a package's .deb file (used by the CLI pull command).
|
||||||
|
func (api *API) GetPackageFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireRead(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkg, err := api.Store.GetPackageByID(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || pkg.RepositoryID != repo.ID {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := api.blobFilePath(pkg.SHA256)
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(pkg.PoolPath)+`"`)
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePackage removes a package and decrements its blob reference.
|
||||||
|
func (api *API) DeletePackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireWrite(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkg, err := api.Store.GetPackageByID(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || pkg.RepositoryID != repo.ID {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.DeletePackage(r.Context(), pkg.ID); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete package")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rc, _ := api.Store.DecBlobRef(r.Context(), pkg.SHA256); rc == 0 {
|
||||||
|
_ = api.Store.DeleteBlob(r.Context(), pkg.SHA256)
|
||||||
|
_ = os.Remove(api.blobFilePath(pkg.SHA256))
|
||||||
|
}
|
||||||
|
api.Cache.InvalidateRepo(repo.ID)
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "package.delete", pkg.Name+"_"+pkg.Version, pkg.Architecture)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiveUpload streams a multipart upload (field "file" + "component") to a
|
||||||
|
// temp file in the packages directory, enforcing the size limit. Returns the
|
||||||
|
// temp path, original filename, component name, and any error.
|
||||||
|
func (api *API) receiveUpload(r *http.Request) (tempPath, origName, component string, err error) {
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", errors.New("expected multipart/form-data")
|
||||||
|
}
|
||||||
|
maxSize := api.Config.MaxPackageSize
|
||||||
|
|
||||||
|
f, err := os.CreateTemp(api.Config.PackagesDir, ".upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", errors.New("failed to create temp file")
|
||||||
|
}
|
||||||
|
tempPath = f.Name()
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
gotFile := false
|
||||||
|
gotComponent := false
|
||||||
|
var written int64
|
||||||
|
for {
|
||||||
|
part, perr := reader.NextPart()
|
||||||
|
if perr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if perr != nil {
|
||||||
|
return tempPath, "", "", perr
|
||||||
|
}
|
||||||
|
switch part.FormName() {
|
||||||
|
case "component":
|
||||||
|
data, derr := io.ReadAll(io.LimitReader(part, 256))
|
||||||
|
if derr != nil {
|
||||||
|
return tempPath, "", "", derr
|
||||||
|
}
|
||||||
|
component = strings.TrimSpace(string(data))
|
||||||
|
gotComponent = true
|
||||||
|
case "file":
|
||||||
|
origName = part.FileName()
|
||||||
|
if origName == "" {
|
||||||
|
return tempPath, "", "", errors.New("file field has no filename")
|
||||||
|
}
|
||||||
|
n, werr := io.Copy(f, io.LimitReader(part, maxSize+1))
|
||||||
|
if werr != nil {
|
||||||
|
return tempPath, origName, "", werr
|
||||||
|
}
|
||||||
|
written = n
|
||||||
|
gotFile = true
|
||||||
|
default:
|
||||||
|
// ignore unknown fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gotFile {
|
||||||
|
return tempPath, "", "", errors.New("missing 'file' field")
|
||||||
|
}
|
||||||
|
if !gotComponent {
|
||||||
|
return tempPath, origName, "", errors.New("missing 'component' field")
|
||||||
|
}
|
||||||
|
if written > maxSize {
|
||||||
|
return tempPath, origName, "", errors.New("package exceeds max size")
|
||||||
|
}
|
||||||
|
_ = gotComponent
|
||||||
|
return tempPath, origName, component, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInt64 parses a base-10 int64, returning 0 on error.
|
||||||
|
func parseInt64(s string) int64 {
|
||||||
|
n, _ := strconv.ParseInt(s, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageOr defaults page to 1.
|
||||||
|
func pageOr(p int) int {
|
||||||
|
if p < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// perPageOr defaults per-page to 25.
|
||||||
|
func perPageOr(p int) int {
|
||||||
|
if p < 1 {
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
if p > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildDebBytes constructs a minimal valid .deb with the given control text.
|
||||||
|
func buildDebBytes(t *testing.T, controlText string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var ctrlBuf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&ctrlBuf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
writeTarFile(t, tw, "control", controlText)
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
|
||||||
|
var dataBuf bytes.Buffer
|
||||||
|
gz2 := gzip.NewWriter(&dataBuf)
|
||||||
|
tw2 := tar.NewWriter(gz2)
|
||||||
|
writeTarFile(t, tw2, "usr/share/foo", "x")
|
||||||
|
tw2.Close()
|
||||||
|
gz2.Close()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("!<arch>\n")
|
||||||
|
writeArMemberBytes(&out, "debian-binary", []byte("2.0\n"))
|
||||||
|
writeArMemberBytes(&out, "control.tar.gz", ctrlBuf.Bytes())
|
||||||
|
writeArMemberBytes(&out, "data.tar.gz", dataBuf.Bytes())
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTarFile(t *testing.T, tw *tar.Writer, name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
|
||||||
|
t.Fatalf("tar header: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write([]byte(body)); err != nil {
|
||||||
|
t.Fatalf("tar write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArMemberBytes(buf *bytes.Buffer, name string, data []byte) {
|
||||||
|
header := make([]byte, 60)
|
||||||
|
for i := range header {
|
||||||
|
header[i] = ' '
|
||||||
|
}
|
||||||
|
copy(header[0:], name+"/")
|
||||||
|
ds := []byte(padLeftInt(len(data), 10))
|
||||||
|
copy(header[48:], ds)
|
||||||
|
header[58] = '`'
|
||||||
|
header[59] = '\n'
|
||||||
|
buf.Write(header)
|
||||||
|
buf.Write(data)
|
||||||
|
if len(data)%2 == 1 {
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func padLeftInt(n, width int) string {
|
||||||
|
s := make([]byte, width)
|
||||||
|
for i := range s {
|
||||||
|
s[i] = ' '
|
||||||
|
}
|
||||||
|
digits := []byte(itoaInt(n))
|
||||||
|
copy(s[len(s)-len(digits):], digits)
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoaInt(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{byte('0' + n%10)}, b...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadPackage POSTs a multipart push.
|
||||||
|
func (h *harness) uploadPackage(token, repo, dist, component string, deb []byte) (int, []byte) {
|
||||||
|
h.t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("component", component)
|
||||||
|
fw, _ := mw.CreateFormFile("file", "foo_1.0_amd64.deb")
|
||||||
|
fw.Write(deb)
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", h.srv.URL+"/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("upload: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushPullDeletePackage(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "owner", "password": "supersecret"})
|
||||||
|
var owner struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &owner)
|
||||||
|
|
||||||
|
// Set packages dir so blobs land in the temp dir.
|
||||||
|
_ = h.store // packages dir is taken from config (temp dir in harness).
|
||||||
|
|
||||||
|
h.do("POST", "/api/v1/repositories", owner.Token, map[string]any{"name": "pkgrepo", "visibility": "public"})
|
||||||
|
h.do("POST", "/api/v1/repositories/pkgrepo/distributions", owner.Token, map[string]string{"name": "stable"})
|
||||||
|
h.do("POST", "/api/v1/repositories/pkgrepo/distributions/stable/components", owner.Token, map[string]string{"name": "main"})
|
||||||
|
h.do("POST", "/api/v1/repositories/pkgrepo/distributions/stable/architectures", owner.Token, map[string]string{"name": "amd64"})
|
||||||
|
|
||||||
|
debBytes := buildDebBytes(t, "Package: foo\nVersion: 1.0\nArchitecture: amd64\nMaintainer: Test <t@e.com>\nDescription: short\n extended\n")
|
||||||
|
|
||||||
|
code, body := h.uploadPackage(owner.Token, "pkgrepo", "stable", "main", debBytes)
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("push status %d body %s", code, body)
|
||||||
|
}
|
||||||
|
var pkg struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Pool string `json:"pool_path"`
|
||||||
|
SHA string `json:"sha256"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &pkg)
|
||||||
|
if pkg.Name != "foo" || pkg.SHA == "" {
|
||||||
|
t.Fatalf("unexpected pkg: %+v", pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// duplicate push should 409
|
||||||
|
code, _ = h.uploadPackage(owner.Token, "pkgrepo", "stable", "main", debBytes)
|
||||||
|
if code != 409 {
|
||||||
|
t.Fatalf("duplicate push should be 409, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// list
|
||||||
|
code, body = h.do("GET", "/api/v1/repositories/pkgrepo/distributions/stable/packages", owner.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("list packages status %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get
|
||||||
|
code, _ = h.do("GET", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID, owner.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("get package status %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// download file
|
||||||
|
code, body = h.do("GET", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID+"/file", owner.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("get file status %d", code)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(body, debBytes) {
|
||||||
|
t.Fatalf("downloaded file does not match uploaded (%d vs %d bytes)", len(body), len(debBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify blob file exists on disk in the temp packages dir
|
||||||
|
blobPath := filepath.Join(h.pkgDir, pkg.SHA+".deb")
|
||||||
|
if _, err := os.Stat(blobPath); err != nil {
|
||||||
|
t.Fatalf("blob file missing on disk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete
|
||||||
|
code, _ = h.do("DELETE", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID, owner.Token, nil)
|
||||||
|
if code != 204 {
|
||||||
|
t.Fatalf("delete package status %d", code)
|
||||||
|
}
|
||||||
|
// get now 404
|
||||||
|
code, _ = h.do("GET", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID, owner.Token, nil)
|
||||||
|
if code != 404 {
|
||||||
|
t.Fatalf("deleted package should 404, got %d", code)
|
||||||
|
}
|
||||||
|
// blob file removed after refcount hits 0
|
||||||
|
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("blob file should be removed after delete, err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"urapt/server/middleware"
|
||||||
|
"urapt/server/store"
|
||||||
|
apitypes "urapt/shared/api"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nameRE is the shared validator for repository, distribution, component, and
|
||||||
|
// architecture names: lowercase, starting alphanumeric, allowing -+., length
|
||||||
|
// 1-64.
|
||||||
|
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9+.\-]{0,63}$`)
|
||||||
|
|
||||||
|
// loadRepoByName fetches a repository by its {repo} path param, rendering the
|
||||||
|
// appropriate error. nil is returned only after an error has been written.
|
||||||
|
func (api *API) loadRepoByName(w http.ResponseWriter, r *http.Request) *models.Repository {
|
||||||
|
name := r.PathValue("repo")
|
||||||
|
repo, err := api.Store.GetRepositoryByName(r.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "repository not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read repository")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireRead loads the repo and checks CanRead; returns the repo or nil (after
|
||||||
|
// writing an error).
|
||||||
|
func (api *API) requireRead(w http.ResponseWriter, r *http.Request) *models.Repository {
|
||||||
|
repo := api.loadRepoByName(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
ok, err := api.Auth.CanRead(r.Context(), id.User, repo)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "no read access")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireWrite loads the repo and checks CanWrite.
|
||||||
|
func (api *API) requireWrite(w http.ResponseWriter, r *http.Request) *models.Repository {
|
||||||
|
repo := api.loadRepoByName(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
ok, err := api.Auth.CanWrite(r.Context(), id.User, repo)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "no write access")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireManage loads the repo and checks CanManage.
|
||||||
|
func (api *API) requireManage(w http.ResponseWriter, r *http.Request) *models.Repository {
|
||||||
|
repo := api.loadRepoByName(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
ok, err := api.Auth.CanManage(r.Context(), id.User, repo)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "manage access required")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepositories returns repositories visible to the caller.
|
||||||
|
func (api *API) ListRepositories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
repos, err := api.Store.ListReposVisible(r.Context(), id.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list repositories")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if repos == nil {
|
||||||
|
repos = []*models.Repository{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, apitypes.ListResponse[*models.Repository]{Items: repos, Page: 1, PerPage: 100, Total: len(repos)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepository creates a new repository owned by the caller.
|
||||||
|
func (api *API) CreateRepository(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req apitypes.CreateRepoRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if !nameRE.MatchString(req.Name) {
|
||||||
|
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vis := models.Visibility(strings.ToLower(req.Visibility))
|
||||||
|
if vis != models.VisibilityPublic && vis != models.VisibilityPrivate {
|
||||||
|
bad(w, "visibility must be 'public' or 'private'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
if _, err := api.Store.GetRepositoryByName(r.Context(), req.Name); err == nil {
|
||||||
|
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "repository name already taken")
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repo, err := api.Store.CreateRepository(r.Context(), req.Name, id.User.ID, vis, req.Description)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create repository")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "repo.create", repo.Name, "")
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepository returns a single repository.
|
||||||
|
func (api *API) GetRepository(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireRead(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepository mutates a repository.
|
||||||
|
func (api *API) UpdateRepository(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireManage(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apitypes.UpdateRepoRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var vis *models.Visibility
|
||||||
|
if req.Visibility != nil {
|
||||||
|
v := models.Visibility(strings.ToLower(*req.Visibility))
|
||||||
|
if v != models.VisibilityPublic && v != models.VisibilityPrivate {
|
||||||
|
bad(w, "visibility must be 'public' or 'private'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vis = &v
|
||||||
|
}
|
||||||
|
if req.Name != nil {
|
||||||
|
if !nameRE.MatchString(*req.Name) {
|
||||||
|
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := api.Store.UpdateRepository(r.Context(), repo.ID, valStr(req.Name), vis, req.Description); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to update repository")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Cache.InvalidateRepo(repo.ID)
|
||||||
|
updated, _ := api.Store.GetRepositoryByID(r.Context(), repo.ID)
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepository removes a repository and cleans up its blobs.
|
||||||
|
func (api *API) DeleteRepository(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireManage(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shas, err := api.Store.ListPackageBlobSHA256sByRepo(r.Context(), repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list packages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.DeletePackagesByRepo(r.Context(), repo.ID); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete packages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.DeleteRepository(r.Context(), repo.ID); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete repository")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, sha := range shas {
|
||||||
|
rc, _ := api.Store.DecBlobRef(r.Context(), sha)
|
||||||
|
if rc == 0 {
|
||||||
|
_ = api.Store.DeleteBlob(r.Context(), sha)
|
||||||
|
_ = os.Remove(api.blobFilePath(sha))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.Cache.InvalidateRepo(repo.ID)
|
||||||
|
id := middleware.IdentityFromContext(r.Context())
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "repo.delete", repo.Name, "")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoPubkey returns the server's armored public key (convenience endpoint).
|
||||||
|
func (api *API) RepoPubkey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.requireRead(w, r) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.ServerPubkey(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembers returns the members of a repository.
|
||||||
|
func (api *API) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireRead(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
members, err := api.Store.ListMembers(r.Context(), repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list members")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if members == nil {
|
||||||
|
members = []*models.RepositoryMember{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, members)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember grants a user access on a repository.
|
||||||
|
func (api *API) AddMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireManage(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apitypes.AddMemberRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := api.Store.GetUserByUsername(r.Context(), req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !models.ValidAccess(req.Access) {
|
||||||
|
bad(w, "access must be read, write, read-write, or admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.ID == repo.OwnerUserID {
|
||||||
|
bad(w, "cannot change owner's access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.AddMember(r.Context(), repo.ID, target.ID, models.Access(req.Access)); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to add member")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = api.Store.RecordAudit(r.Context(), &repo.OwnerUserID, &repo.ID, "member.add", req.Username, req.Access)
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, &models.RepositoryMember{
|
||||||
|
RepositoryID: repo.ID, UserID: target.ID, Access: models.Access(req.Access), User: target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMember changes a member's access level.
|
||||||
|
func (api *API) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireManage(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apitypes.UpdateMemberRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !models.ValidAccess(req.Access) {
|
||||||
|
bad(w, "access must be read, write, read-write, or admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := r.PathValue("username")
|
||||||
|
target, err := api.Store.GetUserByUsername(r.Context(), username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.ID == repo.OwnerUserID {
|
||||||
|
bad(w, "cannot change owner's access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.UpdateMemberAccess(r.Context(), repo.ID, target.ID, models.Access(req.Access)); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "member not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to update member")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, &models.RepositoryMember{
|
||||||
|
RepositoryID: repo.ID, UserID: target.ID, Access: models.Access(req.Access), User: target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember revokes a user's access.
|
||||||
|
func (api *API) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireManage(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := r.PathValue("username")
|
||||||
|
target, err := api.Store.GetUserByUsername(r.Context(), username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.ID == repo.OwnerUserID {
|
||||||
|
bad(w, "cannot remove owner")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.RemoveMember(r.Context(), repo.ID, target.ID); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "member not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to remove member")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// valStr returns s as a string pointer-or-nil.
|
||||||
|
func valStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
// blobFilePath returns the on-disk path for a content-addressed blob.
|
||||||
|
func (api *API) blobFilePath(sha256 string) string {
|
||||||
|
return filepath.Join(api.Config.PackagesDir, sha256+".deb")
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepoCreateAndPermissions(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
// alice (owner/admin), bob (non-admin)
|
||||||
|
_, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "alice", "password": "supersecret"})
|
||||||
|
var alice struct {
|
||||||
|
User map[string]any `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &alice)
|
||||||
|
|
||||||
|
_, body = h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "bob", "password": "supersecret"})
|
||||||
|
var bob struct {
|
||||||
|
User map[string]any `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &bob)
|
||||||
|
|
||||||
|
// alice creates a private repo
|
||||||
|
code, body := h.do("POST", "/api/v1/repositories", alice.Token, map[string]any{
|
||||||
|
"name": "myrepo", "visibility": "private", "description": "test",
|
||||||
|
})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("create repo status %d body %s", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob cannot see it (private, not a member)
|
||||||
|
code, body = h.do("GET", "/api/v1/repositories/myrepo", bob.Token, nil)
|
||||||
|
if code != 403 {
|
||||||
|
t.Fatalf("bob should be forbidden from private repo, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alice grants bob read access
|
||||||
|
code, _ = h.do("POST", "/api/v1/repositories/myrepo/members", alice.Token, map[string]string{
|
||||||
|
"username": "bob", "access": "read",
|
||||||
|
})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("add member status %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob can now read
|
||||||
|
code, _ = h.do("GET", "/api/v1/repositories/myrepo", bob.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("bob should read after grant, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob cannot write (read only)
|
||||||
|
code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions", bob.Token, map[string]string{"name": "stable"})
|
||||||
|
if code != 403 {
|
||||||
|
t.Fatalf("bob read-only should not write, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alice upgrades bob to write
|
||||||
|
h.do("PATCH", "/api/v1/repositories/myrepo/members/bob", alice.Token, map[string]string{"access": "write"})
|
||||||
|
code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions", bob.Token, map[string]string{"name": "stable"})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("bob with write should create distro, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add component and arch
|
||||||
|
h.do("POST", "/api/v1/repositories/myrepo/distributions/stable/components", alice.Token, map[string]string{"name": "main"})
|
||||||
|
code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions/stable/architectures", alice.Token, map[string]string{"name": "amd64"})
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("add arch status %d", code)
|
||||||
|
}
|
||||||
|
// 'all' arch rejected
|
||||||
|
code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions/stable/architectures", alice.Token, map[string]string{"name": "all"})
|
||||||
|
if code != 400 {
|
||||||
|
t.Fatalf("all arch should be rejected, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// list distros/components/arches
|
||||||
|
code, _ = h.do("GET", "/api/v1/repositories/myrepo/distributions", alice.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("list distros status %d", code)
|
||||||
|
}
|
||||||
|
code, _ = h.do("GET", "/api/v1/repositories/myrepo/distributions/stable/components", alice.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("list components status %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove member
|
||||||
|
code, _ = h.do("DELETE", "/api/v1/repositories/myrepo/members/bob", alice.Token, nil)
|
||||||
|
if code != 204 {
|
||||||
|
t.Fatalf("remove member status %d", code)
|
||||||
|
}
|
||||||
|
code, _ = h.do("GET", "/api/v1/repositories/myrepo", bob.Token, nil)
|
||||||
|
if code != 403 {
|
||||||
|
t.Fatalf("bob should be forbidden after removal, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRepoReadableByAll(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "owner", "password": "supersecret"})
|
||||||
|
var owner struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &owner)
|
||||||
|
_, body = h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "stranger", "password": "supersecret"})
|
||||||
|
var stranger struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &stranger)
|
||||||
|
|
||||||
|
h.do("POST", "/api/v1/repositories", owner.Token, map[string]any{"name": "pubrepo", "visibility": "public"})
|
||||||
|
code, _ := h.do("GET", "/api/v1/repositories/pubrepo", stranger.Token, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("stranger should read public repo, got %d", code)
|
||||||
|
}
|
||||||
|
// but stranger cannot write
|
||||||
|
code, _ = h.do("POST", "/api/v1/repositories/pubrepo/distributions", stranger.Token, map[string]string{"name": "x"})
|
||||||
|
if code != 403 {
|
||||||
|
t.Fatalf("stranger should not write public repo, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
apitypes "urapt/shared/api"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerInfo returns build/setup metadata for the server.
|
||||||
|
func (api *API) ServerInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := api.Store.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fp := ""
|
||||||
|
if api.Signer != nil {
|
||||||
|
fp = api.Signer.Fingerprint()
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, apitypes.ServerInfo{
|
||||||
|
Version: version.Version,
|
||||||
|
NeedsSetup: len(users) == 0,
|
||||||
|
DefaultKeyFingerprint: fp,
|
||||||
|
OpenRegistration: api.Config.OpenRegistration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerPubkey returns the ASCII-armored default signing key.
|
||||||
|
func (api *API) ServerPubkey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if api.Signer == nil {
|
||||||
|
httputil.WriteError(w, http.StatusServiceUnavailable, httputil.CodeInternal, "no signing key configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pub, err := api.Signer.PublicKeyArmored()
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/pgp-keys")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(pub))
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"urapt/server/store"
|
||||||
|
apitypes "urapt/shared/api"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateName checks a distribution/component/architecture name.
|
||||||
|
func validateName(s string) bool { return nameRE.MatchString(s) }
|
||||||
|
|
||||||
|
// loadDistro fetches the {repo}/{dist} distribution, rendering errors.
|
||||||
|
func (api *API) loadDistro(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.Distribution) {
|
||||||
|
repo := api.requireRead(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
dist, err := api.Store.GetDistributionByName(r.Context(), repo.ID, r.PathValue("dist"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found")
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read distribution")
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
return repo, dist
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireDistroWrite loads repo (write) + distribution.
|
||||||
|
func (api *API) requireDistroWrite(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.Distribution) {
|
||||||
|
repo := api.requireWrite(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
dist, err := api.Store.GetDistributionByName(r.Context(), repo.ID, r.PathValue("dist"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found")
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read distribution")
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
return repo, dist
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- distributions ---
|
||||||
|
|
||||||
|
// ListDistributions returns the distributions in a repository.
|
||||||
|
func (api *API) ListDistributions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireRead(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dists, err := api.Store.ListDistributions(r.Context(), repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list distributions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dists == nil {
|
||||||
|
dists = []*models.Distribution{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, dists)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDistribution adds a distribution to a repository.
|
||||||
|
func (api *API) CreateDistribution(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := api.requireWrite(w, r)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apitypes.CreateNamedRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validateName(req.Name) {
|
||||||
|
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := api.Store.GetDistributionByName(r.Context(), repo.ID, req.Name); err == nil {
|
||||||
|
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "distribution already exists")
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check distribution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dist, err := api.Store.CreateDistribution(r.Context(), repo.ID, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create distribution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Cache.Invalidate(repo.ID, dist.Name)
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, dist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDistribution removes a distribution (cascades to packages).
|
||||||
|
func (api *API) DeleteDistribution(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, dist := api.requireDistroWrite(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.DeleteDistribution(r.Context(), repo.ID, dist.Name); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete distribution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Note: cascaded package rows are gone; their blob ref counts are now
|
||||||
|
// stale. Best-effort cleanup of orphan blobs is handled by package delete
|
||||||
|
// in normal operation; bulk distribution deletion leaves blobs for now.
|
||||||
|
api.Cache.Invalidate(repo.ID, dist.Name)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- components ---
|
||||||
|
|
||||||
|
// ListComponents returns the components in a distribution.
|
||||||
|
func (api *API) ListComponents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, dist := api.loadDistro(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comps, err := api.Store.ListComponents(r.Context(), dist.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list components")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if comps == nil {
|
||||||
|
comps = []*models.Component{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, comps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateComponent adds a component to a distribution.
|
||||||
|
func (api *API) CreateComponent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, dist := api.requireDistroWrite(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apitypes.CreateNamedRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validateName(req.Name) {
|
||||||
|
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := api.Store.GetComponentByName(r.Context(), dist.ID, req.Name); err == nil {
|
||||||
|
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "component already exists")
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check component")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comp, err := api.Store.CreateComponent(r.Context(), dist.ID, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create component")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Cache.Invalidate(repo.ID, dist.Name)
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, comp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComponent removes a component (blocked if packages reference it).
|
||||||
|
func (api *API) DeleteComponent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, dist := api.requireDistroWrite(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comp, err := api.Store.GetComponentByName(r.Context(), dist.ID, r.PathValue("comp"))
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "component not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.DeleteComponent(r.Context(), dist.ID, comp.Name); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete component (packages may still reference it)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Cache.Invalidate(repo.ID, dist.Name)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- architectures ---
|
||||||
|
|
||||||
|
// ListArchitectures returns the architectures in a distribution.
|
||||||
|
func (api *API) ListArchitectures(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, dist := api.loadDistro(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arches, err := api.Store.ListArchitectures(r.Context(), dist.ID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list architectures")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if arches == nil {
|
||||||
|
arches = []*models.Architecture{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, arches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArchitecture adds an architecture to a distribution.
|
||||||
|
func (api *API) CreateArchitecture(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, dist := api.requireDistroWrite(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apitypes.CreateNamedRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validateName(req.Name) {
|
||||||
|
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "all" {
|
||||||
|
bad(w, "architecture 'all' is implicit and cannot be added")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arch, err := api.Store.CreateArchitecture(r.Context(), dist.ID, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create architecture (already exists?)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Cache.Invalidate(repo.ID, dist.Name)
|
||||||
|
httputil.WriteJSON(w, http.StatusCreated, arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteArchitecture removes an architecture from a distribution.
|
||||||
|
func (api *API) DeleteArchitecture(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, dist := api.requireDistroWrite(w, r)
|
||||||
|
if dist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.DeleteArchitecture(r.Context(), dist.ID, r.PathValue("arch")); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "architecture not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete architecture")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Cache.Invalidate(repo.ID, dist.Name)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"urapt/server/middleware"
|
||||||
|
"urapt/server/store"
|
||||||
|
apitypes "urapt/shared/api"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListUsers returns all users (admin only).
|
||||||
|
func (api *API) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := api.Store.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if users == nil {
|
||||||
|
users = []*models.User{}
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, apitypes.ListResponse[*models.User]{Items: users, Page: 1, PerPage: 100, Total: len(users)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns a single user by id (admin only).
|
||||||
|
func (api *API) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
user, err := api.Store.GetUserByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser mutates a user (currently only is_admin) (admin only).
|
||||||
|
func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var req apitypes.UpdateUserRequest
|
||||||
|
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
|
||||||
|
bad(w, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := api.Store.GetUserByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller := middleware.IdentityFromContext(r.Context())
|
||||||
|
if req.IsAdmin != nil {
|
||||||
|
if *req.IsAdmin && !caller.User.IsAdmin {
|
||||||
|
httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "cannot grant admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.ID == caller.User.ID && !*req.IsAdmin {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "cannot revoke your own admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := api.Store.UpdateUser(r.Context(), id, req.IsAdmin); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to update user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, _ := api.Store.GetUserByID(r.Context(), id)
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user (admin only). Self-deletion is blocked.
|
||||||
|
func (api *API) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
caller := middleware.IdentityFromContext(r.Context())
|
||||||
|
if id == caller.User.ID {
|
||||||
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "cannot delete your own account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := api.Store.DeleteUser(r.Context(), id); err != nil {
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// RecordAudit inserts a best-effort audit log entry. Errors are ignored at the
|
||||||
|
// call site's discretion; this helper returns the error for completeness.
|
||||||
|
func (s *Store) RecordAudit(ctx context.Context, userID, repositoryID *string, action, target, details string) error {
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO audit_log (id, user_id, repository_id, action, target, details, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
newID(), nullable(userID), nullable(repositoryID), action, target, details, s.now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullable(p *string) any {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBlob returns a blob by its sha256, or ErrNotFound.
|
||||||
|
func (s *Store) GetBlob(ctx context.Context, sha256 string) (filename string, size, refCount int64, err error) {
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT filename, size, ref_count FROM blobs WHERE sha256 = ?`, sha256).
|
||||||
|
Scan(&filename, &size, &refCount)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return "", 0, 0, ErrNotFound
|
||||||
|
}
|
||||||
|
return filename, size, refCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBlob creates a new blob row with ref_count=1. It returns
|
||||||
|
// (created=true) when a new row was inserted, or (created=false) when the
|
||||||
|
// blob already existed (in which case its ref_count is left unchanged here;
|
||||||
|
// use IncBlobRef to bump it).
|
||||||
|
func (s *Store) CreateBlob(ctx context.Context, sha256, filename string, size int64) (created bool, err error) {
|
||||||
|
now := s.now()
|
||||||
|
res, err := s.exec(ctx, `INSERT OR IGNORE INTO blobs (sha256, filename, size, ref_count, created_at) VALUES (?, ?, ?, 1, ?)`,
|
||||||
|
sha256, filename, size, now)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("insert blob: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncBlobRef atomically increments a blob's ref_count and returns the new value.
|
||||||
|
func (s *Store) IncBlobRef(ctx context.Context, sha256 string) (int64, error) {
|
||||||
|
res, err := s.exec(ctx, `UPDATE blobs SET ref_count = ref_count + 1 WHERE sha256 = ?`, sha256)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("inc blob: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return 0, ErrNotFound
|
||||||
|
}
|
||||||
|
var rc int64
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT ref_count FROM blobs WHERE sha256 = ?`, sha256).Scan(&rc); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecBlobRef atomically decrements a blob's ref_count and returns the new
|
||||||
|
// value. When it reaches 0 the caller should delete the on-disk file and call
|
||||||
|
// DeleteBlob.
|
||||||
|
func (s *Store) DecBlobRef(ctx context.Context, sha256 string) (int64, error) {
|
||||||
|
res, err := s.exec(ctx, `UPDATE blobs SET ref_count = ref_count - 1 WHERE sha256 = ? AND ref_count > 0`, sha256)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("dec blob: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
var rc int64
|
||||||
|
if e := s.db.QueryRowContext(ctx, `SELECT ref_count FROM blobs WHERE sha256 = ?`, sha256).Scan(&rc); e != nil {
|
||||||
|
if isErrNoRows(e) {
|
||||||
|
return 0, ErrNotFound
|
||||||
|
}
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
var rc int64
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT ref_count FROM blobs WHERE sha256 = ?`, sha256).Scan(&rc); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBlob removes a blob row.
|
||||||
|
func (s *Store) DeleteBlob(ctx context.Context, sha256 string) error {
|
||||||
|
_, err := s.exec(ctx, `DELETE FROM blobs WHERE sha256 = ?`, sha256)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveGPGKey inserts a GPG key row.
|
||||||
|
func (s *Store) SaveGPGKey(ctx context.Context, fingerprint, userID, pubArmored, privArmored string, isDefault bool) (*models.GPGKey, error) {
|
||||||
|
id := newID()
|
||||||
|
now := s.now()
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO gpg_keys (id, fingerprint, user_id, public_key_armored, private_key_armored, is_default, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, fingerprint, userID, pubArmored, privArmored, boolToInt(isDefault), now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert gpg key: %w", err)
|
||||||
|
}
|
||||||
|
return &models.GPGKey{
|
||||||
|
ID: id, Fingerprint: fingerprint, UserID: userID,
|
||||||
|
PublicKeyArmored: pubArmored, IsDefault: isDefault, CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultGPGKey returns the default signing key, or ErrNotFound if none.
|
||||||
|
func (s *Store) GetDefaultGPGKey(ctx context.Context) (*models.GPGKey, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, fingerprint, user_id, public_key_armored, private_key_armored, is_default, created_at
|
||||||
|
FROM gpg_keys WHERE is_default = 1 LIMIT 1`)
|
||||||
|
k := &models.GPGKey{}
|
||||||
|
var isDefault int
|
||||||
|
err := row.Scan(&k.ID, &k.Fingerprint, &k.UserID, &k.PublicKeyArmored, &k.PrivateKeyArmored, &isDefault, &k.CreatedAt)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
k.IsDefault = isDefault == 1
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGPGKeyPublic returns just the armored public key of the default key.
|
||||||
|
func (s *Store) GetGPGKeyPublic(ctx context.Context) (string, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `SELECT public_key_armored FROM gpg_keys WHERE is_default = 1 LIMIT 1`)
|
||||||
|
var pub string
|
||||||
|
err := row.Scan(&pub)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return pub, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveGPGKeyAndGetDefault(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
k, err := s.SaveGPGKey(ctx, "ABCD1234", "user-1", "PUB-ARMORED", "PRIV-ARMORED", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
if k.ID == "" || k.Fingerprint != "ABCD1234" || !k.IsDefault {
|
||||||
|
t.Fatalf("key = %+v", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetDefaultGPGKey(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get default: %v", err)
|
||||||
|
}
|
||||||
|
if got.Fingerprint != "ABCD1234" || got.PublicKeyArmored != "PUB-ARMORED" {
|
||||||
|
t.Fatalf("got = %+v", got)
|
||||||
|
}
|
||||||
|
if got.PrivateKeyArmored != "PRIV-ARMORED" {
|
||||||
|
t.Fatal("private key armor should be retrieved from DB")
|
||||||
|
}
|
||||||
|
if !got.IsDefault {
|
||||||
|
t.Fatal("expected is_default=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDefaultGPGKey_None(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
if _, err := s.GetDefaultGPGKey(ctx); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound when no key, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGPGKeyPublic(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
_, _ = s.SaveGPGKey(ctx, "ABCD1234", "user-1", "PUB-ARMORED", "PRIV-ARMORED", true)
|
||||||
|
|
||||||
|
pub, err := s.GetGPGKeyPublic(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get public: %v", err)
|
||||||
|
}
|
||||||
|
if pub != "PUB-ARMORED" {
|
||||||
|
t.Fatalf("pub = %q", pub)
|
||||||
|
}
|
||||||
|
// No default key.
|
||||||
|
s2 := newTestStore(t)
|
||||||
|
if _, err := s2.GetGPGKeyPublic(ctx); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- audit ---
|
||||||
|
|
||||||
|
func TestRecordAudit(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
// audit_log has FK constraints on user_id/repo_id, so use real rows.
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
uid, repoID := u.ID, repo.ID
|
||||||
|
|
||||||
|
// Insert with both user and repo set.
|
||||||
|
if err := s.RecordAudit(ctx, &uid, &repoID, "push", "pkg-1", "uploaded myapp"); err != nil {
|
||||||
|
t.Fatalf("record: %v", err)
|
||||||
|
}
|
||||||
|
// Insert with nil pointers (system action).
|
||||||
|
if err := s.RecordAudit(ctx, nil, nil, "startup", "server", "started"); err != nil {
|
||||||
|
t.Fatalf("record nil: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify rows exist. The audit_log table is write-only from the store API;
|
||||||
|
// query directly to confirm persistence.
|
||||||
|
var n int
|
||||||
|
err := s.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("expected 2 audit rows, got %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify nullable columns are stored correctly.
|
||||||
|
var uidVal, repoVal *string
|
||||||
|
row := s.DB().QueryRowContext(ctx, `SELECT user_id, repository_id FROM audit_log WHERE action = 'startup'`)
|
||||||
|
if err := row.Scan(&uidVal, &repoVal); err != nil {
|
||||||
|
t.Fatalf("scan startup row: %v", err)
|
||||||
|
}
|
||||||
|
if uidVal != nil || repoVal != nil {
|
||||||
|
t.Fatalf("expected nil user_id/repository_id for system action, got %v %v", uidVal, repoVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddMember grants a user access on a repository. If a grant already exists it
|
||||||
|
// is updated.
|
||||||
|
func (s *Store) AddMember(ctx context.Context, repoID, userID string, access models.Access) error {
|
||||||
|
now := s.now()
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO repository_members (repository_id, user_id, access, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(repository_id, user_id) DO UPDATE SET access = excluded.access`,
|
||||||
|
repoID, userID, string(access), now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert member: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMemberAccess changes a user's access on a repository.
|
||||||
|
func (s *Store) UpdateMemberAccess(ctx context.Context, repoID, userID string, access models.Access) error {
|
||||||
|
res, err := s.exec(ctx, `UPDATE repository_members SET access = ? WHERE repository_id = ? AND user_id = ?`,
|
||||||
|
string(access), repoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update member: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember revokes a user's access on a repository.
|
||||||
|
func (s *Store) RemoveMember(ctx context.Context, repoID, userID string) error {
|
||||||
|
res, err := s.exec(ctx, `DELETE FROM repository_members WHERE repository_id = ? AND user_id = ?`, repoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove member: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembers returns all members of a repository with their user records.
|
||||||
|
func (s *Store) ListMembers(ctx context.Context, repoID string) ([]*models.RepositoryMember, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT m.repository_id, m.user_id, m.access, m.created_at,
|
||||||
|
u.id, u.username, u.is_admin, u.created_at, u.updated_at
|
||||||
|
FROM repository_members m
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.repository_id = ?
|
||||||
|
ORDER BY u.username`, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list members: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.RepositoryMember
|
||||||
|
for rows.Next() {
|
||||||
|
m := &models.RepositoryMember{User: &models.User{}}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.RepositoryID, &m.UserID, &m.Access, &m.CreatedAt,
|
||||||
|
&m.User.ID, &m.User.Username, &m.User.IsAdmin, &m.User.CreatedAt, &m.User.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuitePackage is a package row joined with its component and distribution
|
||||||
|
// names, used by the APT index generator.
|
||||||
|
type SuitePackage struct {
|
||||||
|
models.Package
|
||||||
|
ComponentName string
|
||||||
|
DistributionName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePackage inserts a new package row.
|
||||||
|
func (s *Store) CreatePackage(ctx context.Context, p *models.Package) error {
|
||||||
|
if p.ID == "" {
|
||||||
|
p.ID = newID()
|
||||||
|
}
|
||||||
|
if p.CreatedAt == "" {
|
||||||
|
p.CreatedAt = s.now()
|
||||||
|
}
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO packages (
|
||||||
|
id, repository_id, distribution_id, component_id,
|
||||||
|
name, version, architecture, source, maintainer, priority, section,
|
||||||
|
origin, homepage, description, description_md5, depends, pre_depends,
|
||||||
|
recommends, suggests, conflicts, breaks, provides, replaces, enhances,
|
||||||
|
installed_size, essential, built_using, tag, raw_control, filename,
|
||||||
|
pool_path, size, md5sum, sha1, sha256, uploaded_by_user_id, created_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
p.ID, p.RepositoryID, p.DistributionID, p.ComponentID,
|
||||||
|
p.Name, p.Version, p.Architecture, p.Source, p.Maintainer, p.Priority, p.Section,
|
||||||
|
p.Origin, p.Homepage, p.Description, p.DescriptionMD5, p.Depends, p.PreDepends,
|
||||||
|
p.Recommends, p.Suggests, p.Conflicts, p.Breaks, p.Provides, p.Replaces, p.Enhances,
|
||||||
|
p.InstalledSize, p.Essential, p.BuiltUsing, p.Tag, p.RawControl, p.Filename,
|
||||||
|
p.PoolPath, p.Size, p.MD5sum, p.SHA1, p.SHA256, p.UploadedByUserID, p.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert package: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageByID returns a package by id.
|
||||||
|
func (s *Store) GetPackageByID(ctx context.Context, id string) (*models.Package, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, packageCols+` FROM packages WHERE id = ?`, id)
|
||||||
|
return scanPackage(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageByPoolPath returns a package within a repository by its pool_path.
|
||||||
|
func (s *Store) GetPackageByPoolPath(ctx context.Context, repoID, poolPath string) (*models.Package, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, packageCols+` FROM packages WHERE repository_id = ? AND pool_path = ?`, repoID, poolPath)
|
||||||
|
return scanPackage(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageFilters controls ListPackages filtering.
|
||||||
|
type PackageFilters struct {
|
||||||
|
ComponentID string
|
||||||
|
Arch string
|
||||||
|
Name string
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackages lists packages within a (repo, distribution) with optional
|
||||||
|
// filters and pagination.
|
||||||
|
func (s *Store) ListPackages(ctx context.Context, repoID, distroID string, f PackageFilters, page, perPage int) ([]*models.Package, int, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 25
|
||||||
|
}
|
||||||
|
var where []string
|
||||||
|
var args []any
|
||||||
|
where = append(where, "repository_id = ?", "distribution_id = ?")
|
||||||
|
args = append(args, repoID, distroID)
|
||||||
|
if f.ComponentID != "" {
|
||||||
|
where = append(where, "component_id = ?")
|
||||||
|
args = append(args, f.ComponentID)
|
||||||
|
}
|
||||||
|
if f.Arch != "" {
|
||||||
|
where = append(where, "(architecture = ? OR architecture = 'all')")
|
||||||
|
args = append(args, f.Arch)
|
||||||
|
}
|
||||||
|
if f.Name != "" {
|
||||||
|
where = append(where, "name = ?")
|
||||||
|
args = append(args, f.Name)
|
||||||
|
}
|
||||||
|
if f.Query != "" {
|
||||||
|
where = append(where, "(name LIKE ? OR description LIKE ?)")
|
||||||
|
args = append(args, "%"+f.Query+"%", "%"+f.Query+"%")
|
||||||
|
}
|
||||||
|
q := strings.Join(where, " AND ")
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM packages WHERE `+q, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count packages: %w", err)
|
||||||
|
}
|
||||||
|
args2 := append(args, perPage, (page-1)*perPage)
|
||||||
|
rows, err := s.db.QueryContext(ctx, packageCols+` FROM packages WHERE `+q+` ORDER BY name, version LIMIT ? OFFSET ?`, args2...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list packages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.Package
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPackageRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSuitePackages returns all packages in a (repo, distribution) joined with
|
||||||
|
// their component and distribution names, for APT index generation.
|
||||||
|
func (s *Store) ListSuitePackages(ctx context.Context, repoID, distroID string) ([]SuitePackage, error) {
|
||||||
|
cols := qualifiedPackageCols("p")
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT `+cols+`, c.name, d.name
|
||||||
|
FROM packages p
|
||||||
|
JOIN components c ON c.id = p.component_id
|
||||||
|
JOIN distributions d ON d.id = p.distribution_id
|
||||||
|
WHERE p.repository_id = ? AND p.distribution_id = ?`, repoID, distroID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list suite packages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []SuitePackage
|
||||||
|
for rows.Next() {
|
||||||
|
var sp SuitePackage
|
||||||
|
if err := scanPackageColsFull(rows.Scan, &sp.Package, &sp.ComponentName, &sp.DistributionName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, sp)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanPackageColsFull scans the 37 package columns plus the joined component
|
||||||
|
// name and distribution name.
|
||||||
|
func scanPackageColsFull(scan scanFunc, p *models.Package, compName, distName *string) error {
|
||||||
|
return scan(
|
||||||
|
&p.ID, &p.RepositoryID, &p.DistributionID, &p.ComponentID,
|
||||||
|
&p.Name, &p.Version, &p.Architecture, &p.Source, &p.Maintainer, &p.Priority, &p.Section,
|
||||||
|
&p.Origin, &p.Homepage, &p.Description, &p.DescriptionMD5, &p.Depends, &p.PreDepends,
|
||||||
|
&p.Recommends, &p.Suggests, &p.Conflicts, &p.Breaks, &p.Provides, &p.Replaces, &p.Enhances,
|
||||||
|
&p.InstalledSize, &p.Essential, &p.BuiltUsing, &p.Tag, &p.RawControl, &p.Filename,
|
||||||
|
&p.PoolPath, &p.Size, &p.MD5sum, &p.SHA1, &p.SHA256, &p.UploadedByUserID, &p.CreatedAt,
|
||||||
|
compName, distName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackageBlobSHA256sByRepo returns the sha256 of every package in a repo
|
||||||
|
// (with duplicates), used during repository deletion to decrement ref counts.
|
||||||
|
func (s *Store) ListPackageBlobSHA256sByRepo(ctx context.Context, repoID string) ([]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT sha256 FROM packages WHERE repository_id = ?`, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list repo blobs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var sha string
|
||||||
|
if err := rows.Scan(&sha); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, sha)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePackage removes a package row by id.
|
||||||
|
func (s *Store) DeletePackage(ctx context.Context, id string) error {
|
||||||
|
_, err := s.exec(ctx, `DELETE FROM packages WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePackagesByRepo removes all package rows in a repository.
|
||||||
|
func (s *Store) DeletePackagesByRepo(ctx context.Context, repoID string) error {
|
||||||
|
_, err := s.exec(ctx, `DELETE FROM packages WHERE repository_id = ?`, repoID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageColNames = `id, repository_id, distribution_id, component_id, name, version, architecture, source, maintainer, priority, section, origin, homepage, description, description_md5, depends, pre_depends, recommends, suggests, conflicts, breaks, provides, replaces, enhances, installed_size, essential, built_using, tag, raw_control, filename, pool_path, size, md5sum, sha1, sha256, uploaded_by_user_id, created_at`
|
||||||
|
|
||||||
|
const packageCols = `SELECT ` + packageColNames
|
||||||
|
|
||||||
|
// qualifiedPackageCols returns the package column list prefixed with alias.,
|
||||||
|
// e.g. "p.id, p.repository_id, ...", for use in JOINs.
|
||||||
|
func qualifiedPackageCols(alias string) string {
|
||||||
|
parts := strings.Split(packageColNames, ", ")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = alias + "." + p
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPackage(row *sql.Row) (*models.Package, error) {
|
||||||
|
p := &models.Package{}
|
||||||
|
if err := scanPackageCols(row.Scan, p); err != nil {
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPackageRows(rows *sql.Rows) (*models.Package, error) {
|
||||||
|
p := &models.Package{}
|
||||||
|
if err := scanPackageCols(rows.Scan, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanFunc abstracts *sql.Row.Scan and *sql.Rows.Scan.
|
||||||
|
type scanFunc func(dest ...any) error
|
||||||
|
|
||||||
|
func scanPackageCols(scan scanFunc, p *models.Package) error {
|
||||||
|
return scan(
|
||||||
|
&p.ID, &p.RepositoryID, &p.DistributionID, &p.ComponentID,
|
||||||
|
&p.Name, &p.Version, &p.Architecture, &p.Source, &p.Maintainer, &p.Priority, &p.Section,
|
||||||
|
&p.Origin, &p.Homepage, &p.Description, &p.DescriptionMD5, &p.Depends, &p.PreDepends,
|
||||||
|
&p.Recommends, &p.Suggests, &p.Conflicts, &p.Breaks, &p.Provides, &p.Replaces, &p.Enhances,
|
||||||
|
&p.InstalledSize, &p.Essential, &p.BuiltUsing, &p.Tag, &p.RawControl, &p.Filename,
|
||||||
|
&p.PoolPath, &p.Size, &p.MD5sum, &p.SHA1, &p.SHA256, &p.UploadedByUserID, &p.CreatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newPackage creates a minimal valid Package row ready to insert.
|
||||||
|
func newPackage(repoID, distroID, compID, userID string) *models.Package {
|
||||||
|
return &models.Package{
|
||||||
|
RepositoryID: repoID,
|
||||||
|
DistributionID: distroID,
|
||||||
|
ComponentID: compID,
|
||||||
|
Name: "myapp-hello",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Architecture: "amd64",
|
||||||
|
Maintainer: "Test <test@example.com>",
|
||||||
|
Description: "a test package",
|
||||||
|
RawControl: "Package: myapp-hello\nVersion: 1.0.0\n",
|
||||||
|
Filename: "myapp-hello_1.0.0_amd64.deb",
|
||||||
|
PoolPath: "pool/main/m/myapp-hello/myapp-hello_1.0.0_amd64.deb",
|
||||||
|
Size: 712,
|
||||||
|
MD5sum: "d41d8cd98f00b204e9800998ecf8427e",
|
||||||
|
SHA1: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||||
|
SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
UploadedByUserID: userID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePackageAndGetByID(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
c := s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
_ = c
|
||||||
|
|
||||||
|
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
|
||||||
|
if err := s.CreatePackage(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if p.ID == "" || p.CreatedAt == "" {
|
||||||
|
t.Fatal("id/created_at should be populated by CreatePackage")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetPackageByID(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "myapp-hello" || got.Version != "1.0.0" || got.Architecture != "amd64" {
|
||||||
|
t.Fatalf("got = %+v", got)
|
||||||
|
}
|
||||||
|
if got.SHA256 != p.SHA256 {
|
||||||
|
t.Fatalf("sha256 mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPackageByID_NotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
if _, err := s.GetPackageByID(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPackageByPoolPath(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
c := s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
|
||||||
|
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
|
||||||
|
_ = s.CreatePackage(ctx, p)
|
||||||
|
|
||||||
|
got, err := s.GetPackageByPoolPath(ctx, repo.ID, p.PoolPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by pool path: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != p.ID {
|
||||||
|
t.Fatal("id mismatch")
|
||||||
|
}
|
||||||
|
// Wrong repo.
|
||||||
|
if _, err := s.GetPackageByPoolPath(ctx, "other-repo", p.PoolPath); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound for wrong repo, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPackages_FiltersAndPagination(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
main := s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
contrib := s.createComponent(t, ctx, d.ID, "contrib")
|
||||||
|
|
||||||
|
// Insert 5 packages: 3 in main, 2 in contrib; mix of amd64 and arm64.
|
||||||
|
pkgs := []*models.Package{
|
||||||
|
newPackage(repo.ID, d.ID, main.ID, u.ID),
|
||||||
|
newPackage(repo.ID, d.ID, main.ID, u.ID),
|
||||||
|
newPackage(repo.ID, d.ID, main.ID, u.ID),
|
||||||
|
newPackage(repo.ID, d.ID, contrib.ID, u.ID),
|
||||||
|
newPackage(repo.ID, d.ID, contrib.ID, u.ID),
|
||||||
|
}
|
||||||
|
names := []string{"alpha", "beta", "gamma", "delta", "epsilon"}
|
||||||
|
arches := []string{"amd64", "amd64", "arm64", "amd64", "arm64"}
|
||||||
|
for i, p := range pkgs {
|
||||||
|
p.Name = names[i]
|
||||||
|
p.Architecture = arches[i]
|
||||||
|
p.PoolPath = "pool/main/m/" + names[i] + "/" + names[i] + "_1.0.0_" + arches[i] + ".deb"
|
||||||
|
p.Filename = names[i] + "_1.0.0_" + arches[i] + ".deb"
|
||||||
|
if err := s.CreatePackage(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All.
|
||||||
|
list, total, err := s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list all: %v", err)
|
||||||
|
}
|
||||||
|
if total != 5 || len(list) != 5 {
|
||||||
|
t.Fatalf("expected total=5 len=5, got total=%d len=%d", total, len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by component.
|
||||||
|
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{ComponentID: main.ID}, 1, 100)
|
||||||
|
if total != 3 || len(list) != 3 {
|
||||||
|
t.Fatalf("main filter: expected 3, got total=%d len=%d", total, len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by arch (matches arch OR 'all').
|
||||||
|
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Arch: "amd64"}, 1, 100)
|
||||||
|
if total != 3 { // alpha, beta, delta
|
||||||
|
t.Fatalf("amd64 filter: expected 3, got %d", total)
|
||||||
|
}
|
||||||
|
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Arch: "arm64"}, 1, 100)
|
||||||
|
if total != 2 { // gamma, epsilon
|
||||||
|
t.Fatalf("arm64 filter: expected 2, got %d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by exact name.
|
||||||
|
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Name: "beta"}, 1, 100)
|
||||||
|
if total != 1 || len(list) != 1 || list[0].Name != "beta" {
|
||||||
|
t.Fatalf("name filter: total=%d list=%v", total, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query (LIKE on name/description).
|
||||||
|
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Query: "a test"}, 1, 100)
|
||||||
|
if total != 5 {
|
||||||
|
t.Fatalf("query filter: expected all 5 to match description, got %d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination: page 1, perPage 2 -> 2 items, total 5.
|
||||||
|
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 2)
|
||||||
|
if total != 5 || len(list) != 2 {
|
||||||
|
t.Fatalf("page 1: total=%d len=%d", total, len(list))
|
||||||
|
}
|
||||||
|
// page 3 -> only 1 item.
|
||||||
|
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 3, 2)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("page 3: expected 1 item, got %d", len(list))
|
||||||
|
}
|
||||||
|
_ = list
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePackage(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
c := s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
|
||||||
|
_ = s.CreatePackage(ctx, p)
|
||||||
|
|
||||||
|
if err := s.DeletePackage(ctx, p.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetPackageByID(ctx, p.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePackagesByRepo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
c := s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
_ = s.CreatePackage(ctx, newPackage(repo.ID, d.ID, c.ID, u.ID))
|
||||||
|
_ = s.CreatePackage(ctx, newPackage(repo.ID, d.ID, c.ID, u.ID))
|
||||||
|
|
||||||
|
if err := s.DeletePackagesByRepo(ctx, repo.ID); err != nil {
|
||||||
|
t.Fatalf("delete by repo: %v", err)
|
||||||
|
}
|
||||||
|
list, total, _ := s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 100)
|
||||||
|
if total != 0 || len(list) != 0 {
|
||||||
|
t.Fatalf("expected no packages after DeletePackagesByRepo, got total=%d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSuitePackages(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
c := s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
|
||||||
|
_ = s.CreatePackage(ctx, p)
|
||||||
|
|
||||||
|
suite, err := s.ListSuitePackages(ctx, repo.ID, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list suite: %v", err)
|
||||||
|
}
|
||||||
|
if len(suite) != 1 {
|
||||||
|
t.Fatalf("expected 1 suite package, got %d", len(suite))
|
||||||
|
}
|
||||||
|
if suite[0].ComponentName != "main" || suite[0].DistributionName != "stable" {
|
||||||
|
t.Fatalf("component=%q distro=%q", suite[0].ComponentName, suite[0].DistributionName)
|
||||||
|
}
|
||||||
|
if suite[0].Name != "myapp-hello" {
|
||||||
|
t.Fatalf("name=%q", suite[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPackageBlobSHA256sByRepo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
c := s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
|
||||||
|
p1 := newPackage(repo.ID, d.ID, c.ID, u.ID)
|
||||||
|
p1.Name = "alpha"
|
||||||
|
p1.SHA256 = "aaa"
|
||||||
|
p1.PoolPath = "pool/main/m/alpha/alpha.deb"
|
||||||
|
p1.Filename = "alpha.deb"
|
||||||
|
_ = s.CreatePackage(ctx, p1)
|
||||||
|
p2 := newPackage(repo.ID, d.ID, c.ID, u.ID)
|
||||||
|
p2.Name = "beta"
|
||||||
|
p2.SHA256 = "bbb"
|
||||||
|
p2.PoolPath = "pool/main/m/beta/beta.deb"
|
||||||
|
p2.Filename = "beta.deb"
|
||||||
|
_ = s.CreatePackage(ctx, p2)
|
||||||
|
|
||||||
|
shas, err := s.ListPackageBlobSHA256sByRepo(ctx, repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(shas) != 2 {
|
||||||
|
t.Fatalf("expected 2 shas, got %d", len(shas))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- blobs ---
|
||||||
|
|
||||||
|
func TestCreateBlob_NewAndExisting(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
created, err := s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
|
t.Fatal("first CreateBlob should report created=true")
|
||||||
|
}
|
||||||
|
// Second time, same sha -> not created, no error.
|
||||||
|
created, err = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second create: %v", err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
t.Fatal("second CreateBlob should report created=false (already exists)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBlob(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
|
||||||
|
|
||||||
|
filename, size, refCount, err := s.GetBlob(ctx, "sha-aaa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if filename != "hello.deb" || size != 712 || refCount != 1 {
|
||||||
|
t.Fatalf("got filename=%q size=%d ref=%d", filename, size, refCount)
|
||||||
|
}
|
||||||
|
if _, _, _, err := s.GetBlob(ctx, "missing"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncBlobRef(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
|
||||||
|
|
||||||
|
rc, err := s.IncBlobRef(ctx, "sha-aaa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inc: %v", err)
|
||||||
|
}
|
||||||
|
if rc != 2 {
|
||||||
|
t.Fatalf("expected ref=2 after inc, got %d", rc)
|
||||||
|
}
|
||||||
|
rc, _ = s.IncBlobRef(ctx, "sha-aaa")
|
||||||
|
if rc != 3 {
|
||||||
|
t.Fatalf("expected ref=3, got %d", rc)
|
||||||
|
}
|
||||||
|
// Inc on missing blob.
|
||||||
|
if _, err := s.IncBlobRef(ctx, "missing"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecBlobRef(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
|
||||||
|
_, _ = s.IncBlobRef(ctx, "sha-aaa") // ref=2
|
||||||
|
|
||||||
|
rc, err := s.DecBlobRef(ctx, "sha-aaa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dec: %v", err)
|
||||||
|
}
|
||||||
|
if rc != 1 {
|
||||||
|
t.Fatalf("expected ref=1 after dec, got %d", rc)
|
||||||
|
}
|
||||||
|
rc, _ = s.DecBlobRef(ctx, "sha-aaa")
|
||||||
|
if rc != 0 {
|
||||||
|
t.Fatalf("expected ref=0, got %d", rc)
|
||||||
|
}
|
||||||
|
// Dec below 0 should not go negative; ref_count > 0 guard.
|
||||||
|
rc, _ = s.DecBlobRef(ctx, "sha-aaa")
|
||||||
|
if rc != 0 {
|
||||||
|
t.Fatalf("expected ref to stay at 0, got %d", rc)
|
||||||
|
}
|
||||||
|
// Dec on missing blob.
|
||||||
|
if _, err := s.DecBlobRef(ctx, "totally-missing"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound for missing, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteBlob(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
|
||||||
|
|
||||||
|
if err := s.DeleteBlob(ctx, "sha-aaa"); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := s.GetBlob(ctx, "sha-aaa"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateRepository inserts a new repository owned by userID.
|
||||||
|
func (s *Store) CreateRepository(ctx context.Context, name, ownerUserID string, visibility models.Visibility, description string) (*models.Repository, error) {
|
||||||
|
id := newID()
|
||||||
|
now := s.now()
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO repositories (id, name, owner_user_id, visibility, description, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`, id, name, ownerUserID, string(visibility), description, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert repository: %w", err)
|
||||||
|
}
|
||||||
|
return &models.Repository{
|
||||||
|
ID: id, Name: name, OwnerUserID: ownerUserID, Visibility: visibility,
|
||||||
|
Description: description, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReposVisible returns repositories visible to userID: owned, public, or
|
||||||
|
// where the user is a member.
|
||||||
|
func (s *Store) ListReposVisible(ctx context.Context, userID string) ([]*models.Repository, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT r.id, r.name, r.owner_user_id, r.visibility, r.description, r.created_at, r.updated_at
|
||||||
|
FROM repositories r
|
||||||
|
WHERE r.owner_user_id = ?
|
||||||
|
OR r.visibility = 'public'
|
||||||
|
OR EXISTS (SELECT 1 FROM repository_members m WHERE m.repository_id = r.id AND m.user_id = ?)
|
||||||
|
ORDER BY r.name`, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list repos: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.Repository
|
||||||
|
for rows.Next() {
|
||||||
|
r := &models.Repository{}
|
||||||
|
if err := rows.Scan(&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepository mutates a repository's name, visibility, and description.
|
||||||
|
// Empty strings leave the field unchanged.
|
||||||
|
func (s *Store) UpdateRepository(ctx context.Context, id, name string, visibility *models.Visibility, description *string) error {
|
||||||
|
now := s.now()
|
||||||
|
if name != "" {
|
||||||
|
if _, err := s.exec(ctx, `UPDATE repositories SET name = ?, updated_at = ? WHERE id = ?`, name, now, id); err != nil {
|
||||||
|
return fmt.Errorf("update repo name: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if visibility != nil {
|
||||||
|
if _, err := s.exec(ctx, `UPDATE repositories SET visibility = ?, updated_at = ? WHERE id = ?`, string(*visibility), now, id); err != nil {
|
||||||
|
return fmt.Errorf("update repo visibility: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description != nil {
|
||||||
|
if _, err := s.exec(ctx, `UPDATE repositories SET description = ?, updated_at = ? WHERE id = ?`, *description, now, id); err != nil {
|
||||||
|
return fmt.Errorf("update repo description: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepository deletes a repository and all its child rows (cascade).
|
||||||
|
// The caller must have already handled blob ref-count cleanup.
|
||||||
|
func (s *Store) DeleteRepository(ctx context.Context, id string) error {
|
||||||
|
if _, err := s.exec(ctx, `DELETE FROM repositories WHERE id = ?`, id); err != nil {
|
||||||
|
return fmt.Errorf("delete repository: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepositoryByName returns a repository by its unique name.
|
||||||
|
func (s *Store) GetRepositoryByName(ctx context.Context, name string) (*models.Repository, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, name, owner_user_id, visibility, description, created_at, updated_at
|
||||||
|
FROM repositories WHERE name = ?`, name)
|
||||||
|
return scanRepo(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepositoryByID returns a repository by id.
|
||||||
|
func (s *Store) GetRepositoryByID(ctx context.Context, id string) (*models.Repository, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, name, owner_user_id, visibility, description, created_at, updated_at
|
||||||
|
FROM repositories WHERE id = ?`, id)
|
||||||
|
return scanRepo(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRepo(row *sql.Row) (*models.Repository, error) {
|
||||||
|
r := &models.Repository{}
|
||||||
|
err := row.Scan(&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberAccess returns the access level granted to userID on repoID, or
|
||||||
|
// ("", false) if the user is not an explicit member.
|
||||||
|
func (s *Store) GetMemberAccess(ctx context.Context, repoID, userID string) (models.Access, bool, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT access FROM repository_members WHERE repository_id = ? AND user_id = ?`,
|
||||||
|
repoID, userID)
|
||||||
|
var access string
|
||||||
|
err := row.Scan(&access)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("get member access: %w", err)
|
||||||
|
}
|
||||||
|
return models.Access(access), true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateRepository(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
|
||||||
|
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
|
||||||
|
if r.Name != "myrepo" || r.OwnerUserID != u.ID || r.Visibility != models.VisibilityPublic {
|
||||||
|
t.Fatalf("repo = %+v", r)
|
||||||
|
}
|
||||||
|
if r.ID == "" || r.CreatedAt == "" {
|
||||||
|
t.Fatal("id/created_at should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRepositoryByNameAndID(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
|
||||||
|
|
||||||
|
byName, err := s.GetRepositoryByName(ctx, "myrepo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by name: %v", err)
|
||||||
|
}
|
||||||
|
if byName.ID != r.ID {
|
||||||
|
t.Fatalf("id mismatch")
|
||||||
|
}
|
||||||
|
byID, err := s.GetRepositoryByID(ctx, r.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
}
|
||||||
|
if byID.Name != "myrepo" {
|
||||||
|
t.Fatalf("name = %q", byID.Name)
|
||||||
|
}
|
||||||
|
if _, err := s.GetRepositoryByName(ctx, "missing"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReposVisible(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
carol := s.createUser(t, ctx, "carol", "p")
|
||||||
|
|
||||||
|
// alice owns: pub-own, priv-own
|
||||||
|
pubOwn := s.createRepo(t, ctx, "pub-own", alice.ID, models.VisibilityPublic)
|
||||||
|
privOwn := s.createRepo(t, ctx, "priv-own", alice.ID, models.VisibilityPrivate)
|
||||||
|
// bob owns: pub-bob, priv-bob, and grants carol read on priv-bob
|
||||||
|
pubBob := s.createRepo(t, ctx, "pub-bob", bob.ID, models.VisibilityPublic)
|
||||||
|
privBob := s.createRepo(t, ctx, "priv-bob", bob.ID, models.VisibilityPrivate)
|
||||||
|
_ = pubOwn
|
||||||
|
_ = privOwn
|
||||||
|
_ = pubBob
|
||||||
|
if err := s.AddMember(ctx, privBob.ID, carol.ID, models.AccessRead); err != nil {
|
||||||
|
t.Fatalf("add member: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice sees: her two + bob's public. NOT bob's private.
|
||||||
|
aliceRepos, err := s.ListReposVisible(ctx, alice.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("alice list: %v", err)
|
||||||
|
}
|
||||||
|
if len(aliceRepos) != 3 {
|
||||||
|
t.Fatalf("alice should see 3 repos, got %d", len(aliceRepos))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carol sees: pub-own, pub-bob (both public) + priv-bob (member). NOT priv-own.
|
||||||
|
carolRepos, err := s.ListReposVisible(ctx, carol.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("carol list: %v", err)
|
||||||
|
}
|
||||||
|
if len(carolRepos) != 3 {
|
||||||
|
t.Fatalf("carol should see 3 repos, got %d", len(carolRepos))
|
||||||
|
}
|
||||||
|
// Verify priv-bob is among carol's repos.
|
||||||
|
foundPrivBob := false
|
||||||
|
for _, r := range carolRepos {
|
||||||
|
if r.ID == privBob.ID {
|
||||||
|
foundPrivBob = true
|
||||||
|
}
|
||||||
|
if r.ID == privOwn.ID {
|
||||||
|
t.Fatal("carol should not see alice's private repo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundPrivBob {
|
||||||
|
t.Fatal("carol should see priv-bob as a member")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRepository(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
|
||||||
|
|
||||||
|
// Update name only.
|
||||||
|
if err := s.UpdateRepository(ctx, r.ID, "newname", nil, nil); err != nil {
|
||||||
|
t.Fatalf("update name: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := s.GetRepositoryByID(ctx, r.ID)
|
||||||
|
if got.Name != "newname" || got.Visibility != models.VisibilityPublic {
|
||||||
|
t.Fatalf("name=%q vis=%s", got.Name, got.Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update visibility only.
|
||||||
|
priv := models.VisibilityPrivate
|
||||||
|
_ = s.UpdateRepository(ctx, r.ID, "", &priv, nil)
|
||||||
|
got, _ = s.GetRepositoryByID(ctx, r.ID)
|
||||||
|
if got.Visibility != models.VisibilityPrivate || got.Name != "newname" {
|
||||||
|
t.Fatalf("vis=%s name=%q", got.Visibility, got.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update description only.
|
||||||
|
desc := "a repo"
|
||||||
|
_ = s.UpdateRepository(ctx, r.ID, "", nil, &desc)
|
||||||
|
got, _ = s.GetRepositoryByID(ctx, r.ID)
|
||||||
|
if got.Description != "a repo" {
|
||||||
|
t.Fatalf("desc=%q", got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRepository(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
|
||||||
|
|
||||||
|
if err := s.DeleteRepository(ctx, r.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetRepositoryByID(ctx, r.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- members ---
|
||||||
|
|
||||||
|
func TestAddMember_Upsert(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
|
||||||
|
|
||||||
|
// Initial grant: read.
|
||||||
|
if err := s.AddMember(ctx, repo.ID, bob.ID, models.AccessRead); err != nil {
|
||||||
|
t.Fatalf("add: %v", err)
|
||||||
|
}
|
||||||
|
access, ok, err := s.GetMemberAccess(ctx, repo.ID, bob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || access != models.AccessRead {
|
||||||
|
t.Fatalf("expected read grant, got ok=%v access=%s", ok, access)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert to write.
|
||||||
|
if err := s.AddMember(ctx, repo.ID, bob.ID, models.AccessWrite); err != nil {
|
||||||
|
t.Fatalf("upsert: %v", err)
|
||||||
|
}
|
||||||
|
access, ok, _ = s.GetMemberAccess(ctx, repo.ID, bob.ID)
|
||||||
|
if !ok || access != models.AccessWrite {
|
||||||
|
t.Fatalf("expected write after upsert, got %s", access)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMemberAccess_NonMember(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
|
||||||
|
|
||||||
|
_, ok, err := s.GetMemberAccess(ctx, repo.ID, bob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("non-member should return ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMemberAccess_NotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
|
||||||
|
|
||||||
|
if err := s.UpdateMemberAccess(ctx, repo.ID, bob.ID, models.AccessRead); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("update non-member should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveMember(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
|
||||||
|
|
||||||
|
_ = s.AddMember(ctx, repo.ID, bob.ID, models.AccessRead)
|
||||||
|
if err := s.RemoveMember(ctx, repo.ID, bob.ID); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
_, ok, _ := s.GetMemberAccess(ctx, repo.ID, bob.ID)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("member should be gone after remove")
|
||||||
|
}
|
||||||
|
// Removing again returns ErrNotFound.
|
||||||
|
if err := s.RemoveMember(ctx, repo.ID, bob.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("second remove should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListMembers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
carol := s.createUser(t, ctx, "carol", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
|
||||||
|
|
||||||
|
_ = s.AddMember(ctx, repo.ID, carol.ID, models.AccessRead)
|
||||||
|
_ = s.AddMember(ctx, repo.ID, bob.ID, models.AccessWrite)
|
||||||
|
|
||||||
|
members, err := s.ListMembers(ctx, repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(members) != 2 {
|
||||||
|
t.Fatalf("expected 2 members, got %d", len(members))
|
||||||
|
}
|
||||||
|
// Ordered by username: bob, carol.
|
||||||
|
if members[0].User.Username != "bob" || members[0].Access != models.AccessWrite {
|
||||||
|
t.Fatalf("member[0] = %+v", members[0])
|
||||||
|
}
|
||||||
|
if members[1].User.Username != "carol" || members[1].Access != models.AccessRead {
|
||||||
|
t.Fatalf("member[1] = %+v", members[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Package store provides the data-access layer for urapt-server: typed
|
||||||
|
// methods over the SQLite database backing all domain objects (users, tokens,
|
||||||
|
// repositories, distributions, components, architectures, packages, blobs,
|
||||||
|
// gpg keys, audit log).
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the entrypoint to the data-access layer. All methods are safe for
|
||||||
|
// concurrent use; the underlying *sql.DB is configured with a single writer
|
||||||
|
// connection (see shared/db).
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
now func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Store wrapping db.
|
||||||
|
func New(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db, now: nowISO}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB returns the underlying database (used by app for raw queries if needed).
|
||||||
|
func (s *Store) DB() *sql.DB { return s.db }
|
||||||
|
|
||||||
|
// Now returns the current timestamp in the urapt canonical form.
|
||||||
|
func (s *Store) Now() string { return s.now() }
|
||||||
|
|
||||||
|
// newID returns a fresh UUIDv4 string.
|
||||||
|
func newID() string { return uuid.NewString() }
|
||||||
|
|
||||||
|
// nowISO returns the current UTC time in RFC3339 form.
|
||||||
|
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||||
|
|
||||||
|
// exec is a small helper for ExecContext with a context.
|
||||||
|
func (s *Store) exec(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||||
|
return s.db.ExecContext(ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound is returned by Get-style methods when no row matches.
|
||||||
|
var ErrNotFound = fmt.Errorf("not found")
|
||||||
|
|
||||||
|
// isErrNoRows returns true if err is sql.ErrNoRows.
|
||||||
|
func isErrNoRows(err error) bool { return err == sql.ErrNoRows }
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"urapt/shared/crypto"
|
||||||
|
"urapt/shared/db"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestStore opens a fresh migrated SQLite database in a per-test temp
|
||||||
|
// directory and returns a Store over it. The database is closed automatically
|
||||||
|
// when the test finishes.
|
||||||
|
func newTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
return New(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUser is a test helper that inserts a user with a bcrypt-hashed
|
||||||
|
// password and returns it. The password is hashed for realism so that
|
||||||
|
// password-verification paths can be exercised if needed.
|
||||||
|
func (s *Store) createUser(t *testing.T, ctx context.Context, username, password string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
hash, err := crypto.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
u, admin, err := s.CreateUser(ctx, username, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user %q: %v", username, err)
|
||||||
|
}
|
||||||
|
t.Logf("created user %q (admin=%v)", u.Username, admin)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// createToken is a test helper that mints a real API token (with hash and
|
||||||
|
// prefix) for userID and returns the plaintext token plus the stored row.
|
||||||
|
func (s *Store) createToken(t *testing.T, ctx context.Context, userID, name string) (string, *models.APIToken) {
|
||||||
|
t.Helper()
|
||||||
|
tok, hash, prefix, err := crypto.GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate token: %v", err)
|
||||||
|
}
|
||||||
|
stored, err := s.CreateToken(ctx, userID, name, prefix, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create token: %v", err)
|
||||||
|
}
|
||||||
|
return tok, stored
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRepo is a test helper that creates a repository owned by userID.
|
||||||
|
func (s *Store) createRepo(t *testing.T, ctx context.Context, name, ownerID string, vis models.Visibility) *models.Repository {
|
||||||
|
t.Helper()
|
||||||
|
r, err := s.CreateRepository(ctx, name, ownerID, vis, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create repo %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDistro is a test helper that creates a distribution within repoID.
|
||||||
|
func (s *Store) createDistro(t *testing.T, ctx context.Context, repoID, name string) *models.Distribution {
|
||||||
|
t.Helper()
|
||||||
|
d, err := s.CreateDistribution(ctx, repoID, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create distro %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// createComponent is a test helper that creates a component within distroID.
|
||||||
|
func (s *Store) createComponent(t *testing.T, ctx context.Context, distroID, name string) *models.Component {
|
||||||
|
t.Helper()
|
||||||
|
c, err := s.CreateComponent(ctx, distroID, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create component %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- distributions ---
|
||||||
|
|
||||||
|
// CreateDistribution adds a distribution (suite) to a repository.
|
||||||
|
func (s *Store) CreateDistribution(ctx context.Context, repoID, name string) (*models.Distribution, error) {
|
||||||
|
id := newID()
|
||||||
|
now := s.now()
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO distributions (id, repository_id, name, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
id, repoID, name, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert distribution: %w", err)
|
||||||
|
}
|
||||||
|
return &models.Distribution{ID: id, RepositoryID: repoID, Name: name, CreatedAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDistributionByName returns a distribution by name within a repository.
|
||||||
|
func (s *Store) GetDistributionByName(ctx context.Context, repoID, name string) (*models.Distribution, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, repository_id, name, created_at FROM distributions WHERE repository_id = ? AND name = ?`, repoID, name)
|
||||||
|
d := &models.Distribution{}
|
||||||
|
err := row.Scan(&d.ID, &d.RepositoryID, &d.Name, &d.CreatedAt)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return d, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDistributions returns all distributions in a repository.
|
||||||
|
func (s *Store) ListDistributions(ctx context.Context, repoID string) ([]*models.Distribution, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, repository_id, name, created_at FROM distributions WHERE repository_id = ? ORDER BY name`, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list distributions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.Distribution
|
||||||
|
for rows.Next() {
|
||||||
|
d := &models.Distribution{}
|
||||||
|
if err := rows.Scan(&d.ID, &d.RepositoryID, &d.Name, &d.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDistribution removes a distribution and cascades to components,
|
||||||
|
// architectures, and packages.
|
||||||
|
func (s *Store) DeleteDistribution(ctx context.Context, repoID, name string) error {
|
||||||
|
res, err := s.exec(ctx, `DELETE FROM distributions WHERE repository_id = ? AND name = ?`, repoID, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete distribution: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- components ---
|
||||||
|
|
||||||
|
// CreateComponent adds a component to a distribution.
|
||||||
|
func (s *Store) CreateComponent(ctx context.Context, distroID, name string) (*models.Component, error) {
|
||||||
|
id := newID()
|
||||||
|
now := s.now()
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO components (id, distribution_id, name, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
id, distroID, name, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert component: %w", err)
|
||||||
|
}
|
||||||
|
return &models.Component{ID: id, DistributionID: distroID, Name: name, CreatedAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetComponentByName returns a component by name within a distribution.
|
||||||
|
func (s *Store) GetComponentByName(ctx context.Context, distroID, name string) (*models.Component, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, distribution_id, name, created_at FROM components WHERE distribution_id = ? AND name = ?`, distroID, name)
|
||||||
|
c := &models.Component{}
|
||||||
|
err := row.Scan(&c.ID, &c.DistributionID, &c.Name, &c.CreatedAt)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListComponents returns all components in a distribution.
|
||||||
|
func (s *Store) ListComponents(ctx context.Context, distroID string) ([]*models.Component, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, distribution_id, name, created_at FROM components WHERE distribution_id = ? ORDER BY name`, distroID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list components: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.Component
|
||||||
|
for rows.Next() {
|
||||||
|
c := &models.Component{}
|
||||||
|
if err := rows.Scan(&c.ID, &c.DistributionID, &c.Name, &c.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComponent removes a component. The schema blocks deletion while
|
||||||
|
// packages reference it (ON DELETE RESTRICT); the handler checks first.
|
||||||
|
func (s *Store) DeleteComponent(ctx context.Context, distroID, name string) error {
|
||||||
|
res, err := s.exec(ctx, `DELETE FROM components WHERE distribution_id = ? AND name = ?`, distroID, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete component: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountComponentsByDistro reports how many components a distribution has.
|
||||||
|
func (s *Store) CountComponentsByDistro(ctx context.Context, distroID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM components WHERE distribution_id = ?`, distroID).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- architectures ---
|
||||||
|
|
||||||
|
// CreateArchitecture adds an architecture to a distribution.
|
||||||
|
func (s *Store) CreateArchitecture(ctx context.Context, distroID, name string) (*models.Architecture, error) {
|
||||||
|
id := newID()
|
||||||
|
now := s.now()
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO architectures (id, distribution_id, name, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
id, distroID, name, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert architecture: %w", err)
|
||||||
|
}
|
||||||
|
return &models.Architecture{ID: id, DistributionID: distroID, Name: name, CreatedAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListArchitectures returns all architectures in a distribution.
|
||||||
|
func (s *Store) ListArchitectures(ctx context.Context, distroID string) ([]*models.Architecture, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, distribution_id, name, created_at FROM architectures WHERE distribution_id = ? ORDER BY name`, distroID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list architectures: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.Architecture
|
||||||
|
for rows.Next() {
|
||||||
|
a := &models.Architecture{}
|
||||||
|
if err := rows.Scan(&a.ID, &a.DistributionID, &a.Name, &a.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteArchitecture removes an architecture from a distribution.
|
||||||
|
func (s *Store) DeleteArchitecture(ctx context.Context, distroID, name string) error {
|
||||||
|
res, err := s.exec(ctx, `DELETE FROM architectures WHERE distribution_id = ? AND name = ?`, distroID, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete architecture: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasArchitecture reports whether a distribution declares the given arch.
|
||||||
|
func (s *Store) HasArchitecture(ctx context.Context, distroID, name string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM architectures WHERE distribution_id = ? AND name = ?`, distroID, name).Scan(&n)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- distributions ---
|
||||||
|
|
||||||
|
func TestCreateDistributionAndGetByName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
if d.Name != "stable" || d.RepositoryID != repo.ID {
|
||||||
|
t.Fatalf("distro = %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetDistributionByName(ctx, repo.ID, "stable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != d.ID {
|
||||||
|
t.Fatal("id mismatch")
|
||||||
|
}
|
||||||
|
if _, err := s.GetDistributionByName(ctx, repo.ID, "missing"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDistributions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
s.createDistro(t, ctx, repo.ID, "unstable")
|
||||||
|
s.createDistro(t, ctx, repo.ID, "oldstable")
|
||||||
|
|
||||||
|
ds, err := s.ListDistributions(ctx, repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(ds) != 3 {
|
||||||
|
t.Fatalf("expected 3 distros, got %d", len(ds))
|
||||||
|
}
|
||||||
|
// Ordered by name.
|
||||||
|
if ds[0].Name != "oldstable" || ds[1].Name != "stable" || ds[2].Name != "unstable" {
|
||||||
|
names := []string{ds[0].Name, ds[1].Name, ds[2].Name}
|
||||||
|
t.Fatalf("expected sorted, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteDistribution(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
|
||||||
|
if err := s.DeleteDistribution(ctx, repo.ID, "stable"); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetDistributionByName(ctx, repo.ID, "stable"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteDistribution(ctx, repo.ID, "stable"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("second delete should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- components ---
|
||||||
|
|
||||||
|
func TestCreateComponentAndList(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
|
||||||
|
s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
s.createComponent(t, ctx, d.ID, "contrib")
|
||||||
|
s.createComponent(t, ctx, d.ID, "non-free")
|
||||||
|
|
||||||
|
cs, err := s.ListComponents(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(cs) != 3 {
|
||||||
|
t.Fatalf("expected 3 components, got %d", len(cs))
|
||||||
|
}
|
||||||
|
if cs[0].Name != "contrib" {
|
||||||
|
t.Fatalf("expected sorted; first = %q", cs[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteComponent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
|
||||||
|
if err := s.DeleteComponent(ctx, d.ID, "main"); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteComponent(ctx, d.ID, "main"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("second delete should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountComponentsByDistro(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
|
||||||
|
n, err := s.CountComponentsByDistro(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("expected 0, got %d", n)
|
||||||
|
}
|
||||||
|
s.createComponent(t, ctx, d.ID, "main")
|
||||||
|
s.createComponent(t, ctx, d.ID, "contrib")
|
||||||
|
n, _ = s.CountComponentsByDistro(ctx, d.ID)
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("expected 2, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- architectures ---
|
||||||
|
|
||||||
|
func TestCreateArchitectureAndList(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
|
||||||
|
if _, err := s.CreateArchitecture(ctx, d.ID, "amd64"); err != nil {
|
||||||
|
t.Fatalf("create amd64: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateArchitecture(ctx, d.ID, "arm64"); err != nil {
|
||||||
|
t.Fatalf("create arm64: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
arches, err := s.ListArchitectures(ctx, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(arches) != 2 {
|
||||||
|
t.Fatalf("expected 2 arches, got %d", len(arches))
|
||||||
|
}
|
||||||
|
if arches[0].Name != "amd64" || arches[1].Name != "arm64" {
|
||||||
|
t.Fatalf("expected sorted, got %q %q", arches[0].Name, arches[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteArchitecture(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
_, _ = s.CreateArchitecture(ctx, d.ID, "amd64")
|
||||||
|
|
||||||
|
if err := s.DeleteArchitecture(ctx, d.ID, "amd64"); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteArchitecture(ctx, d.ID, "amd64"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("second delete should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasArchitecture(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||||
|
d := s.createDistro(t, ctx, repo.ID, "stable")
|
||||||
|
_, _ = s.CreateArchitecture(ctx, d.ID, "amd64")
|
||||||
|
|
||||||
|
has, err := s.HasArchitecture(ctx, d.ID, "amd64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("has amd64: %v", err)
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
t.Fatal("expected has=true for amd64")
|
||||||
|
}
|
||||||
|
has, err = s.HasArchitecture(ctx, d.ID, "arm64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("has arm64: %v", err)
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
t.Fatal("expected has=false for arm64")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateToken inserts a new API token row. The plaintext token is NOT stored;
|
||||||
|
// only its SHA-256 hash and display prefix are.
|
||||||
|
func (s *Store) CreateToken(ctx context.Context, userID, name, prefix, tokenHash string) (*models.APIToken, error) {
|
||||||
|
id := newID()
|
||||||
|
now := s.now()
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO api_tokens (id, user_id, name, prefix, token_hash, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`, id, userID, name, prefix, tokenHash, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert token: %w", err)
|
||||||
|
}
|
||||||
|
return &models.APIToken{
|
||||||
|
ID: id, UserID: userID, Name: name, Prefix: prefix, CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenByHash returns the active (non-revoked) token with the given hash.
|
||||||
|
func (s *Store) GetTokenByHash(ctx context.Context, hash string) (*models.APIToken, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, user_id, name, prefix, created_at, last_used_at, revoked_at
|
||||||
|
FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL`, hash)
|
||||||
|
t := &models.APIToken{}
|
||||||
|
var lastUsed, revoked sql.NullString
|
||||||
|
err := row.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &t.CreatedAt, &lastUsed, &revoked)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastUsed.Valid {
|
||||||
|
v := lastUsed.String
|
||||||
|
t.LastUsedAt = &v
|
||||||
|
}
|
||||||
|
if revoked.Valid {
|
||||||
|
v := revoked.String
|
||||||
|
t.RevokedAt = &v
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchToken updates last_used_at for a token.
|
||||||
|
func (s *Store) TouchToken(ctx context.Context, id string) error {
|
||||||
|
_, err := s.exec(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, s.now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens returns all tokens for a user (including revoked).
|
||||||
|
func (s *Store) ListTokens(ctx context.Context, userID string) ([]*models.APIToken, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, user_id, name, prefix, created_at, last_used_at, revoked_at
|
||||||
|
FROM api_tokens WHERE user_id = ? ORDER BY created_at`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tokens: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.APIToken
|
||||||
|
for rows.Next() {
|
||||||
|
t := &models.APIToken{}
|
||||||
|
var lastUsed, revoked sql.NullString
|
||||||
|
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &t.CreatedAt, &lastUsed, &revoked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastUsed.Valid {
|
||||||
|
v := lastUsed.String
|
||||||
|
t.LastUsedAt = &v
|
||||||
|
}
|
||||||
|
if revoked.Valid {
|
||||||
|
v := revoked.String
|
||||||
|
t.RevokedAt = &v
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeToken marks the given token revoked. It must belong to userID.
|
||||||
|
func (s *Store) RevokeToken(ctx context.Context, userID, tokenID string) error {
|
||||||
|
res, err := s.exec(ctx, `UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ?`,
|
||||||
|
s.now(), tokenID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("revoke token: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeTokenByHash marks the token with the given hash revoked (used for logout).
|
||||||
|
func (s *Store) RevokeTokenByHash(ctx context.Context, hash string) error {
|
||||||
|
_, err := s.exec(ctx, `UPDATE api_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
|
||||||
|
s.now(), hash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateUser inserts a new user. If the users table is empty, the user is made
|
||||||
|
// an admin (the bootstrap-admin rule).
|
||||||
|
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (*models.User, bool, error) {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
lc := strings.ToLower(username)
|
||||||
|
id := newID()
|
||||||
|
now := s.now()
|
||||||
|
|
||||||
|
var admin bool
|
||||||
|
var count int
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("count users: %w", err)
|
||||||
|
}
|
||||||
|
admin = count == 0
|
||||||
|
|
||||||
|
_, err := s.exec(ctx, `INSERT INTO users (id, username, username_lc, password_hash, is_admin, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, username, lc, passwordHash, boolToInt(admin), now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("insert user: %w", err)
|
||||||
|
}
|
||||||
|
return &models.User{
|
||||||
|
ID: id, Username: username, IsAdmin: admin, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}, admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID returns the user with the given id.
|
||||||
|
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, username, is_admin, created_at, updated_at FROM users WHERE id = ?`, id)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername returns the user with the given (case-insensitive) username.
|
||||||
|
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, username, is_admin, created_at, updated_at FROM users WHERE username_lc = ?`,
|
||||||
|
strings.ToLower(strings.TrimSpace(username)))
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all users ordered by username.
|
||||||
|
func (s *Store) ListUsers(ctx context.Context) ([]*models.User, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, username, is_admin, created_at, updated_at FROM users ORDER BY username_lc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*models.User
|
||||||
|
for rows.Next() {
|
||||||
|
u, err := scanUserRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserPasswordHash returns the stored bcrypt hash for a user.
|
||||||
|
func (s *Store) GetUserPasswordHash(ctx context.Context, id string) (string, error) {
|
||||||
|
var hash string
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id = ?`, id).Scan(&hash)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return hash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates mutable fields. If isAdmin is nil, it is left unchanged.
|
||||||
|
func (s *Store) UpdateUser(ctx context.Context, id string, isAdmin *bool) error {
|
||||||
|
now := s.now()
|
||||||
|
if isAdmin != nil {
|
||||||
|
if _, err := s.exec(ctx, `UPDATE users SET is_admin = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
boolToInt(*isAdmin), now, id); err != nil {
|
||||||
|
return fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user. The caller should prevent self-deletion.
|
||||||
|
func (s *Store) DeleteUser(ctx context.Context, id string) error {
|
||||||
|
if _, err := s.exec(ctx, `DELETE FROM users WHERE id = ?`, id); err != nil {
|
||||||
|
return fmt.Errorf("delete user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(row *sql.Row) (*models.User, error) {
|
||||||
|
u := &models.User{}
|
||||||
|
err := row.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt)
|
||||||
|
if isErrNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUserRows(rows *sql.Rows) (*models.User, error) {
|
||||||
|
u := &models.User{}
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"urapt/shared/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateUser_BootstrapAdmin(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
// First user becomes admin.
|
||||||
|
u1 := s.createUser(t, ctx, "alice", "pw1")
|
||||||
|
if !u1.IsAdmin {
|
||||||
|
t.Fatalf("first user should be admin, got is_admin=%v", u1.IsAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent users are not admin.
|
||||||
|
u2 := s.createUser(t, ctx, "bob", "pw2")
|
||||||
|
if u2.IsAdmin {
|
||||||
|
t.Fatalf("second user should not be admin")
|
||||||
|
}
|
||||||
|
u3 := s.createUser(t, ctx, "carol", "pw3")
|
||||||
|
if u3.IsAdmin {
|
||||||
|
t.Fatalf("third user should not be admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUser_DuplicateUsernameRejected(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
s.createUser(t, ctx, "alice", "pw1")
|
||||||
|
if _, _, err := s.CreateUser(ctx, "alice", "hash"); err == nil {
|
||||||
|
t.Fatal("expected error creating duplicate username")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserByID(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "pw1")
|
||||||
|
|
||||||
|
got, err := s.GetUserByID(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != u.ID || got.Username != "alice" {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.GetUserByID(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserByUsername_CaseInsensitive(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
s.createUser(t, ctx, "Alice", "pw")
|
||||||
|
|
||||||
|
for _, q := range []string{"alice", "ALICE", "AlIcE"} {
|
||||||
|
got, err := s.GetUserByUsername(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup %q: %v", q, err)
|
||||||
|
}
|
||||||
|
if got.Username != "Alice" {
|
||||||
|
t.Fatalf("expected original casing 'Alice', got %q", got.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := s.GetUserByUsername(ctx, "bob"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound for missing user, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserPasswordHash(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "supersecret")
|
||||||
|
|
||||||
|
hash, err := s.GetUserPasswordHash(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get hash: %v", err)
|
||||||
|
}
|
||||||
|
if !crypto.VerifyPassword(hash, "supersecret") {
|
||||||
|
t.Fatal("bcrypt hash did not verify against original password")
|
||||||
|
}
|
||||||
|
if crypto.VerifyPassword(hash, "wrong") {
|
||||||
|
t.Fatal("bcrypt hash verified against wrong password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListUsers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
s.createUser(t, ctx, "carol", "p")
|
||||||
|
s.createUser(t, ctx, "alice", "p")
|
||||||
|
s.createUser(t, ctx, "bob", "p")
|
||||||
|
|
||||||
|
users, err := s.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 3 {
|
||||||
|
t.Fatalf("expected 3 users, got %d", len(users))
|
||||||
|
}
|
||||||
|
// Ordered by username_lc.
|
||||||
|
if users[0].Username != "alice" || users[1].Username != "bob" || users[2].Username != "carol" {
|
||||||
|
names := []string{users[0].Username, users[1].Username, users[2].Username}
|
||||||
|
t.Fatalf("expected alphabetical order, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateUser(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
// First user is bootstrap admin; create a second non-admin user to test
|
||||||
|
// promotion/demotion on.
|
||||||
|
s.createUser(t, ctx, "admin", "p")
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
if u.IsAdmin {
|
||||||
|
t.Fatal("non-bootstrap user should not be admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := true
|
||||||
|
if err := s.UpdateUser(ctx, u.ID, &admin); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := s.GetUserByID(ctx, u.ID)
|
||||||
|
if !got.IsAdmin {
|
||||||
|
t.Fatal("expected is_admin=true after update")
|
||||||
|
}
|
||||||
|
|
||||||
|
off := false
|
||||||
|
_ = s.UpdateUser(ctx, u.ID, &off)
|
||||||
|
got, _ = s.GetUserByID(ctx, u.ID)
|
||||||
|
if got.IsAdmin {
|
||||||
|
t.Fatal("expected is_admin=false after demotion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil leaves it unchanged.
|
||||||
|
_ = s.UpdateUser(ctx, u.ID, nil)
|
||||||
|
got, _ = s.GetUserByID(ctx, u.ID)
|
||||||
|
if got.IsAdmin {
|
||||||
|
t.Fatal("nil isAdmin should leave it false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUser(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
|
||||||
|
if err := s.DeleteUser(ctx, u.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetUserByID(ctx, u.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tokens ---
|
||||||
|
|
||||||
|
func TestCreateTokenAndGetByHash(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
|
||||||
|
plaintext, stored := s.createToken(t, ctx, u.ID, "laptop")
|
||||||
|
if stored.Name != "laptop" {
|
||||||
|
t.Fatalf("name = %q", stored.Name)
|
||||||
|
}
|
||||||
|
if stored.Prefix == "" {
|
||||||
|
t.Fatal("prefix should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by hash: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != stored.ID {
|
||||||
|
t.Fatalf("id mismatch: %s vs %s", got.ID, stored.ID)
|
||||||
|
}
|
||||||
|
if got.RevokedAt != nil {
|
||||||
|
t.Fatal("fresh token should not be revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTokenByHash_RevokedExcluded(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
plaintext, stored := s.createToken(t, ctx, u.ID, "laptop")
|
||||||
|
|
||||||
|
if err := s.RevokeToken(ctx, u.ID, stored.ID); err != nil {
|
||||||
|
t.Fatalf("revoke: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("revoked token should not be returned, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeToken_OwnershipEnforced(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
_, aliceToken := s.createToken(t, ctx, alice.ID, "alice-laptop")
|
||||||
|
|
||||||
|
// Bob cannot revoke Alice's token.
|
||||||
|
if err := s.RevokeToken(ctx, bob.ID, aliceToken.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("cross-user revoke should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
// Alice can revoke her own.
|
||||||
|
if err := s.RevokeToken(ctx, alice.ID, aliceToken.ID); err != nil {
|
||||||
|
t.Fatalf("own revoke: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeTokenByHash(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
plaintext, _ := s.createToken(t, ctx, u.ID, "laptop")
|
||||||
|
|
||||||
|
if err := s.RevokeTokenByHash(ctx, crypto.HashToken(plaintext)); err != nil {
|
||||||
|
t.Fatalf("revoke by hash: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("revoked token should not be found, got %v", err)
|
||||||
|
}
|
||||||
|
// Revoking again is a no-op (no error, no rows affected).
|
||||||
|
if err := s.RevokeTokenByHash(ctx, crypto.HashToken(plaintext)); err != nil {
|
||||||
|
t.Fatalf("idempotent revoke: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTokens_IncludesRevoked(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
_, t1 := s.createToken(t, ctx, u.ID, "a")
|
||||||
|
_, t2 := s.createToken(t, ctx, u.ID, "b")
|
||||||
|
_ = s.RevokeToken(ctx, u.ID, t1.ID)
|
||||||
|
|
||||||
|
list, err := s.ListTokens(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("expected 2 tokens (incl revoked), got %d", len(list))
|
||||||
|
}
|
||||||
|
for _, tk := range list {
|
||||||
|
if tk.ID == t1.ID && tk.RevokedAt == nil {
|
||||||
|
t.Fatal("revoked token should have RevokedAt set")
|
||||||
|
}
|
||||||
|
if tk.ID == t2.ID && tk.RevokedAt != nil {
|
||||||
|
t.Fatal("active token should not be revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTokens_ScopedToUser(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
alice := s.createUser(t, ctx, "alice", "p")
|
||||||
|
bob := s.createUser(t, ctx, "bob", "p")
|
||||||
|
s.createToken(t, ctx, alice.ID, "alice-token")
|
||||||
|
s.createToken(t, ctx, bob.ID, "bob-token")
|
||||||
|
|
||||||
|
aliceList, _ := s.ListTokens(ctx, alice.ID)
|
||||||
|
if len(aliceList) != 1 || aliceList[0].UserID != alice.ID {
|
||||||
|
t.Fatalf("alice should see only her token, got %d", len(aliceList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchToken(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
u := s.createUser(t, ctx, "alice", "p")
|
||||||
|
plaintext, stored := s.createToken(t, ctx, u.ID, "laptop")
|
||||||
|
|
||||||
|
if stored.LastUsedAt != nil {
|
||||||
|
t.Fatal("fresh token should have nil LastUsedAt")
|
||||||
|
}
|
||||||
|
if err := s.TouchToken(ctx, stored.ID); err != nil {
|
||||||
|
t.Fatalf("touch: %v", err)
|
||||||
|
}
|
||||||
|
// Look up via the hash to confirm last_used_at was written.
|
||||||
|
got, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by hash: %v", err)
|
||||||
|
}
|
||||||
|
if got.LastUsedAt == nil {
|
||||||
|
t.Fatal("expected LastUsedAt set after touch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Package api defines the request and response DTOs that form the urapt REST
|
||||||
|
// API contract. The server's restapi package produces these and the CLI's
|
||||||
|
// apiclient package consumes them; keeping them in one place prevents drift.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "urapt/shared/models"
|
||||||
|
|
||||||
|
// --- server / setup ---
|
||||||
|
|
||||||
|
// ServerInfo is the response from GET /server/info.
|
||||||
|
type ServerInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
NeedsSetup bool `json:"needs_setup"`
|
||||||
|
DefaultKeyFingerprint string `json:"default_key_fingerprint"`
|
||||||
|
OpenRegistration bool `json:"open_registration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- auth ---
|
||||||
|
|
||||||
|
// RegisterRequest is the body for POST /auth/register.
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username" validate:"required,min=3,max=32,username"`
|
||||||
|
Password string `json:"password" validate:"required,min=8,max=256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest is the body for POST /auth/login.
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" validate:"required"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse is returned by register and login.
|
||||||
|
type AuthResponse struct {
|
||||||
|
User *models.User `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenRequest is the body for POST /me/tokens.
|
||||||
|
type CreateTokenRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListResponse wraps a page of items.
|
||||||
|
type ListResponse[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PerPage int `json:"per_page"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- users (admin) ---
|
||||||
|
|
||||||
|
// UpdateUserRequest is the body for PATCH /users/:id.
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
IsAdmin *bool `json:"is_admin,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- repositories ---
|
||||||
|
|
||||||
|
// CreateRepoRequest is the body for POST /repositories.
|
||||||
|
type CreateRepoRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoRequest is the body for PATCH /repositories/:repo.
|
||||||
|
type UpdateRepoRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Visibility *string `json:"visibility,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMemberRequest is the body for POST /repositories/:repo/members.
|
||||||
|
type AddMemberRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Access string `json:"access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMemberRequest is the body for PATCH /repositories/:repo/members/:username.
|
||||||
|
type UpdateMemberRequest struct {
|
||||||
|
Access string `json:"access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- structure ---
|
||||||
|
|
||||||
|
// CreateNamedRequest is the body for creating a distribution/component/arch.
|
||||||
|
type CreateNamedRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package apiclient_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"urapt/server/aptrepo"
|
||||||
|
"urapt/server/auth"
|
||||||
|
"urapt/server/cache"
|
||||||
|
"urapt/server/restapi"
|
||||||
|
"urapt/server/store"
|
||||||
|
"urapt/shared/apiclient"
|
||||||
|
"urapt/shared/config"
|
||||||
|
"urapt/shared/db"
|
||||||
|
"urapt/shared/gpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newServer(t *testing.T) (baseURL string, cleanup func()) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db open: %v", err)
|
||||||
|
}
|
||||||
|
st := store.New(database)
|
||||||
|
authSvc := auth.NewService(st)
|
||||||
|
key, err := gpg.GenerateKey("urapt-test <t>", 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gpg: %v", err)
|
||||||
|
}
|
||||||
|
sp := &signer{key: key}
|
||||||
|
cfg := config.Defaults
|
||||||
|
cfg.StoreDir = dir
|
||||||
|
cfg.PackagesDir = filepath.Join(dir, "packages")
|
||||||
|
cfg.DBPath = filepath.Join(dir, "test.db")
|
||||||
|
_ = os.MkdirAll(cfg.PackagesDir, 0o755)
|
||||||
|
|
||||||
|
idxCache := cache.New()
|
||||||
|
root := chi.NewRouter()
|
||||||
|
root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache))
|
||||||
|
root.Mount("/apt", aptrepo.New(st, authSvc, key, idxCache))
|
||||||
|
srv := newHTTPServer(root)
|
||||||
|
return srv.URL, func() { srv.Close(); database.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
type signer struct{ key *gpg.Key }
|
||||||
|
|
||||||
|
func (s *signer) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() }
|
||||||
|
func (s *signer) Fingerprint() string { return s.key.Fingerprint }
|
||||||
|
|
||||||
|
func TestClientEndToEnd(t *testing.T) {
|
||||||
|
baseURL, cleanup := newServer(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
unauth := apiclient.New(baseURL, "")
|
||||||
|
user, token, err := unauth.Register("alice", "supersecret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
if user.Username != "alice" || token == "" {
|
||||||
|
t.Fatalf("bad register response: %+v", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := apiclient.New(baseURL, token)
|
||||||
|
|
||||||
|
if _, err := c.Me(); err != nil {
|
||||||
|
t.Fatalf("Me: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := c.CreateRepository("myrepo", "public", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRepository: %v", err)
|
||||||
|
}
|
||||||
|
if repo.Name != "myrepo" {
|
||||||
|
t.Fatalf("bad repo: %+v", repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.CreateDistribution("myrepo", "stable"); err != nil {
|
||||||
|
t.Fatalf("CreateDistribution: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c.CreateComponent("myrepo", "stable", "main"); err != nil {
|
||||||
|
t.Fatalf("CreateComponent: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c.CreateArchitecture("myrepo", "stable", "amd64"); err != nil {
|
||||||
|
t.Fatalf("CreateArchitecture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a fixture deb and push it.
|
||||||
|
debBytes := buildDeb("hello", "1.0", "amd64")
|
||||||
|
debPath := filepath.Join(t.TempDir(), "hello_1.0_amd64.deb")
|
||||||
|
if err := os.WriteFile(debPath, debBytes, 0o644); err != nil {
|
||||||
|
t.Fatalf("write deb: %v", err)
|
||||||
|
}
|
||||||
|
pkg, err := c.PushPackage("myrepo", "stable", "main", debPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PushPackage: %v", err)
|
||||||
|
}
|
||||||
|
if pkg.Name != "hello" || pkg.SHA256 == "" {
|
||||||
|
t.Fatalf("bad package: %+v", pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := c.ListPackages("myrepo", "stable", map[string]string{"name": "hello"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPackages: %v", err)
|
||||||
|
}
|
||||||
|
if len(list.Items) != 1 {
|
||||||
|
t.Fatalf("expected 1 package, got %d", len(list.Items))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := c.GetPackage("myrepo", pkg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPackage: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != pkg.ID {
|
||||||
|
t.Fatalf("GetPackage mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and compare bytes.
|
||||||
|
dlPath := filepath.Join(t.TempDir(), "pulled.deb")
|
||||||
|
if err := c.DownloadPackage("myrepo", pkg.ID, dlPath); err != nil {
|
||||||
|
t.Fatalf("DownloadPackage: %v", err)
|
||||||
|
}
|
||||||
|
dl, _ := os.ReadFile(dlPath)
|
||||||
|
if !bytes.Equal(dl, debBytes) {
|
||||||
|
t.Fatalf("downloaded bytes mismatch (%d vs %d)", len(dl), len(debBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// apt-config helpers.
|
||||||
|
if _, err := c.RepositoryPubkey("myrepo"); err != nil {
|
||||||
|
t.Fatalf("RepositoryPubkey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the package.
|
||||||
|
if err := c.DeletePackage("myrepo", pkg.ID); err != nil {
|
||||||
|
t.Fatalf("DeletePackage: %v", err)
|
||||||
|
}
|
||||||
|
list, _ = c.ListPackages("myrepo", "stable", nil)
|
||||||
|
if len(list.Items) != 0 {
|
||||||
|
t.Fatalf("expected 0 packages after delete, got %d", len(list.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Package apiclient is the typed HTTP client used by the urapt CLI to talk to
|
||||||
|
// the urapt-server REST API. It wraps net/http with the shared API DTOs.
|
||||||
|
package apiclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apitypes "urapt/shared/api"
|
||||||
|
"urapt/shared/httputil"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is an authenticated HTTP client for the urapt REST API.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a client. baseURL must not have a trailing slash.
|
||||||
|
func New(baseURL, token string) *Client {
|
||||||
|
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: http.DefaultClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError is an error returned by the server (the error envelope).
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("%s (HTTP %d)", e.Message, e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAPIError reports whether err is an *APIError and returns it.
|
||||||
|
func IsAPIError(err error) (*APIError, bool) {
|
||||||
|
if e, ok := err.(*APIError); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs a JSON request and unmarshals the response into out (if non-nil
|
||||||
|
// and status is 2xx). On non-2xx it returns an *APIError.
|
||||||
|
func (c *Client) do(method, path string, body any, out any) error {
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
r = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, c.BaseURL+path, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
var env struct {
|
||||||
|
Error httputil.APIError `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &env)
|
||||||
|
return &APIError{Status: resp.StatusCode, Code: env.Error.Code, Message: env.Error.Message}
|
||||||
|
}
|
||||||
|
if out != nil && len(data) > 0 {
|
||||||
|
if err := json.Unmarshal(data, out); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJSON, postJSON, patchJSON, deleteJSON are convenience wrappers.
|
||||||
|
func (c *Client) getJSON(path string, out any) error { return c.do("GET", path, nil, out) }
|
||||||
|
func (c *Client) postJSON(path string, body, out any) error {
|
||||||
|
return c.do("POST", path, body, out)
|
||||||
|
}
|
||||||
|
func (c *Client) patchJSON(path string, body, out any) error {
|
||||||
|
return c.do("PATCH", path, body, out)
|
||||||
|
}
|
||||||
|
func (c *Client) deleteJSON(path string) error { return c.do("DELETE", path, nil, nil) }
|
||||||
|
|
||||||
|
// --- server ---
|
||||||
|
|
||||||
|
// ServerInfo fetches /server/info.
|
||||||
|
func (c *Client) ServerInfo() (*apitypes.ServerInfo, error) {
|
||||||
|
var info apitypes.ServerInfo
|
||||||
|
if err := c.getJSON("/api/v1/server/info", &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerPubkey fetches the armored default public key.
|
||||||
|
func (c *Client) ServerPubkey() (string, error) {
|
||||||
|
req, _ := http.NewRequest("GET", c.BaseURL+"/api/v1/server/pubkey", nil)
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", parseErrorBody(resp.StatusCode, data)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseErrorBody builds an *APIError from a non-2xx response body, decoding the
|
||||||
|
// {"error": {...}} envelope when present.
|
||||||
|
func parseErrorBody(status int, data []byte) error {
|
||||||
|
var env struct {
|
||||||
|
Error httputil.APIError `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &env); err == nil && env.Error.Message != "" {
|
||||||
|
return &APIError{Status: status, Code: env.Error.Code, Message: env.Error.Message}
|
||||||
|
}
|
||||||
|
return &APIError{Status: status, Message: strings.TrimSpace(string(data))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- auth ---
|
||||||
|
|
||||||
|
// Register creates an account and returns the user + token.
|
||||||
|
func (c *Client) Register(username, password string) (*models.User, string, error) {
|
||||||
|
var resp apitypes.AuthResponse
|
||||||
|
if err := c.postJSON("/api/v1/auth/register", apitypes.RegisterRequest{Username: username, Password: password}, &resp); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return resp.User, resp.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates and returns the user + token.
|
||||||
|
func (c *Client) Login(username, password string) (*models.User, string, error) {
|
||||||
|
var resp apitypes.AuthResponse
|
||||||
|
if err := c.postJSON("/api/v1/auth/login", apitypes.LoginRequest{Username: username, Password: password}, &resp); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return resp.User, resp.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes the current token.
|
||||||
|
func (c *Client) Logout() error { return c.postJSON("/api/v1/auth/logout", nil, nil) }
|
||||||
|
|
||||||
|
// Me returns the current user.
|
||||||
|
func (c *Client) Me() (*models.User, error) {
|
||||||
|
var u models.User
|
||||||
|
if err := c.getJSON("/api/v1/me", &u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens returns the caller's tokens.
|
||||||
|
func (c *Client) ListTokens() ([]*models.APIToken, error) {
|
||||||
|
var resp apitypes.ListResponse[*models.APIToken]
|
||||||
|
if err := c.getJSON("/api/v1/me/tokens", &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken issues a new named token.
|
||||||
|
func (c *Client) CreateToken(name string) (*models.APIToken, error) {
|
||||||
|
var t models.APIToken
|
||||||
|
if err := c.postJSON("/api/v1/me/tokens", apitypes.CreateTokenRequest{Name: name}, &t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeToken revokes a token by id.
|
||||||
|
func (c *Client) RevokeToken(id string) error { return c.deleteJSON("/api/v1/me/tokens/" + id) }
|
||||||
|
|
||||||
|
// addQuery attaches query parameters to path.
|
||||||
|
func addQuery(path string, params map[string]string) string {
|
||||||
|
if len(params) == 0 {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
v := url.Values{}
|
||||||
|
for k, val := range params {
|
||||||
|
if val != "" {
|
||||||
|
v.Set(k, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := v.Encode()
|
||||||
|
if q == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return path + "?" + q
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep multipart referenced (used by PushPackage in Phase 9).
|
||||||
|
var _ = multipart.NewWriter
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package apiclient_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newHTTPServer(h http.Handler) *httptest.Server {
|
||||||
|
return httptest.NewServer(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDeb constructs a minimal valid .deb with the given package/version/arch.
|
||||||
|
func buildDeb(name, version, arch string) []byte {
|
||||||
|
control := "Package: " + name + "\nVersion: " + version + "\nArchitecture: " + arch + "\nMaintainer: t <t@e>\nDescription: short\n extended\n"
|
||||||
|
var ctrlBuf bytes.Buffer
|
||||||
|
gz, _ := gzip.NewWriterLevel(&ctrlBuf, 9)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
writeTw(tw, "control", control)
|
||||||
|
tw.Close()
|
||||||
|
gz.Close()
|
||||||
|
|
||||||
|
var dataBuf bytes.Buffer
|
||||||
|
gz2, _ := gzip.NewWriterLevel(&dataBuf, 9)
|
||||||
|
tw2 := tar.NewWriter(gz2)
|
||||||
|
writeTw(tw2, "usr/share/"+name, "x")
|
||||||
|
tw2.Close()
|
||||||
|
gz2.Close()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("!<arch>\n")
|
||||||
|
writeAr(&out, "debian-binary", []byte("2.0\n"))
|
||||||
|
writeAr(&out, "control.tar.gz", ctrlBuf.Bytes())
|
||||||
|
writeAr(&out, "data.tar.gz", dataBuf.Bytes())
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTw(tw *tar.Writer, name, body string) {
|
||||||
|
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
||||||
|
_, _ = tw.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAr(buf *bytes.Buffer, name string, data []byte) {
|
||||||
|
header := make([]byte, 60)
|
||||||
|
for i := range header {
|
||||||
|
header[i] = ' '
|
||||||
|
}
|
||||||
|
copy(header[0:], name+"/")
|
||||||
|
copy(header[48:], []byte(padNum(len(data), 10)))
|
||||||
|
header[58] = '`'
|
||||||
|
header[59] = '\n'
|
||||||
|
buf.Write(header)
|
||||||
|
buf.Write(data)
|
||||||
|
if len(data)%2 == 1 {
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func padNum(n, width int) string {
|
||||||
|
s := make([]byte, width)
|
||||||
|
for i := range s {
|
||||||
|
s[i] = ' '
|
||||||
|
}
|
||||||
|
digits := []byte(itoa(n))
|
||||||
|
copy(s[len(s)-len(digits):], digits)
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{byte('0' + n%10)}, b...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package apiclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageListResponse is the shape returned by the packages list endpoint.
|
||||||
|
type PackageListResponse struct {
|
||||||
|
Items []*models.Package `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PerPage int `json:"per_page"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackages lists packages in a (repo, distribution) with optional filters.
|
||||||
|
func (c *Client) ListPackages(repo, dist string, filters map[string]string) (*PackageListResponse, error) {
|
||||||
|
var resp PackageListResponse
|
||||||
|
path := addQuery("/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", filters)
|
||||||
|
if err := c.getJSON(path, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackage returns a single package by id.
|
||||||
|
func (c *Client) GetPackage(repo, id string) (*models.Package, error) {
|
||||||
|
var p models.Package
|
||||||
|
if err := c.getJSON("/api/v1/repositories/"+repo+"/packages/"+id, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushPackage uploads a .deb file to a repository/distribution/component. The
|
||||||
|
// upload is streamed via multipart/form-data.
|
||||||
|
func (c *Client) PushPackage(repo, dist, component, filePath string) (*models.Package, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
writer := multipart.NewWriter(pw)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer pw.Close()
|
||||||
|
_ = writer.WriteField("component", component)
|
||||||
|
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||||
|
if err != nil {
|
||||||
|
pw.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, f); err != nil {
|
||||||
|
pw.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = writer.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.BaseURL+"/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", pr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, parseErrorBody(resp.StatusCode, data)
|
||||||
|
}
|
||||||
|
var p models.Package
|
||||||
|
if err := json.Unmarshal(data, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadPackage fetches a package's .deb file and writes it to outFile. If
|
||||||
|
// outFile is empty, the bytes are written to stdout.
|
||||||
|
func (c *Client) DownloadPackage(repo, id, outFile string) error {
|
||||||
|
req, err := http.NewRequest("GET", c.BaseURL+"/api/v1/repositories/"+repo+"/packages/"+id+"/file", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
return parseErrorBody(resp.StatusCode, data)
|
||||||
|
}
|
||||||
|
var w io.Writer
|
||||||
|
if outFile == "" || outFile == "-" {
|
||||||
|
w = os.Stdout
|
||||||
|
} else {
|
||||||
|
f, err := os.Create(outFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w = f
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePackage removes a package by id.
|
||||||
|
func (c *Client) DeletePackage(repo, id string) error {
|
||||||
|
return c.deleteJSON("/api/v1/repositories/" + repo + "/packages/" + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePackageSpec splits a "name[@version][:arch]" specifier into its parts.
|
||||||
|
// A string that looks like a UUID is treated as an id.
|
||||||
|
func ParsePackageSpec(spec string) (id, name, version, arch string) {
|
||||||
|
spec = strings.TrimSpace(spec)
|
||||||
|
if isUUID(spec) {
|
||||||
|
return spec, "", "", ""
|
||||||
|
}
|
||||||
|
// split :arch
|
||||||
|
if i := strings.IndexByte(spec, ':'); i >= 0 {
|
||||||
|
arch = spec[i+1:]
|
||||||
|
spec = spec[:i]
|
||||||
|
}
|
||||||
|
// split @version
|
||||||
|
if i := strings.IndexByte(spec, '@'); i >= 0 {
|
||||||
|
version = spec[i+1:]
|
||||||
|
spec = spec[:i]
|
||||||
|
}
|
||||||
|
name = spec
|
||||||
|
return "", name, version, arch
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUUID reports whether s looks like a UUIDv4.
|
||||||
|
func isUUID(s string) bool {
|
||||||
|
if len(s) != 36 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-'
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package apiclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
apitypes "urapt/shared/api"
|
||||||
|
"urapt/shared/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newGetReq builds a GET request to url.
|
||||||
|
func newGetReq(url string) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBody fully reads a response body.
|
||||||
|
func readBody(resp *http.Response) ([]byte, error) {
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- repositories ---
|
||||||
|
|
||||||
|
// ListRepositories returns repositories visible to the caller.
|
||||||
|
func (c *Client) ListRepositories() ([]*models.Repository, error) {
|
||||||
|
var resp apitypes.ListResponse[*models.Repository]
|
||||||
|
if err := c.getJSON("/api/v1/repositories", &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepository creates a new repository.
|
||||||
|
func (c *Client) CreateRepository(name, visibility, description string) (*models.Repository, error) {
|
||||||
|
var repo models.Repository
|
||||||
|
if err := c.postJSON("/api/v1/repositories", apitypes.CreateRepoRequest{
|
||||||
|
Name: name, Visibility: visibility, Description: description,
|
||||||
|
}, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepository returns a single repository by name.
|
||||||
|
func (c *Client) GetRepository(name string) (*models.Repository, error) {
|
||||||
|
var repo models.Repository
|
||||||
|
if err := c.getJSON("/api/v1/repositories/"+name, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepository mutates a repository. nil arguments leave fields unchanged.
|
||||||
|
func (c *Client) UpdateRepository(name string, newName *string, visibility *string, description *string) (*models.Repository, error) {
|
||||||
|
var repo models.Repository
|
||||||
|
if err := c.patchJSON("/api/v1/repositories/"+name, apitypes.UpdateRepoRequest{
|
||||||
|
Name: newName, Visibility: visibility, Description: description,
|
||||||
|
}, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepository removes a repository.
|
||||||
|
func (c *Client) DeleteRepository(name string) error {
|
||||||
|
return c.deleteJSON("/api/v1/repositories/" + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepositoryPubkey returns the server's armored public key (repo-scoped path).
|
||||||
|
func (c *Client) RepositoryPubkey(name string) (string, error) {
|
||||||
|
req, err := newGetReq(c.BaseURL + "/api/v1/repositories/" + name + "/pubkey")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := readBody(resp)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", parseErrorBody(resp.StatusCode, data)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- members ---
|
||||||
|
|
||||||
|
// ListMembers returns the members of a repository.
|
||||||
|
func (c *Client) ListMembers(repo string) ([]*models.RepositoryMember, error) {
|
||||||
|
var out []*models.RepositoryMember
|
||||||
|
if err := c.getJSON("/api/v1/repositories/"+repo+"/members", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember grants a user access on a repository.
|
||||||
|
func (c *Client) AddMember(repo, username, access string) (*models.RepositoryMember, error) {
|
||||||
|
var m models.RepositoryMember
|
||||||
|
if err := c.postJSON("/api/v1/repositories/"+repo+"/members",
|
||||||
|
apitypes.AddMemberRequest{Username: username, Access: access}, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMember changes a member's access.
|
||||||
|
func (c *Client) UpdateMember(repo, username, access string) (*models.RepositoryMember, error) {
|
||||||
|
var m models.RepositoryMember
|
||||||
|
if err := c.patchJSON("/api/v1/repositories/"+repo+"/members/"+username,
|
||||||
|
apitypes.UpdateMemberRequest{Access: access}, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember revokes a user's access.
|
||||||
|
func (c *Client) RemoveMember(repo, username string) error {
|
||||||
|
return c.deleteJSON("/api/v1/repositories/" + repo + "/members/" + username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- distributions ---
|
||||||
|
|
||||||
|
// ListDistributions returns the distributions in a repository.
|
||||||
|
func (c *Client) ListDistributions(repo string) ([]*models.Distribution, error) {
|
||||||
|
var out []*models.Distribution
|
||||||
|
if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDistribution adds a distribution.
|
||||||
|
func (c *Client) CreateDistribution(repo, name string) (*models.Distribution, error) {
|
||||||
|
var d models.Distribution
|
||||||
|
if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions",
|
||||||
|
apitypes.CreateNamedRequest{Name: name}, &d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDistribution removes a distribution.
|
||||||
|
func (c *Client) DeleteDistribution(repo, name string) error {
|
||||||
|
return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- components ---
|
||||||
|
|
||||||
|
// ListComponents returns the components in a distribution.
|
||||||
|
func (c *Client) ListComponents(repo, dist string) ([]*models.Component, error) {
|
||||||
|
var out []*models.Component
|
||||||
|
if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/components", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateComponent adds a component.
|
||||||
|
func (c *Client) CreateComponent(repo, dist, name string) (*models.Component, error) {
|
||||||
|
var comp models.Component
|
||||||
|
if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/components",
|
||||||
|
apitypes.CreateNamedRequest{Name: name}, &comp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &comp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComponent removes a component.
|
||||||
|
func (c *Client) DeleteComponent(repo, dist, name string) error {
|
||||||
|
return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + dist + "/components/" + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- architectures ---
|
||||||
|
|
||||||
|
// ListArchitectures returns the architectures in a distribution.
|
||||||
|
func (c *Client) ListArchitectures(repo, dist string) ([]*models.Architecture, error) {
|
||||||
|
var out []*models.Architecture
|
||||||
|
if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/architectures", &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArchitecture adds an architecture.
|
||||||
|
func (c *Client) CreateArchitecture(repo, dist, name string) (*models.Architecture, error) {
|
||||||
|
var a models.Architecture
|
||||||
|
if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/architectures",
|
||||||
|
apitypes.CreateNamedRequest{Name: name}, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteArchitecture removes an architecture.
|
||||||
|
func (c *Client) DeleteArchitecture(repo, dist, name string) error {
|
||||||
|
return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + dist + "/architectures/" + name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// Package apt generates APT repository indices (Packages, Release, InRelease,
|
||||||
|
// Release.gpg) entirely from in-memory package data. Indices are never written
|
||||||
|
// to disk by this package; the caller caches and serves them.
|
||||||
|
package apt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ulikunitz/xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signer is implemented by anything able to clearsign and detached-sign the
|
||||||
|
// Release file (e.g. *gpg.Key).
|
||||||
|
type Signer interface {
|
||||||
|
ClearSign(data []byte) ([]byte, error)
|
||||||
|
DetachedSign(data []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageRow is a single package's data needed to emit its index entry.
|
||||||
|
type PackageRow struct {
|
||||||
|
Component string
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Architecture string
|
||||||
|
PoolPath string
|
||||||
|
Size int64
|
||||||
|
MD5sum string
|
||||||
|
SHA1 string
|
||||||
|
SHA256 string
|
||||||
|
DescriptionMD5 string
|
||||||
|
RawControl string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suite describes a (repository, distribution) for which to generate indices.
|
||||||
|
type Suite struct {
|
||||||
|
Origin string
|
||||||
|
Label string
|
||||||
|
Suite string
|
||||||
|
Codename string
|
||||||
|
Description string
|
||||||
|
Components []string
|
||||||
|
Architectures []string
|
||||||
|
Packages []PackageRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indices holds all generated index artifacts for a suite, keyed by their path
|
||||||
|
// relative to the suite directory.
|
||||||
|
type Indices struct {
|
||||||
|
Packages map[string][]byte
|
||||||
|
PackagesGz map[string][]byte
|
||||||
|
PackagesXz map[string][]byte
|
||||||
|
Release []byte
|
||||||
|
InRelease []byte
|
||||||
|
ReleaseGpg []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate builds all indices for the suite and signs the Release file using
|
||||||
|
// signer. If signer is nil, InRelease/ReleaseGpg are left empty.
|
||||||
|
func Generate(s *Suite, signer Signer) (*Indices, error) {
|
||||||
|
components := dedupSorted(s.Components)
|
||||||
|
arches := dedupSorted(s.Architectures)
|
||||||
|
|
||||||
|
idx := &Indices{
|
||||||
|
Packages: map[string][]byte{},
|
||||||
|
PackagesGz: map[string][]byte{},
|
||||||
|
PackagesXz: map[string][]byte{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group packages by (component, arch), including arch="all" in every arch.
|
||||||
|
type key struct{ comp, arch string }
|
||||||
|
groups := map[key][]PackageRow{}
|
||||||
|
for _, p := range s.Packages {
|
||||||
|
for _, arch := range arches {
|
||||||
|
if p.Architecture == arch || p.Architecture == "all" {
|
||||||
|
k := key{p.Component, arch}
|
||||||
|
groups[k] = append(groups[k], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, comp := range components {
|
||||||
|
for _, arch := range arches {
|
||||||
|
rows := groups[key{comp, arch}]
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Name != rows[j].Name {
|
||||||
|
return rows[i].Name < rows[j].Name
|
||||||
|
}
|
||||||
|
return rows[i].Version < rows[j].Version
|
||||||
|
})
|
||||||
|
pkgBytes := generatePackagesIndex(rows)
|
||||||
|
relPath := fmt.Sprintf("%s/binary-%s/Packages", comp, arch)
|
||||||
|
idx.Packages[relPath] = pkgBytes
|
||||||
|
idx.PackagesGz[relPath+".gz"] = gzipBytes(pkgBytes)
|
||||||
|
xzBytes, err := xzBytes(pkgBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("xz compress %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
idx.PackagesXz[relPath+".xz"] = xzBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := generateRelease(s, components, arches, idx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idx.Release = release
|
||||||
|
|
||||||
|
if signer != nil {
|
||||||
|
clear, err := signer.ClearSign(release)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clearsign: %w", err)
|
||||||
|
}
|
||||||
|
idx.InRelease = clear
|
||||||
|
det, err := signer.DetachedSign(release)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("detach sign: %w", err)
|
||||||
|
}
|
||||||
|
idx.ReleaseGpg = det
|
||||||
|
}
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePackagesIndex emits the Packages file body for one (component, arch).
|
||||||
|
// The result is always non-nil (an empty byte slice if there are no rows).
|
||||||
|
func generatePackagesIndex(rows []PackageRow) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, r := range rows {
|
||||||
|
stanza := strings.TrimRight(r.RawControl, "\n")
|
||||||
|
buf.WriteString(stanza)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
writeField(&buf, "Filename", r.PoolPath)
|
||||||
|
writeFieldInt(&buf, "Size", r.Size)
|
||||||
|
writeField(&buf, "MD5sum", r.MD5sum)
|
||||||
|
writeField(&buf, "SHA1", r.SHA1)
|
||||||
|
writeField(&buf, "SHA256", r.SHA256)
|
||||||
|
if r.DescriptionMD5 != "" {
|
||||||
|
writeField(&buf, "Description-md5", r.DescriptionMD5)
|
||||||
|
}
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
out := buf.Bytes()
|
||||||
|
if out == nil {
|
||||||
|
out = []byte{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileEntry is one index file's path and bytes, used for Release checksums.
|
||||||
|
type fileEntry struct {
|
||||||
|
path string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e fileEntry) md5() string { sum := md5.Sum(e.data); return hex.EncodeToString(sum[:]) }
|
||||||
|
func (e fileEntry) sha1() string { sum := sha1.Sum(e.data); return hex.EncodeToString(sum[:]) }
|
||||||
|
func (e fileEntry) sha256() string {
|
||||||
|
sum := sha256.Sum256(e.data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRelease(s *Suite, components, arches []string, idx *Indices) ([]byte, error) {
|
||||||
|
var entries []fileEntry
|
||||||
|
for path, data := range idx.Packages {
|
||||||
|
entries = append(entries, fileEntry{path: path, data: data})
|
||||||
|
}
|
||||||
|
for path, data := range idx.PackagesGz {
|
||||||
|
entries = append(entries, fileEntry{path: path, data: data})
|
||||||
|
}
|
||||||
|
for path, data := range idx.PackagesXz {
|
||||||
|
entries = append(entries, fileEntry{path: path, data: data})
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path })
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if s.Origin != "" {
|
||||||
|
writeField(&buf, "Origin", s.Origin)
|
||||||
|
}
|
||||||
|
if s.Label != "" {
|
||||||
|
writeField(&buf, "Label", s.Label)
|
||||||
|
}
|
||||||
|
writeField(&buf, "Suite", s.Suite)
|
||||||
|
codename := s.Codename
|
||||||
|
if codename == "" {
|
||||||
|
codename = s.Suite
|
||||||
|
}
|
||||||
|
writeField(&buf, "Codename", codename)
|
||||||
|
writeField(&buf, "Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 MST"))
|
||||||
|
if s.Description != "" {
|
||||||
|
writeField(&buf, "Description", s.Description)
|
||||||
|
}
|
||||||
|
if len(arches) > 0 {
|
||||||
|
writeField(&buf, "Architectures", strings.Join(arches, " "))
|
||||||
|
}
|
||||||
|
if len(components) > 0 {
|
||||||
|
writeField(&buf, "Components", strings.Join(components, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeChecksumBlock(&buf, "MD5Sum", entries, func(e fileEntry) string { return e.md5() })
|
||||||
|
writeChecksumBlock(&buf, "SHA1", entries, func(e fileEntry) string { return e.sha1() })
|
||||||
|
writeChecksumBlock(&buf, "SHA256", entries, func(e fileEntry) string { return e.sha256() })
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeChecksumBlock(buf *bytes.Buffer, name string, entries []fileEntry, hashFn func(fileEntry) string) {
|
||||||
|
buf.WriteString(name + ":\n")
|
||||||
|
for _, e := range entries {
|
||||||
|
fmt.Fprintf(buf, " %s %16d %s\n", hashFn(e), len(e.data), e.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeField(buf *bytes.Buffer, key, val string) {
|
||||||
|
if val == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(buf, "%s: %s\n", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFieldInt(buf *bytes.Buffer, key string, val int64) {
|
||||||
|
fmt.Fprintf(buf, "%s: %d\n", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipBytes(data []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
_, _ = gz.Write(data)
|
||||||
|
_ = gz.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xzBytes(data []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
xw, err := xz.NewWriter(&buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := xw.Write(data); err != nil {
|
||||||
|
_ = xw.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := xw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupSorted(in []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, v := range in {
|
||||||
|
if v == "" || seen[v] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package apt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"urapt/shared/gpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateIndices(t *testing.T) {
|
||||||
|
key, err := gpg.GenerateKey("urapt-test <test.example.com>", 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
suite := &Suite{
|
||||||
|
Origin: "urapt myrepo",
|
||||||
|
Label: "urapt myrepo",
|
||||||
|
Suite: "stable",
|
||||||
|
Description: "my repo",
|
||||||
|
Components: []string{"main", "contrib"},
|
||||||
|
Architectures: []string{"amd64", "arm64"},
|
||||||
|
Packages: []PackageRow{
|
||||||
|
{
|
||||||
|
Component: "main", Name: "foo", Version: "1.0", Architecture: "amd64",
|
||||||
|
PoolPath: "pool/main/f/foo/foo_1.0_amd64.deb", Size: 1234,
|
||||||
|
MD5sum: "aa", SHA1: "bb", SHA256: "cc", DescriptionMD5: "dd",
|
||||||
|
RawControl: "Package: foo\nVersion: 1.0\nArchitecture: amd64\nDescription: short\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Component: "main", Name: "bar", Version: "2.0", Architecture: "all",
|
||||||
|
PoolPath: "pool/main/b/bar/bar_2.0_all.deb", Size: 5678,
|
||||||
|
MD5sum: "ee", SHA1: "ff", SHA256: "11", DescriptionMD5: "22",
|
||||||
|
RawControl: "Package: bar\nVersion: 2.0\nArchitecture: all\nDescription: bar short\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, err := Generate(suite, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// amd64 index should contain both foo (amd64) and bar (all).
|
||||||
|
pkg := idx.Packages["main/binary-amd64/Packages"]
|
||||||
|
if pkg == nil {
|
||||||
|
t.Fatal("missing amd64 Packages")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(pkg, []byte("Package: foo")) || !bytes.Contains(pkg, []byte("Package: bar")) {
|
||||||
|
t.Fatalf("amd64 index missing entries:\n%s", pkg)
|
||||||
|
}
|
||||||
|
// bar should appear in arm64 too (arch=all).
|
||||||
|
arm := idx.Packages["main/binary-arm64/Packages"]
|
||||||
|
if !bytes.Contains(arm, []byte("Package: bar")) {
|
||||||
|
t.Fatalf("arm64 index missing all-arch bar:\n%s", arm)
|
||||||
|
}
|
||||||
|
if bytes.Contains(arm, []byte("Package: foo")) {
|
||||||
|
t.Fatalf("arm64 index should not contain amd64 foo:\n%s", arm)
|
||||||
|
}
|
||||||
|
// contrib indices should be empty bodies but present.
|
||||||
|
if idx.Packages["contrib/binary-amd64/Packages"] == nil {
|
||||||
|
t.Fatal("missing contrib amd64 index")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file fields appended.
|
||||||
|
if !bytes.Contains(pkg, []byte("Filename: pool/main/f/foo/foo_1.0_amd64.deb")) {
|
||||||
|
t.Fatalf("Packages missing Filename:\n%s", pkg)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(pkg, []byte("SHA256: cc")) {
|
||||||
|
t.Fatalf("Packages missing SHA256:\n%s", pkg)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(pkg, []byte("Description-md5: dd")) {
|
||||||
|
t.Fatalf("Packages missing Description-md5:\n%s", pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release file should list components, architectures, and checksums.
|
||||||
|
rel := idx.Release
|
||||||
|
if !bytes.Contains(rel, []byte("Suite: stable")) {
|
||||||
|
t.Fatalf("Release missing Suite:\n%s", rel)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rel, []byte("Components: contrib main")) {
|
||||||
|
t.Fatalf("Release missing Components:\n%s", rel)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rel, []byte("Architectures: amd64 arm64")) {
|
||||||
|
t.Fatalf("Release missing Architectures:\n%s", rel)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rel, []byte("SHA256:")) {
|
||||||
|
t.Fatalf("Release missing SHA256 block:\n%s", rel)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rel, []byte("main/binary-amd64/Packages")) {
|
||||||
|
t.Fatalf("Release missing index path:\n%s", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InRelease must be a clearsigned block.
|
||||||
|
if !bytes.Contains(idx.InRelease, []byte("BEGIN PGP SIGNED MESSAGE")) {
|
||||||
|
t.Fatalf("InRelease not clearsigned:\n%s", idx.InRelease)
|
||||||
|
}
|
||||||
|
// Release.gpg must be an armored detached signature.
|
||||||
|
if !bytes.Contains(idx.ReleaseGpg, []byte("BEGIN PGP SIGNATURE")) {
|
||||||
|
t.Fatalf("Release.gpg not armored sig:\n%s", idx.ReleaseGpg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the clearsign and detached signatures with the public key.
|
||||||
|
pub, err := key.ArmoredPublic()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ArmoredPublic: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := gpg.VerifyClearSign(pub, idx.InRelease); err != nil {
|
||||||
|
t.Fatalf("verify InRelease: %v", err)
|
||||||
|
}
|
||||||
|
if err := gpg.VerifyDetached(pub, idx.Release, idx.ReleaseGpg); err != nil {
|
||||||
|
t.Fatalf("verify Release.gpg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checksum correctness: the Release SHA256 entry for the Packages file must
|
||||||
|
// match the bytes we generated.
|
||||||
|
want := sha256hex(pkg)
|
||||||
|
if !bytes.Contains(rel, []byte(" "+want)) {
|
||||||
|
t.Fatalf("Release missing correct Packages sha256 %s:\n%s", want, rel)
|
||||||
|
}
|
||||||
|
_ = strings.Repeat
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256hex(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package apt
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// PoolPath computes the conventional Debian pool path for a package:
|
||||||
|
// pool/<component>/<letter>/<src>/<filename>, where <src> is the source package
|
||||||
|
// name (or the binary package name if absent) and <letter> follows the Debian
|
||||||
|
// "libX" convention.
|
||||||
|
func PoolPath(component, source, packageName, filename string) string {
|
||||||
|
src := source
|
||||||
|
if src == "" {
|
||||||
|
src = packageName
|
||||||
|
}
|
||||||
|
letter := poolLetter(src)
|
||||||
|
return strings.Join([]string{"pool", component, letter, src, filename}, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolLetter returns the pool subdirectory prefix for a source name: "lib" +
|
||||||
|
// next char for names starting with "lib", otherwise the first character.
|
||||||
|
func poolLetter(src string) string {
|
||||||
|
if len(src) >= 4 && strings.HasPrefix(src, "lib") {
|
||||||
|
return "lib" + string(src[3])
|
||||||
|
}
|
||||||
|
if src == "" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return string(src[0])
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Package config defines the urapt-server configuration and its loading from
|
||||||
|
// defaults, a TOML file, environment variables, and command-line flags, with
|
||||||
|
// later sources overriding earlier ones.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults applied before any other source.
|
||||||
|
var Defaults = Config{
|
||||||
|
Bind: "0.0.0.0:8080",
|
||||||
|
BaseURL: "http://localhost:8080",
|
||||||
|
StoreDir: "./store",
|
||||||
|
LogLevel: "info",
|
||||||
|
SigningKeyType: "rsa",
|
||||||
|
SigningKeyBits: 4096,
|
||||||
|
MaxPackageSize: 1024 * 1024 * 1024,
|
||||||
|
OpenRegistration: true,
|
||||||
|
ConfigPath: "./urapt-server.toml",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is the urapt-server runtime configuration.
|
||||||
|
type Config struct {
|
||||||
|
Bind string `toml:"bind"`
|
||||||
|
BaseURL string `toml:"base_url"`
|
||||||
|
StoreDir string `toml:"store_dir"`
|
||||||
|
DBPath string `toml:"db_path"`
|
||||||
|
PackagesDir string `toml:"packages_dir"`
|
||||||
|
LogLevel string `toml:"log_level"`
|
||||||
|
SigningKeyType string `toml:"signing_key_type"`
|
||||||
|
SigningKeyBits int `toml:"signing_key_bits"`
|
||||||
|
SigningKeyUserID string `toml:"signing_key_user_id"`
|
||||||
|
MaxPackageSize int64 `toml:"max_package_size"`
|
||||||
|
OpenRegistration bool `toml:"open_registration"`
|
||||||
|
TLSEnabled bool `toml:"tls_enabled"`
|
||||||
|
TLSCert string `toml:"tls_cert"`
|
||||||
|
TLSKey string `toml:"tls_key"`
|
||||||
|
|
||||||
|
ConfigPath string `toml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load builds the effective Config from Defaults -> file -> env -> flags.
|
||||||
|
// Flags is a map of flag name to string value (already parsed by the caller).
|
||||||
|
func Load(configPath string, flags map[string]string) (Config, error) {
|
||||||
|
c := Defaults
|
||||||
|
c.ConfigPath = configPath
|
||||||
|
|
||||||
|
if err := applyFile(&c, configPath); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
applyEnv(&c)
|
||||||
|
if err := applyFlags(&c, flags); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
c.finalize()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFile(c *Config, path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := toml.Unmarshal(data, c); err != nil {
|
||||||
|
return fmt.Errorf("parse config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEnv(c *Config) {
|
||||||
|
set(c, "URAPT_BIND", &c.Bind)
|
||||||
|
set(c, "URAPT_BASE_URL", &c.BaseURL)
|
||||||
|
set(c, "URAPT_STORE_DIR", &c.StoreDir)
|
||||||
|
set(c, "URAPT_DB_PATH", &c.DBPath)
|
||||||
|
set(c, "URAPT_PACKAGES_DIR", &c.PackagesDir)
|
||||||
|
set(c, "URAPT_LOG_LEVEL", &c.LogLevel)
|
||||||
|
set(c, "URAPT_SIGNING_KEY_TYPE", &c.SigningKeyType)
|
||||||
|
set(c, "URAPT_SIGNING_KEY_USER_ID", &c.SigningKeyUserID)
|
||||||
|
set(c, "URAPT_TLS_CERT", &c.TLSCert)
|
||||||
|
set(c, "URAPT_TLS_KEY", &c.TLSKey)
|
||||||
|
setInt(c, "URAPT_SIGNING_KEY_BITS", &c.SigningKeyBits)
|
||||||
|
setInt64(c, "URAPT_MAX_PACKAGE_SIZE", &c.MaxPackageSize)
|
||||||
|
setBool(c, "URAPT_OPEN_REGISTRATION", &c.OpenRegistration)
|
||||||
|
setBool(c, "URAPT_TLS_ENABLED", &c.TLSEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFlags(c *Config, flags map[string]string) error {
|
||||||
|
for k, v := range flags {
|
||||||
|
switch k {
|
||||||
|
case "bind":
|
||||||
|
c.Bind = v
|
||||||
|
case "base-url":
|
||||||
|
c.BaseURL = v
|
||||||
|
case "store-dir":
|
||||||
|
c.StoreDir = v
|
||||||
|
case "db-path":
|
||||||
|
c.DBPath = v
|
||||||
|
case "packages-dir":
|
||||||
|
c.PackagesDir = v
|
||||||
|
case "log-level":
|
||||||
|
c.LogLevel = v
|
||||||
|
case "signing-key-type":
|
||||||
|
c.SigningKeyType = v
|
||||||
|
case "signing-key-bits":
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid --signing-key-bits %q: %w", v, err)
|
||||||
|
}
|
||||||
|
c.SigningKeyBits = n
|
||||||
|
case "signing-key-user-id":
|
||||||
|
c.SigningKeyUserID = v
|
||||||
|
case "max-package-size":
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid --max-package-size %q: %w", v, err)
|
||||||
|
}
|
||||||
|
c.MaxPackageSize = n
|
||||||
|
case "open-registration":
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid --open-registration %q: %w", v, err)
|
||||||
|
}
|
||||||
|
c.OpenRegistration = b
|
||||||
|
case "tls-enabled":
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid --tls-enabled %q: %w", v, err)
|
||||||
|
}
|
||||||
|
c.TLSEnabled = b
|
||||||
|
case "tls-cert":
|
||||||
|
c.TLSCert = v
|
||||||
|
case "tls-key":
|
||||||
|
c.TLSKey = v
|
||||||
|
case "config":
|
||||||
|
// already handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalize fills derived defaults: DBPath and PackagesDir default under
|
||||||
|
// StoreDir, and SigningKeyUserID gets a hostname-based default.
|
||||||
|
func (c *Config) finalize() {
|
||||||
|
if c.DBPath == "" {
|
||||||
|
c.DBPath = filepath.Join(c.StoreDir, "database", "sqlite.db")
|
||||||
|
}
|
||||||
|
if c.PackagesDir == "" {
|
||||||
|
c.PackagesDir = filepath.Join(c.StoreDir, "packages")
|
||||||
|
}
|
||||||
|
if c.SigningKeyUserID == "" {
|
||||||
|
host, err := os.Hostname()
|
||||||
|
if err != nil || host == "" {
|
||||||
|
host = "localhost"
|
||||||
|
}
|
||||||
|
c.SigningKeyUserID = "urapt-server <" + host + ">"
|
||||||
|
}
|
||||||
|
if c.MaxPackageSize <= 0 {
|
||||||
|
c.MaxPackageSize = Defaults.MaxPackageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the config for obvious errors before startup.
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Bind == "" {
|
||||||
|
return fmt.Errorf("bind address is required")
|
||||||
|
}
|
||||||
|
if c.BaseURL == "" {
|
||||||
|
return fmt.Errorf("base_url is required")
|
||||||
|
}
|
||||||
|
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
|
||||||
|
if c.SigningKeyBits <= 0 {
|
||||||
|
return fmt.Errorf("signing_key_bits must be positive")
|
||||||
|
}
|
||||||
|
if c.TLSEnabled && (c.TLSCert == "" || c.TLSKey == "") {
|
||||||
|
return fmt.Errorf("tls_enabled requires tls_cert and tls_key")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(c *Config, env string, dst *string) {
|
||||||
|
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||||
|
*dst = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInt(c *Config, env string, dst *int) {
|
||||||
|
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
*dst = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInt64(c *Config, env string, dst *int64) {
|
||||||
|
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
*dst = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBool(c *Config, env string, dst *bool) {
|
||||||
|
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
*dst = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Package crypto provides password hashing and API token generation utilities
|
||||||
|
// shared by the server and (for verification symmetry) tests.
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenPrefix is the textual prefix attached to all urapt API tokens so they
|
||||||
|
// are easy to identify and not confused with other secrets.
|
||||||
|
const TokenPrefix = "urapt_"
|
||||||
|
|
||||||
|
// HashPassword returns a bcrypt hash of the given plaintext password.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
h, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("bcrypt: %w", err)
|
||||||
|
}
|
||||||
|
return string(h), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword reports whether password matches the stored bcrypt hash.
|
||||||
|
func VerifyPassword(hash, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a new random API token (TokenPrefix + base64url of 32
|
||||||
|
// random bytes) and returns it together with its SHA-256 hash (for storage)
|
||||||
|
// and an 8-char display prefix.
|
||||||
|
func GenerateToken() (token, hash, prefix string, err error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(raw); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("rand: %w", err)
|
||||||
|
}
|
||||||
|
body := base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
token = TokenPrefix + body
|
||||||
|
hash = HashToken(token)
|
||||||
|
prefix = token[:len(TokenPrefix)+8]
|
||||||
|
return token, hash, prefix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashToken returns the SHA-256 hex digest of a token, for storage/lookup.
|
||||||
|
func HashToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHashAndVerifyPassword(t *testing.T) {
|
||||||
|
h, err := HashPassword("hunter2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(h, "hunter2") {
|
||||||
|
t.Fatal("expected verify to pass for correct password")
|
||||||
|
}
|
||||||
|
if VerifyPassword(h, "wrong") {
|
||||||
|
t.Fatal("expected verify to fail for wrong password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateToken(t *testing.T) {
|
||||||
|
tok, hash, prefix, err := GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateToken: %v", err)
|
||||||
|
}
|
||||||
|
if tok == "" || hash == "" || prefix == "" {
|
||||||
|
t.Fatal("empty token fields")
|
||||||
|
}
|
||||||
|
if len(tok) <= len(TokenPrefix) {
|
||||||
|
t.Fatal("token too short")
|
||||||
|
}
|
||||||
|
if tok[:len(TokenPrefix)] != TokenPrefix {
|
||||||
|
t.Fatalf("token missing prefix: %q", tok)
|
||||||
|
}
|
||||||
|
if HashToken(tok) != hash {
|
||||||
|
t.Fatal("HashToken does not match returned hash")
|
||||||
|
}
|
||||||
|
if prefix != tok[:len(TokenPrefix)+8] {
|
||||||
|
t.Fatalf("prefix %q != expected", prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok2, _, _, _ := GenerateToken()
|
||||||
|
if tok == tok2 {
|
||||||
|
t.Fatal("expected distinct tokens")
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
// Package db opens the urapt SQLite database (pure-Go modernc driver, no CGO),
|
||||||
|
// enables WAL mode and foreign keys, and applies embedded SQL migrations.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:migrations
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// Open opens (or creates) the SQLite database at path, applies pragmas and
|
||||||
|
// pending migrations, and returns the *sql.DB. The parent directory is created
|
||||||
|
// if missing.
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := mkdirAll(dir); err != nil {
|
||||||
|
return nil, fmt.Errorf("create db dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1) // SQLite serial writers; reads still concurrent under WAL via SetMaxOpenConns handling
|
||||||
|
|
||||||
|
if err := db.PingContext(context.Background()); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Migrate(context.Background(), db); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies any embedded SQL migrations not yet recorded in
|
||||||
|
// schema_migrations.
|
||||||
|
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||||
|
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("ensure migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations: %w", err)
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, e := range names {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
version, err := migrationVersion(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse migration name %s: %w", f, err)
|
||||||
|
}
|
||||||
|
var applied int
|
||||||
|
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version).Scan(&applied)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
if applied > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := migrationsFS.ReadFile("migrations/" + f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", f, err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, string(data)); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", f, err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, version, nowISO()); err != nil {
|
||||||
|
return fmt.Errorf("record migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nowISO returns the current UTC time in RFC3339 form.
|
||||||
|
func nowISO() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationVersion extracts the leading numeric component of a migration
|
||||||
|
// filename such as "0001_init.sql" -> 1.
|
||||||
|
func migrationVersion(name string) (int, error) {
|
||||||
|
name = strings.TrimSuffix(name, ".sql")
|
||||||
|
var num string
|
||||||
|
for _, r := range name {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
num += string(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if num == "" {
|
||||||
|
return 0, fmt.Errorf("no leading digits in %q", name)
|
||||||
|
}
|
||||||
|
return strconv.Atoi(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkdirAll(dir string) error {
|
||||||
|
if dir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAndMigrate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
err = db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type='table'`).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query tables: %v", err)
|
||||||
|
}
|
||||||
|
if n < 10 {
|
||||||
|
t.Fatalf("expected at least 10 tables, got %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
var v int
|
||||||
|
err = db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT version FROM schema_migrations WHERE version=1`).Scan(&v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migration not recorded: %v", err)
|
||||||
|
}
|
||||||
|
if v != 1 {
|
||||||
|
t.Fatalf("expected version 1, got %d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open first: %v", err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
db2, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open second: %v", err)
|
||||||
|
}
|
||||||
|
defer db2.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
-- 0001_init.sql: initial urapt schema.
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
username_lc TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE api_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
token_hash TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||||
|
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
|
||||||
|
CREATE TABLE repositories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
visibility TEXT NOT NULL CHECK (visibility IN ('public','private')),
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE repository_members (
|
||||||
|
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
access TEXT NOT NULL CHECK (access IN ('read','write','read-write','admin')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (repository_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE distributions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (repository_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE components (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (distribution_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE architectures (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (distribution_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE packages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT,
|
||||||
|
distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE,
|
||||||
|
component_id TEXT NOT NULL REFERENCES components(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
architecture TEXT NOT NULL,
|
||||||
|
source TEXT,
|
||||||
|
maintainer TEXT,
|
||||||
|
priority TEXT,
|
||||||
|
section TEXT,
|
||||||
|
origin TEXT,
|
||||||
|
homepage TEXT,
|
||||||
|
description TEXT,
|
||||||
|
description_md5 TEXT,
|
||||||
|
depends TEXT,
|
||||||
|
pre_depends TEXT,
|
||||||
|
recommends TEXT,
|
||||||
|
suggests TEXT,
|
||||||
|
conflicts TEXT,
|
||||||
|
breaks TEXT,
|
||||||
|
provides TEXT,
|
||||||
|
replaces TEXT,
|
||||||
|
enhances TEXT,
|
||||||
|
installed_size INTEGER,
|
||||||
|
essential TEXT,
|
||||||
|
built_using TEXT,
|
||||||
|
tag TEXT,
|
||||||
|
raw_control TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
pool_path TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
md5sum TEXT NOT NULL,
|
||||||
|
sha1 TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
uploaded_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (repository_id, distribution_id, component_id, name, version, architecture)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_packages_lookup ON packages(repository_id, distribution_id, component_id, name);
|
||||||
|
CREATE INDEX idx_packages_distro ON packages(repository_id, distribution_id);
|
||||||
|
CREATE INDEX idx_packages_arch ON packages(distribution_id, architecture);
|
||||||
|
CREATE INDEX idx_packages_sha256 ON packages(sha256);
|
||||||
|
|
||||||
|
CREATE TABLE blobs (
|
||||||
|
sha256 TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
ref_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE gpg_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
fingerprint TEXT UNIQUE NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
public_key_armored TEXT NOT NULL,
|
||||||
|
private_key_armored TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
repository_id TEXT REFERENCES repositories(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
details TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_repo ON audit_log(repository_id, created_at);
|
||||||
|
CREATE INDEX idx_audit_user ON audit_log(user_id, created_at);
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// Package deb parses Debian .deb archives: it reads the ar container, locates
|
||||||
|
// and decompresses the control.tar.* member, parses the control stanza, and
|
||||||
|
// computes whole-file hashes/sizes. It performs no execution of package
|
||||||
|
// contents.
|
||||||
|
package deb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/ulikunitz/xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deb holds the parsed metadata of a .deb file.
|
||||||
|
type Deb struct {
|
||||||
|
Control Control
|
||||||
|
Size int64
|
||||||
|
MD5sum string
|
||||||
|
SHA1 string
|
||||||
|
SHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field is a single control header field, preserving original key casing.
|
||||||
|
type Field struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control is a parsed control stanza. Fields preserves insertion order;
|
||||||
|
// Lookup gives case-insensitive access by key. Raw is the original text.
|
||||||
|
type Control struct {
|
||||||
|
Fields []Field
|
||||||
|
Lookup map[string]string
|
||||||
|
Raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the value of a field (case-insensitive), or "" if absent.
|
||||||
|
func (c Control) Get(key string) string {
|
||||||
|
if c.Lookup == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.Lookup[strings.ToLower(key)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect opens the .deb at path, computes whole-file hashes/size, and parses
|
||||||
|
// its control stanza.
|
||||||
|
func Inspect(path string) (*Deb, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open deb: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat deb: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hMD5 := md5.New()
|
||||||
|
hSHA1 := sha1.New()
|
||||||
|
hSHA256 := sha256.New()
|
||||||
|
size := stat.Size()
|
||||||
|
|
||||||
|
if _, err := io.Copy(io.MultiWriter(hMD5, hSHA1, hSHA256), f); err != nil {
|
||||||
|
return nil, fmt.Errorf("hash deb: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return nil, fmt.Errorf("seek deb: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
control, err := readControlFromAR(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Deb{
|
||||||
|
Control: control,
|
||||||
|
Size: size,
|
||||||
|
MD5sum: hex.EncodeToString(hMD5.Sum(nil)),
|
||||||
|
SHA1: hex.EncodeToString(hSHA1.Sum(nil)),
|
||||||
|
SHA256: hex.EncodeToString(hSHA256.Sum(nil)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readControlFromAR reads an ar stream and returns the parsed control stanza.
|
||||||
|
func readControlFromAR(r io.Reader) (Control, error) {
|
||||||
|
members, err := readAR(r)
|
||||||
|
if err != nil {
|
||||||
|
return Control{}, err
|
||||||
|
}
|
||||||
|
var debianBinary []byte
|
||||||
|
var controlTar []byte
|
||||||
|
var controlTarName string
|
||||||
|
for _, m := range members {
|
||||||
|
switch {
|
||||||
|
case m.Name == "debian-binary":
|
||||||
|
debianBinary = m.Data
|
||||||
|
case strings.HasPrefix(m.Name, "control.tar"):
|
||||||
|
controlTar = m.Data
|
||||||
|
controlTarName = m.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if debianBinary == nil {
|
||||||
|
return Control{}, fmt.Errorf("missing debian-binary member")
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(bytes.TrimSpace(debianBinary), []byte("2.0")) {
|
||||||
|
return Control{}, fmt.Errorf("unsupported deb format (expected 2.0)")
|
||||||
|
}
|
||||||
|
if controlTar == nil {
|
||||||
|
return Control{}, fmt.Errorf("missing control.tar member")
|
||||||
|
}
|
||||||
|
|
||||||
|
decompressed, err := decompressMember(controlTarName, controlTar)
|
||||||
|
if err != nil {
|
||||||
|
return Control{}, err
|
||||||
|
}
|
||||||
|
return parseControlTar(decompressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// arMember is a single file within an ar archive.
|
||||||
|
type arMember struct {
|
||||||
|
Name string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAR parses a Unix ar archive (the variant used by .deb: GNU/SysV style,
|
||||||
|
// with member names terminated by '/' and no long-name index needed for the
|
||||||
|
// short standard member names).
|
||||||
|
func readAR(r io.Reader) ([]arMember, error) {
|
||||||
|
br := newBlockReader(r)
|
||||||
|
magic := make([]byte, 8)
|
||||||
|
if _, err := io.ReadFull(br, magic); err != nil {
|
||||||
|
return nil, fmt.Errorf("read ar magic: %w", err)
|
||||||
|
}
|
||||||
|
if string(magic) != "!<arch>\n" {
|
||||||
|
return nil, fmt.Errorf("not an ar archive (bad magic)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var members []arMember
|
||||||
|
for {
|
||||||
|
header := make([]byte, 60)
|
||||||
|
n, err := io.ReadFull(br, header)
|
||||||
|
if err == io.EOF || (err == io.ErrUnexpectedEOF && n == 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ar header: %w", err)
|
||||||
|
}
|
||||||
|
if string(header[58:60]) != "`\n" {
|
||||||
|
return nil, fmt.Errorf("bad ar header terminator")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(strings.TrimRight(string(header[0:16]), " "))
|
||||||
|
name = strings.TrimSuffix(name, "/")
|
||||||
|
sizeStr := strings.TrimSpace(string(header[48:58]))
|
||||||
|
var size int64
|
||||||
|
fmt.Sscanf(sizeStr, "%d", &size)
|
||||||
|
if size < 0 {
|
||||||
|
return nil, fmt.Errorf("negative ar member size")
|
||||||
|
}
|
||||||
|
data := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(br, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("read ar member %q: %w", name, err)
|
||||||
|
}
|
||||||
|
members = append(members, arMember{Name: name, Data: data})
|
||||||
|
if size%2 == 1 {
|
||||||
|
pad := make([]byte, 1)
|
||||||
|
if _, err := io.ReadFull(br, pad); err != nil {
|
||||||
|
return nil, fmt.Errorf("read ar padding: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockReader is a thin wrapper that ensures io.ReadFull semantics work on any
|
||||||
|
// io.Reader (it just forwards reads).
|
||||||
|
type blockReader struct {
|
||||||
|
r io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBlockReader(r io.Reader) *blockReader { return &blockReader{r: r} }
|
||||||
|
func (b *blockReader) Read(p []byte) (int, error) { return b.r.Read(p) }
|
||||||
|
|
||||||
|
// decompressMember decompresses a control.tar.* member based on its name
|
||||||
|
// extension.
|
||||||
|
func decompressMember(name string, data []byte) ([]byte, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(name, ".gz"):
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gzip: %w", err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
return io.ReadAll(gz)
|
||||||
|
case strings.HasSuffix(name, ".xz"):
|
||||||
|
xr, err := xz.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("xz: %w", err)
|
||||||
|
}
|
||||||
|
return io.ReadAll(xr)
|
||||||
|
case strings.HasSuffix(name, ".zst"):
|
||||||
|
zr, err := zstd.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zstd: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
return io.ReadAll(zr)
|
||||||
|
case strings.HasSuffix(name, ".tar"):
|
||||||
|
return data, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown control.tar compression: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseControlTar reads a tar stream and returns the first control stanza
|
||||||
|
// found in a file named "control" or "./control".
|
||||||
|
func parseControlTar(tarData []byte) (Control, error) {
|
||||||
|
tr := tar.NewReader(bytes.NewReader(tarData))
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Control{}, fmt.Errorf("read tar: %w", err)
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(hdr.Name, "./")
|
||||||
|
if name == "control" {
|
||||||
|
body, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
return Control{}, fmt.Errorf("read control: %w", err)
|
||||||
|
}
|
||||||
|
return ParseControl(bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Control{}, fmt.Errorf("control file not found in control.tar")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseControl parses a single RFC822-style control stanza. Continuation
|
||||||
|
// lines (starting with a space or tab) are appended to the previous field's
|
||||||
|
// value with the leading whitespace preserved as a single space.
|
||||||
|
func ParseControl(r io.Reader) (Control, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return Control{}, fmt.Errorf("read control: %w", err)
|
||||||
|
}
|
||||||
|
raw := strings.TrimRight(string(data), "\n")
|
||||||
|
c := Control{Lookup: map[string]string{}, Raw: raw}
|
||||||
|
|
||||||
|
var curKey, curVal string
|
||||||
|
flush := func() {
|
||||||
|
if curKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Fields = append(c.Fields, Field{Key: curKey, Value: curVal})
|
||||||
|
c.Lookup[strings.ToLower(curKey)] = curVal
|
||||||
|
curKey, curVal = "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line[0] == ' ' || line[0] == '\t' {
|
||||||
|
if curKey == "" {
|
||||||
|
return Control{}, fmt.Errorf("continuation line with no field")
|
||||||
|
}
|
||||||
|
// Debian control continuation: strip exactly one leading space,
|
||||||
|
// and a line that is just "." represents a blank line.
|
||||||
|
body := line
|
||||||
|
if body[0] == ' ' {
|
||||||
|
body = body[1:]
|
||||||
|
} else {
|
||||||
|
body = strings.TrimLeft(body, " \t")
|
||||||
|
}
|
||||||
|
if body == "." {
|
||||||
|
curVal += "\n"
|
||||||
|
} else {
|
||||||
|
curVal += "\n" + strings.TrimRight(body, " \t\r")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
colon := strings.IndexByte(line, ':')
|
||||||
|
if colon < 0 {
|
||||||
|
return Control{}, fmt.Errorf("malformed control line: %q", line)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
curKey = strings.TrimSpace(line[:colon])
|
||||||
|
curVal = strings.TrimSpace(line[colon+1:])
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
|
||||||
|
if len(c.Fields) == 0 {
|
||||||
|
return Control{}, fmt.Errorf("empty control stanza")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortDescription returns the short summary (the first line of Description).
|
||||||
|
func (c Control) ShortDescription() string {
|
||||||
|
desc := c.Get("Description")
|
||||||
|
if desc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(desc, '\n'); i >= 0 {
|
||||||
|
return desc[:i]
|
||||||
|
}
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
|
||||||
|
// LongDescription returns the extended description (everything after the short
|
||||||
|
// summary line).
|
||||||
|
func (c Control) LongDescription() string {
|
||||||
|
desc := c.Get("Description")
|
||||||
|
if desc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(desc, '\n'); i >= 0 {
|
||||||
|
return strings.TrimLeft(desc[i+1:], "\n")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DescriptionMD5 returns the MD5 hex digest of the long description, matching
|
||||||
|
// Debian's Description-md5 Packages field.
|
||||||
|
func (c Control) DescriptionMD5() string {
|
||||||
|
sum := md5.Sum([]byte(c.LongDescription()))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsControlFieldName reports whether s looks like a valid control field name
|
||||||
|
// (non-empty, colon-free, starts with a non-space, ASCII letters/digits/-).
|
||||||
|
func IsControlFieldName(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= 'a' && r <= 'z' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure unicode is referenced (reserved for stricter validation later).
|
||||||
|
var _ = unicode.IsLetter
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package deb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeTestDeb builds a minimal valid .deb at path with the given control
|
||||||
|
// stanza text.
|
||||||
|
func makeTestDeb(t *testing.T, path, controlText string) {
|
||||||
|
t.Helper()
|
||||||
|
controlTar := buildControlTar(t, controlText)
|
||||||
|
dataTar := buildDataTar(t)
|
||||||
|
deb := buildAr(t, controlTar, dataTar)
|
||||||
|
if err := os.WriteFile(path, deb, 0o644); err != nil {
|
||||||
|
t.Fatalf("write deb: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildControlTar(t *testing.T, controlText string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
addFile(t, tw, "control", controlText)
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatalf("close control tar: %v", err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatalf("close control gz: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDataTar(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
addFile(t, tw, "usr/share/doc/foo/README", "readme\n")
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatalf("close data tar: %v", err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatalf("close data gz: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFile(t *testing.T, tw *tar.Writer, name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("write tar header %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write([]byte(body)); err != nil {
|
||||||
|
t.Fatalf("write tar body %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAr(t *testing.T, controlTar, dataTar []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteString("!<arch>\n")
|
||||||
|
writeArMember(&buf, "debian-binary", []byte("2.0\n"))
|
||||||
|
writeArMember(&buf, "control.tar.gz", controlTar)
|
||||||
|
writeArMember(&buf, "data.tar.gz", dataTar)
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArMember(buf *bytes.Buffer, name string, data []byte) {
|
||||||
|
header := make([]byte, 60)
|
||||||
|
for i := range header {
|
||||||
|
header[i] = ' '
|
||||||
|
}
|
||||||
|
copy(header[0:], name+"/")
|
||||||
|
copy(header[48:], []byte(padLeft(len(data), 10)))
|
||||||
|
header[58] = '`'
|
||||||
|
header[59] = '\n'
|
||||||
|
buf.Write(header)
|
||||||
|
buf.Write(data)
|
||||||
|
if len(data)%2 == 1 {
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func padLeft(n, width int) string {
|
||||||
|
s := []byte(strings.Repeat(" ", width))
|
||||||
|
v := []byte(itoa(n))
|
||||||
|
copy(s[len(s)-len(v):], v)
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{byte('0' + n%10)}, b...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspect(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "foo_1.0_amd64.deb")
|
||||||
|
controlText := `Package: foo
|
||||||
|
Version: 1.0
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: Test <test@example.com>
|
||||||
|
Installed-Size: 42
|
||||||
|
Depends: libc6 (>= 2.31), bash | dash
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Homepage: https://example.com
|
||||||
|
Description: short summary
|
||||||
|
extended description line one
|
||||||
|
.
|
||||||
|
second paragraph.
|
||||||
|
`
|
||||||
|
makeTestDeb(t, path, controlText)
|
||||||
|
|
||||||
|
d, err := Inspect(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Inspect: %v", err)
|
||||||
|
}
|
||||||
|
if d.Control.Get("Package") != "foo" {
|
||||||
|
t.Fatalf("Package = %q", d.Control.Get("Package"))
|
||||||
|
}
|
||||||
|
if d.Control.Get("Version") != "1.0" {
|
||||||
|
t.Fatalf("Version = %q", d.Control.Get("Version"))
|
||||||
|
}
|
||||||
|
if d.Control.Get("Architecture") != "amd64" {
|
||||||
|
t.Fatalf("Architecture = %q", d.Control.Get("Architecture"))
|
||||||
|
}
|
||||||
|
if d.Control.Get("Depends") != "libc6 (>= 2.31), bash | dash" {
|
||||||
|
t.Fatalf("Depends = %q", d.Control.Get("Depends"))
|
||||||
|
}
|
||||||
|
if d.Size <= 0 {
|
||||||
|
t.Fatalf("Size = %d", d.Size)
|
||||||
|
}
|
||||||
|
if d.MD5sum == "" || d.SHA1 == "" || d.SHA256 == "" {
|
||||||
|
t.Fatalf("hashes empty: md5=%s sha1=%s sha256=%s", d.MD5sum, d.SHA1, d.SHA256)
|
||||||
|
}
|
||||||
|
if d.Control.ShortDescription() != "short summary" {
|
||||||
|
t.Fatalf("short = %q", d.Control.ShortDescription())
|
||||||
|
}
|
||||||
|
long := d.Control.LongDescription()
|
||||||
|
if !strings.Contains(long, "extended description line one") || !strings.Contains(long, "second paragraph.") {
|
||||||
|
t.Fatalf("long = %q", long)
|
||||||
|
}
|
||||||
|
if d.Control.DescriptionMD5() == "" {
|
||||||
|
t.Fatal("empty description md5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseControlContinuation(t *testing.T) {
|
||||||
|
c, err := ParseControl(strings.NewReader("Package: bar\nVersion: 1\nDescription: short\n long\n .\n more\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseControl: %v", err)
|
||||||
|
}
|
||||||
|
if c.Get("Description") != "short\nlong\n\nmore" {
|
||||||
|
t.Fatalf("Description = %q", c.Get("Description"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Package gpg provides server-managed OpenPGP signing: key generation,
|
||||||
|
// armored export/import, clearsigning (for InRelease), and detached signing
|
||||||
|
// (for Release.gpg). It uses the pure-Go ProtonMail/go-crypto library so the
|
||||||
|
// server has no runtime dependency on the gpg binary.
|
||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key wraps an OpenPGP entity together with metadata urapt uses.
|
||||||
|
type Key struct {
|
||||||
|
Entity *openpgp.Entity
|
||||||
|
Fingerprint string
|
||||||
|
UserID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKey creates a new RSA signing key with the given user-id (in the form
|
||||||
|
// "Name <email>" or a plain name) and key size in bits.
|
||||||
|
func GenerateKey(userID string, bits int) (*Key, error) {
|
||||||
|
if bits <= 0 {
|
||||||
|
bits = 4096
|
||||||
|
}
|
||||||
|
name, email := splitUserID(userID)
|
||||||
|
cfg := &packet.Config{
|
||||||
|
RSABits: bits,
|
||||||
|
DefaultHash: crypto.SHA256,
|
||||||
|
V6Keys: false,
|
||||||
|
}
|
||||||
|
entity, err := openpgp.NewEntity(name, "", email, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new entity: %w", err)
|
||||||
|
}
|
||||||
|
return &Key{
|
||||||
|
Entity: entity,
|
||||||
|
Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint),
|
||||||
|
UserID: userID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseArmoredPrivate decodes an ASCII-armored private key produced by
|
||||||
|
// ArmoredPrivate.
|
||||||
|
func ParseArmoredPrivate(armored string) (*Key, error) {
|
||||||
|
block, err := armor.Decode(strings.NewReader(armored))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode armor: %w", err)
|
||||||
|
}
|
||||||
|
if block.Type != "PGP PRIVATE KEY BLOCK" {
|
||||||
|
return nil, fmt.Errorf("unexpected armor type %q", block.Type)
|
||||||
|
}
|
||||||
|
entity, err := openpgp.ReadEntity(packet.NewReader(block.Body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read entity: %w", err)
|
||||||
|
}
|
||||||
|
uid := ""
|
||||||
|
if id := entity.PrimaryIdentity(); id != nil {
|
||||||
|
uid = id.Name
|
||||||
|
}
|
||||||
|
return &Key{
|
||||||
|
Entity: entity,
|
||||||
|
Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint),
|
||||||
|
UserID: uid,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArmoredPublic returns the ASCII-armored public key.
|
||||||
|
func (k *Key) ArmoredPublic() (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("armor encode: %w", err)
|
||||||
|
}
|
||||||
|
if err := k.Entity.Serialize(w); err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
return "", fmt.Errorf("serialize public: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("close armor: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArmoredPrivate returns the ASCII-armored private key (unencrypted).
|
||||||
|
func (k *Key) ArmoredPrivate() (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("armor encode: %w", err)
|
||||||
|
}
|
||||||
|
if err := k.Entity.SerializePrivate(w, nil); err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
return "", fmt.Errorf("serialize private: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("close armor: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSign produces a clearsigned message (used for the InRelease file).
|
||||||
|
func (k *Key) ClearSign(data []byte) ([]byte, error) {
|
||||||
|
sk, ok := k.Entity.SigningKey(time.Now())
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no signing key available")
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
cfg := &packet.Config{DefaultHash: crypto.SHA256}
|
||||||
|
plaintext, err := clearsign.Encode(&out, sk.PrivateKey, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clearsign encode: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := plaintext.Write(data); err != nil {
|
||||||
|
_ = plaintext.Close()
|
||||||
|
return nil, fmt.Errorf("write clearsign: %w", err)
|
||||||
|
}
|
||||||
|
if err := plaintext.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("close clearsign: %w", err)
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetachedSign produces an ASCII-armored detached signature of data (used for
|
||||||
|
// Release.gpg).
|
||||||
|
func (k *Key) DetachedSign(data []byte) ([]byte, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
cfg := &packet.Config{DefaultHash: crypto.SHA256}
|
||||||
|
if err := openpgp.ArmoredDetachSign(&out, k.Entity, bytes.NewReader(data), cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("detach sign: %w", err)
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyClearSign is a test helper that verifies a clearsigned block and
|
||||||
|
// returns the plaintext.
|
||||||
|
func VerifyClearSign(armoredPublic string, clearsigned []byte) (plaintext []byte, err error) {
|
||||||
|
key, err := ParseArmoredPublic(armoredPublic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, rest := clearsign.Decode(clearsigned)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no clearsign block (rest=%d bytes)", len(rest))
|
||||||
|
}
|
||||||
|
keyring := openpgp.EntityList{key.Entity}
|
||||||
|
if _, err := block.VerifySignature(keyring, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("verify: %w", err)
|
||||||
|
}
|
||||||
|
return block.Bytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDetached verifies an armored detached signature of data using the
|
||||||
|
// given armored public key. Test helper.
|
||||||
|
func VerifyDetached(armoredPublic string, data, armoredSig []byte) error {
|
||||||
|
key, err := ParseArmoredPublic(armoredPublic)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyring := openpgp.EntityList{key.Entity}
|
||||||
|
if _, err := openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(armoredSig), nil); err != nil {
|
||||||
|
return fmt.Errorf("verify: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseArmoredPublic decodes an ASCII-armored public key.
|
||||||
|
func ParseArmoredPublic(armored string) (*Key, error) {
|
||||||
|
block, err := armor.Decode(strings.NewReader(armored))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode armor: %w", err)
|
||||||
|
}
|
||||||
|
if block.Type != "PGP PUBLIC KEY BLOCK" {
|
||||||
|
return nil, fmt.Errorf("unexpected armor type %q", block.Type)
|
||||||
|
}
|
||||||
|
entity, err := openpgp.ReadEntity(packet.NewReader(block.Body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read entity: %w", err)
|
||||||
|
}
|
||||||
|
uid := ""
|
||||||
|
if id := entity.PrimaryIdentity(); id != nil {
|
||||||
|
uid = id.Name
|
||||||
|
}
|
||||||
|
return &Key{
|
||||||
|
Entity: entity,
|
||||||
|
Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint),
|
||||||
|
UserID: uid,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitUserID parses a user-id of the form "Name <email>" into name and email.
|
||||||
|
// If no email brackets are present, the whole string is treated as the name.
|
||||||
|
func splitUserID(userID string) (name, email string) {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
i := strings.LastIndexByte(userID, '<')
|
||||||
|
j := strings.LastIndexByte(userID, '>')
|
||||||
|
if i >= 0 && j > i {
|
||||||
|
name = strings.TrimSpace(userID[:i])
|
||||||
|
email = strings.TrimSpace(userID[i+1 : j])
|
||||||
|
return name, email
|
||||||
|
}
|
||||||
|
return userID, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure io is referenced (used implicitly by armor/clearsign APIs).
|
||||||
|
var _ = io.EOF
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package gpg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateAndSign(t *testing.T) {
|
||||||
|
k, err := GenerateKey("urapt-server <test.example.com>", 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
if k.Fingerprint == "" {
|
||||||
|
t.Fatal("empty fingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub, err := k.ArmoredPublic()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ArmoredPublic: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(pub), []byte("BEGIN PGP PUBLIC KEY BLOCK")) {
|
||||||
|
t.Fatal("bad armored public")
|
||||||
|
}
|
||||||
|
priv, err := k.ArmoredPrivate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ArmoredPrivate: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(priv), []byte("BEGIN PGP PRIVATE KEY BLOCK")) {
|
||||||
|
t.Fatal("bad armored private")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte("Origin: urapt\nSuite: stable\n\nContents here.\n")
|
||||||
|
|
||||||
|
clear, err := k.ClearSign(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ClearSign: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(clear, []byte("BEGIN PGP SIGNED MESSAGE")) {
|
||||||
|
t.Fatal("bad clearsign output")
|
||||||
|
}
|
||||||
|
pt, err := VerifyClearSign(pub, clear)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyClearSign: %v", err)
|
||||||
|
}
|
||||||
|
if strings.ReplaceAll(string(pt), "\r\n", "\n") != string(data) {
|
||||||
|
t.Fatalf("plaintext mismatch: got %q want %q", pt, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
det, err := k.DetachedSign(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DetachedSign: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(det, []byte("BEGIN PGP SIGNATURE")) {
|
||||||
|
t.Fatal("bad detached output")
|
||||||
|
}
|
||||||
|
if err := VerifyDetached(pub, data, det); err != nil {
|
||||||
|
t.Fatalf("VerifyDetached: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
k2, err := ParseArmoredPrivate(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseArmoredPrivate: %v", err)
|
||||||
|
}
|
||||||
|
if k2.Fingerprint != k.Fingerprint {
|
||||||
|
t.Fatalf("fingerprint mismatch after round-trip: %s != %s", k2.Fingerprint, k.Fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Package httputil provides small helpers for JSON I/O, uniform error
|
||||||
|
// rendering, and parsing of Authorization headers shared across the REST API
|
||||||
|
// and the APT endpoint.
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIError is the uniform JSON error body: {"error": {...}}.
|
||||||
|
type APIError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details any `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorEnvelope wraps an APIError.
|
||||||
|
type ErrorEnvelope struct {
|
||||||
|
Error APIError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteJSON writes v as JSON with the given status code.
|
||||||
|
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadJSON decodes r.Body into v. It limits the body to maxBytes.
|
||||||
|
func ReadJSON(r *http.Request, v any, maxBytes int64) error {
|
||||||
|
if maxBytes > 0 {
|
||||||
|
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError renders a uniform error response.
|
||||||
|
func WriteError(w http.ResponseWriter, status int, code, message string, details ...any) {
|
||||||
|
e := APIError{Code: code, Message: message}
|
||||||
|
if len(details) > 0 {
|
||||||
|
e.Details = details[0]
|
||||||
|
}
|
||||||
|
WriteJSON(w, status, ErrorEnvelope{Error: e})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteErrorf is WriteError with printf-style message formatting.
|
||||||
|
func WriteErrorf(w http.ResponseWriter, status int, code, format string, args ...any) {
|
||||||
|
WriteError(w, status, code, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common error code constants.
|
||||||
|
const (
|
||||||
|
CodeBadRequest = "bad_request"
|
||||||
|
CodeUnauthorized = "unauthorized"
|
||||||
|
CodeForbidden = "forbidden"
|
||||||
|
CodeNotFound = "not_found"
|
||||||
|
CodeConflict = "conflict"
|
||||||
|
CodePayloadTooLarge = "payload_too_large"
|
||||||
|
CodeInternal = "internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseBearer extracts the token from an "Authorization: Bearer <token>"
|
||||||
|
// header. ok is false if the header is absent or malformed.
|
||||||
|
func ParseBearer(h http.Header) (token string, ok bool) {
|
||||||
|
v := h.Get("Authorization")
|
||||||
|
if v == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(v, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(parts[1])
|
||||||
|
if t == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBasic extracts username/password from an "Authorization: Basic"
|
||||||
|
// header. ok is false if absent or malformed.
|
||||||
|
func ParseBasic(h http.Header) (username, password string, ok bool) {
|
||||||
|
v := h.Get("Authorization")
|
||||||
|
if v == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(v, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Basic") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
idx := strings.IndexByte(string(raw), ':')
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return string(raw[:idx]), string(raw[idx+1:]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChallengeBasic writes a 401 with a WWW-Authenticate Basic challenge.
|
||||||
|
func ChallengeBasic(w http.ResponseWriter, realm string) {
|
||||||
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q, charset="UTF-8"`, realm))
|
||||||
|
WriteError(w, http.StatusUnauthorized, CodeUnauthorized, "authentication required")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Package log provides a thin structured logging wrapper over the standard
|
||||||
|
// library's log/slog package, with a configurable level.
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level names accepted by ParseLevel.
|
||||||
|
const (
|
||||||
|
LevelDebug = "debug"
|
||||||
|
LevelInfo = "info"
|
||||||
|
LevelWarn = "warn"
|
||||||
|
LevelError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New constructs a slog.Logger writing to stderr at the given level. Unknown
|
||||||
|
// levels fall back to info.
|
||||||
|
func New(level string) *slog.Logger {
|
||||||
|
var lv slog.Level
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case LevelDebug:
|
||||||
|
lv = slog.LevelDebug
|
||||||
|
case LevelWarn:
|
||||||
|
lv = slog.LevelWarn
|
||||||
|
case LevelError:
|
||||||
|
lv = slog.LevelError
|
||||||
|
default:
|
||||||
|
lv = slog.LevelInfo
|
||||||
|
}
|
||||||
|
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lv})
|
||||||
|
return slog.New(h)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Package models defines the domain types used across urapt's database and API
|
||||||
|
// boundaries. These structs mirror the SQLite schema and are serialized into
|
||||||
|
// API DTOs by the restapi and apiclient packages.
|
||||||
|
package models
|
||||||
|
|
||||||
|
// User is an urapt account.
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIToken is a revocable authentication token. The plaintext token is only
|
||||||
|
// returned at creation/login time; only its hash and a short display prefix
|
||||||
|
// are persisted.
|
||||||
|
type APIToken struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Token string `json:"token,omitempty"` // plaintext, only on creation
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastUsedAt *string `json:"last_used_at,omitempty"`
|
||||||
|
RevokedAt *string `json:"revoked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility is whether a repository is world-readable.
|
||||||
|
type Visibility string
|
||||||
|
|
||||||
|
const (
|
||||||
|
VisibilityPublic Visibility = "public"
|
||||||
|
VisibilityPrivate Visibility = "private"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Access is a user's role on a repository.
|
||||||
|
type Access string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccessRead Access = "read"
|
||||||
|
AccessWrite Access = "write"
|
||||||
|
AccessReadWrite Access = "read-write"
|
||||||
|
AccessAdmin Access = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidAccess reports whether s is a recognized access level.
|
||||||
|
func ValidAccess(s string) bool {
|
||||||
|
switch Access(s) {
|
||||||
|
case AccessRead, AccessWrite, AccessReadWrite, AccessAdmin:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository is a named APT repository owned by a user.
|
||||||
|
type Repository struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OwnerUserID string `json:"owner_user_id"`
|
||||||
|
Owner *User `json:"owner,omitempty"`
|
||||||
|
Visibility Visibility `json:"visibility"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepositoryMember is a user's access grant on a repository.
|
||||||
|
type RepositoryMember struct {
|
||||||
|
RepositoryID string `json:"repository_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
User *User `json:"user,omitempty"`
|
||||||
|
Access Access `json:"access"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribution (suite) within a repository.
|
||||||
|
type Distribution struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RepositoryID string `json:"repository_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component within a distribution (e.g. main, contrib).
|
||||||
|
type Component struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DistributionID string `json:"distribution_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Architecture configured for a distribution (e.g. amd64). The special
|
||||||
|
// architecture "all" is implicit and never stored.
|
||||||
|
type Architecture struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DistributionID string `json:"distribution_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package is one uploaded .deb version and its extracted control metadata.
|
||||||
|
type Package struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RepositoryID string `json:"repository_id"`
|
||||||
|
DistributionID string `json:"distribution_id"`
|
||||||
|
ComponentID string `json:"component_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Maintainer string `json:"maintainer,omitempty"`
|
||||||
|
Priority string `json:"priority,omitempty"`
|
||||||
|
Section string `json:"section,omitempty"`
|
||||||
|
Origin string `json:"origin,omitempty"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
DescriptionMD5 string `json:"description_md5,omitempty"`
|
||||||
|
Depends string `json:"depends,omitempty"`
|
||||||
|
PreDepends string `json:"pre_depends,omitempty"`
|
||||||
|
Recommends string `json:"recommends,omitempty"`
|
||||||
|
Suggests string `json:"suggests,omitempty"`
|
||||||
|
Conflicts string `json:"conflicts,omitempty"`
|
||||||
|
Breaks string `json:"breaks,omitempty"`
|
||||||
|
Provides string `json:"provides,omitempty"`
|
||||||
|
Replaces string `json:"replaces,omitempty"`
|
||||||
|
Enhances string `json:"enhances,omitempty"`
|
||||||
|
InstalledSize int64 `json:"installed_size,omitempty"`
|
||||||
|
Essential string `json:"essential,omitempty"`
|
||||||
|
BuiltUsing string `json:"built_using,omitempty"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
RawControl string `json:"raw_control"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
PoolPath string `json:"pool_path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
MD5sum string `json:"md5sum"`
|
||||||
|
SHA1 string `json:"sha1"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
UploadedByUserID string `json:"uploaded_by_user_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob is a content-addressed .deb file on disk, reference-counted for dedup.
|
||||||
|
type Blob struct {
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
RefCount int64 `json:"ref_count"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPGKey is a server-managed signing key.
|
||||||
|
type GPGKey struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
PublicKeyArmored string `json:"public_key_armored"`
|
||||||
|
PrivateKeyArmored string `json:"-"` // never serialized in API responses
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogEntry is a best-effort record of a mutating action.
|
||||||
|
type AuditLogEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID *string `json:"user_id,omitempty"`
|
||||||
|
RepositoryID *string `json:"repository_id,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Package version holds build-time version information for urapt.
|
||||||
|
package version
|
||||||
|
|
||||||
|
import "runtime/debug"
|
||||||
|
|
||||||
|
// Version is the urapt build version. It is populated from VCS info when
|
||||||
|
// available, otherwise it falls back to "dev".
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, s := range info.Settings {
|
||||||
|
if s.Key == "vcs.revision" {
|
||||||
|
Version = s.Value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# urapt-server configuration. Copy to urapt-server.toml and edit.
|
||||||
|
# Any value here can be overridden by URAPT_* environment variables or CLI flags.
|
||||||
|
|
||||||
|
bind = "0.0.0.0:8080"
|
||||||
|
base_url = "https://apt.example.com"
|
||||||
|
store_dir = "./store"
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
# Signing key generated on first start if none exists.
|
||||||
|
signing_key_type = "rsa"
|
||||||
|
signing_key_bits = 4096
|
||||||
|
signing_key_user_id = "urapt-server <apt.example.com>"
|
||||||
|
|
||||||
|
# Maximum .deb upload size (bytes).
|
||||||
|
max_package_size = 1073741824
|
||||||
|
|
||||||
|
# Allow new users to self-register. The first registrant always becomes admin.
|
||||||
|
open_registration = true
|
||||||
|
|
||||||
|
# Optional built-in TLS (otherwise use a TLS-terminating reverse proxy).
|
||||||
|
tls_enabled = false
|
||||||
|
# tls_cert = "/path/to/fullchain.pem"
|
||||||
|
# tls_key = "/path/to/privkey.pem"
|
||||||
Reference in New Issue
Block a user