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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user