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.
256 lines
8.5 KiB
Go
256 lines
8.5 KiB
Go
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)
|
|
}
|