commit b15b95781c0accba6e70f4d50cfa488789f3790d Author: delete Date: Wed Jun 10 20:51:17 2026 +0300 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2c727ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.DS_Store +.bin/ +data-dev/ +docs/agents/ +*.cookies +*.sqlite +*.sqlite-shm +*.sqlite-wal + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..003b816 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.bin/ +data-dev/ diff --git a/DEVELOPMENT_WORKFLOW.md b/DEVELOPMENT_WORKFLOW.md new file mode 100644 index 0000000..29aff06 --- /dev/null +++ b/DEVELOPMENT_WORKFLOW.md @@ -0,0 +1,74 @@ +# Update Server - Development Workflow + +## Recommendation + +Start development locally on your Mac. + +Only move to Proxmox after the first complete flow works: + +- admin login; +- create project; +- upload release; +- create API key; +- download release through API. + +This is the fastest and least painful path. + +## Why not start directly on Proxmox + +- slower iteration cycle; +- harder debugging; +- extra network and proxy setup too early; +- file upload and local logs are less convenient while the app is changing a lot. + +## Recommended workflow + +### Stage 1 - Local development on Mac + +- keep the whole project in one directory; +- run Go locally; +- store local dev data in a project folder like `./data-dev`; +- use SQLite locally; +- build the admin pages and API here first. + +### Stage 2 - Local container verification + +- add `Dockerfile`; +- add `docker-compose.yml` if needed; +- verify the app runs with mounted `/data`; +- verify uploads and DB survive container restart. + +### Stage 3 - Deploy to Proxmox + +- create VM or LXC; +- run app container; +- mount persistent storage for `/data`; +- place Caddy or Nginx in front for HTTPS; +- expose only the reverse proxy to the internet; +- optionally protect admin UI by IP allow-list or VPN. + +## Local development checklist + +- install Go +- initialize module +- add SQLite migrations +- create `.env` or env loader for dev values +- run server locally +- test upload/download with browser and `curl` +- only then freeze container setup + +## Deployment checklist + +- persistent volume for SQLite and artifacts +- HTTPS configured +- strong `SESSION_SECRET` +- bootstrap admin credentials +- backup strategy for `/data` +- logs enabled +- rate limiting enabled + +## Final recommendation + +Develop on Mac first. + +Use Proxmox as the deployment target, not as the primary coding environment. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..15a99d7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +FROM golang:1.26-bookworm AS build + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY migrations/ migrations/ +COPY web/ web/ + +RUN CGO_ENABLED=1 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/update-server ./cmd/server +RUN CGO_ENABLED=1 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/update-migrate ./cmd/migrate + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates sqlite3 tzdata \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system --gid 10001 update-server \ + && useradd --system --uid 10001 --gid 10001 --home /nonexistent --shell /usr/sbin/nologin update-server + +WORKDIR /app + +COPY --from=build /out/update-server /usr/local/bin/update-server +COPY --from=build /out/update-migrate /usr/local/bin/update-migrate +COPY migrations/ ./migrations/ +COPY web/ ./web/ + +RUN mkdir -p /data \ + && chown -R update-server:update-server /app /data + +ENV APP_ADDR=0.0.0.0:8080 \ + DATA_DIR=/data \ + SQLITE_PATH=/data/db.sqlite \ + ARTIFACTS_DIR=/data/artifacts \ + MIGRATIONS_DIR=/app/migrations \ + TEMPLATES_DIR=/app/web/templates \ + STATIC_DIR=/app/web/static \ + TRUST_PROXY_HEADERS=true \ + APP_READ_TIMEOUT=30s \ + APP_READ_HEADER_TIMEOUT=5s \ + APP_WRITE_TIMEOUT=60s \ + APP_IDLE_TIMEOUT=120s \ + APP_MAX_HEADER_BYTES=1048576 \ + APP_LOGIN_RATE_LIMIT_PER_MINUTE=10 \ + APP_LOGIN_RATE_LIMIT_BURST=5 \ + APP_CLIENT_RATE_LIMIT_PER_MINUTE=120 \ + APP_CLIENT_RATE_LIMIT_BURST=60 + +VOLUME ["/data"] +EXPOSE 8080 + +USER update-server:update-server + +ENTRYPOINT ["update-server"] + diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..957666f --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,495 @@ +# Update Server - Implementation Strategy + +## 1. Recommended stack + +### Backend + +- Go 1.24+ +- Router: `chi` or `gin` +- HTML templates for admin UI, or server-rendered pages first +- Database: `SQLite` +- ORM/query layer: `sqlc`, `bun`, `gorm`, or plain `database/sql` +- Migrations: `golang-migrate` or `goose` +- Auth: + - web admin via secure cookie session + - client API via bearer API key + +### My recommendation + +For this project, a pragmatic Go stack would be: + +- `chi` for routing; +- `database/sql` + `sqlc` or `bun`; +- `SQLite` with `modernc.org/sqlite` or `mattn/go-sqlite3`; +- server-rendered HTML templates for admin pages; +- `goose` for migrations. + +Why this stack: + +- simple to deploy; +- less JS and frontend complexity; +- good fit for an internal admin panel; +- easier to keep maintainable for a small self-hosted tool. + +## 2. Quick glossary + +`GUI` + +- graphical user interface; +- here it simply means the admin web panel in a browser. + +`chi` + +- a small and fast HTTP router for Go; +- this is not `CI`; +- it helps map routes like `/api/v1/projects/{id}` to handlers. + +`CI` + +- continuous integration; +- automated checks that run on commits, for example tests, linting, or building Docker images. + +`server-rendered HTML` + +- the Go server itself generates HTML pages and returns them to the browser; +- this keeps the admin panel simpler than building a separate frontend app. + +## 3. Architecture proposal + +Suggested project structure: + +```text +/cmd/server +/internal/app +/internal/http +/internal/auth +/internal/projects +/internal/releases +/internal/apikeys +/internal/users +/internal/tags +/internal/storage +/internal/db +/internal/config +/web/templates +/web/static +/migrations +/data +``` + +### Main modules + +`projects` + +- create/update/list/archive projects +- manage project tags + +`releases` + +- upload artifact +- compute checksum +- store metadata +- provide release lookup and download + +`apikeys` + +- generate secure random API keys +- hash and persist +- enforce permissions and project scope + +`tags` + +- CRUD for tags +- attach tags to projects +- resolve tag-based access rules + +`users` + +- admin authentication +- password hashing +- role checks + +`storage` + +- local filesystem storage abstraction +- future option to swap to S3-compatible storage + +## 4. Suggested data model + +Core tables: + +- `users` +- `projects` +- `tags` +- `project_tags` +- `releases` +- `api_keys` +- `api_key_project_access` +- `api_key_tag_access` +- `sessions` if server-side sessions are used +- optionally `audit_logs` + +### Example relations + +- one `project` has many `releases` +- one `user` uploads many `releases` +- one `project` has many tags through `project_tags` +- one `api_key` can reference many projects through `api_key_project_access` +- one `api_key` can reference many tags through `api_key_tag_access` + +Suggested key columns in `api_keys`: + +- `scope_mode` with values: + - `all_projects` + - `project_allow_list` + - `project_deny_list` + - `tag_allow_list` + - `tag_deny_list` + +## 5. Permission strategy + +Recommended first implementation: + +- API key has boolean flags: + - `can_download` + - `can_upload` + - `can_delete` + - `can_manage_projects` +- API key has access mode: + - `all_projects` + - `project_allow_list` + - `project_deny_list` + - `tag_allow_list` + - `tag_deny_list` +- linked projects stored in `api_key_project_access` +- linked tags stored in `api_key_tag_access` + +Recommended evaluation rules: + +- `all_projects`: allow every active project +- `project_allow_list`: allow only linked projects +- `project_deny_list`: allow every active project except linked projects +- `tag_allow_list`: allow projects matching at least one linked tag +- `tag_deny_list`: allow every active project except projects matching blocked tags + +Recommended constraint for v1: + +- each key uses exactly one scope mode at a time. + +This supports whitelist, blacklist, and future grouping without building a full RBAC engine upfront. + +## 6. API design strategy + +Split API into two logical areas: + +### Client API + +Used by application clients: + +- authenticate by bearer API key +- list accessible projects +- query latest release +- download release artifact + +### Admin API + +Used by web UI and optionally automation: + +- manage projects +- manage tags +- manage releases +- manage API keys +- manage users + +Keep both under `/api/v1`, but separate middleware and handlers clearly. + +## 7. File upload strategy + +Recommended upload flow: + +1. admin uploads file; +2. backend stores temporary stream; +3. checksum is calculated; +4. file is moved into final artifact path; +5. release metadata is inserted into DB; +6. transaction/state is finalized. + +Important safeguards: + +- limit file size; +- sanitize filenames; +- prevent path traversal; +- avoid duplicate version collisions unless explicitly replacing; +- validate project exists before storing; +- store uploads outside any static web root; +- never execute or unpack uploaded files. + +## 8. Security checklist + +- hash passwords with `bcrypt` or `argon2id` +- hash API keys before storing +- show full API key only once +- secure cookies with `HttpOnly`, `Secure`, `SameSite` +- CSRF protection for admin forms +- upload size limits +- permission checks on every API endpoint +- request logging without leaking secrets +- rate limiting for login and API key endpoints +- store artifacts outside the web root +- serve downloads only after auth and permission checks +- set server timeouts: read, write, idle, header +- add security headers such as `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options` +- terminate TLS at Caddy or Nginx +- optionally restrict admin UI by IP allow-list or VPN +- avoid shelling out to external tools for file processing +- keep dependencies updated and scan them periodically + +## 9. Internet-facing deployment posture + +If the server will be visible on the internet, this is the safe baseline: + +- app listens only behind a reverse proxy; +- reverse proxy handles HTTPS certificates; +- admin UI uses strong password and optionally IP allow-list; +- SQLite file and artifacts live on a mounted persistent volume; +- no public directory listing for artifacts; +- uploads never become directly executable server-side content; +- logs and backups are stored separately from app code. + +## 10. Development strategy + +Recommended approach: + +- develop locally on your Mac first; +- keep everything in one project directory; +- run the Go server natively for fast iteration; +- add Docker packaging once the core flows work locally; +- deploy to Proxmox only after the first end-to-end flow is stable. + +Why local-first is better here: + +- faster feedback loop; +- easier debugging; +- easier file upload testing; +- no need to fight remote networking while core logic is still changing. + +Good compromise: + +- write code locally on Mac; +- keep deployment target from day one in mind; +- periodically verify that the Docker image still builds. + +## 11. Deployment strategy + +Single container is realistic. + +Recommended runtime layout: + +- app binary in container +- reverse proxy in front of container +- mounted `/data` volume +- SQLite DB in `/data/db.sqlite` +- artifacts in `/data/artifacts` + +### Environment variables + +- `APP_ADDR` +- `APP_BASE_URL` +- `DATA_DIR` +- `SQLITE_PATH` +- `ADMIN_EMAIL` +- `ADMIN_PASSWORD` +- `SESSION_SECRET` + +Recommended deployment target: + +- Proxmox VM or LXC with Docker or Podman; +- app container plus reverse proxy; +- mounted volume for `/data`; +- regular backup of `/data`. + +## 12. Delivery phases + +### Phase 1 - Skeleton + +- initialize Go module +- wire config +- HTTP server +- health endpoint +- migrations setup +- SQLite connection +- basic template rendering + +### Phase 2 - Auth and admin bootstrap + +- user table +- create initial admin user +- login/logout +- session middleware + +### Phase 3 - Projects and releases + +- projects CRUD +- project tags CRUD +- release upload +- release list/detail +- local artifact storage + +### Phase 4 - API keys and access control + +- API key generation +- hashed storage +- project white-list and black-list +- tag-based schema and access checks +- permission middleware + +### Phase 5 - Client update API + +- latest release endpoint +- release metadata endpoint +- download endpoint +- audit of last key usage + +### Phase 6 - Hardening + +- validation +- CSRF +- logging +- soft deletes +- basic audit logs +- reverse proxy config +- rate limiting +- Docker image and compose setup + +## 13. Agent execution plan + +If multiple AI agents or contributors will implement this, split work by vertical ownership. + +### Agent 1 - Core platform + +Owns: + +- Go module setup +- config +- server bootstrap +- middleware skeleton +- Docker setup + +### Agent 2 - Data layer + +Owns: + +- migrations +- schema +- repositories/queries +- SQLite integration +- tags and access-rule tables + +### Agent 3 - Auth and users + +Owns: + +- admin login +- sessions +- password handling +- user management basics + +### Agent 4 - Projects and releases + +Owns: + +- project CRUD +- tag assignment to projects +- release upload flow +- artifact storage abstraction +- checksum logic + +### Agent 5 - API keys and client API + +Owns: + +- API key generation +- permission model +- whitelist/blacklist evaluation +- tag-based access evaluation +- client-facing release lookup and download endpoints + +### Agent 6 - Admin UI + +Owns: + +- HTML templates +- forms +- tables/pages for projects, releases, keys, users, tags + +### Agent 7 - Security and deployment hardening + +Owns: + +- secure headers +- rate limiting +- reverse proxy templates +- deployment hardening review + +## 14. Recommended implementation order for agents + +1. Agent 1 sets up application skeleton and app wiring. +2. Agent 2 defines schema and migrations. +3. Agent 3 implements admin auth. +4. Agent 4 implements projects, tags, and release storage. +5. Agent 5 implements API key model and client endpoints. +6. Agent 6 builds the admin UI on top of completed flows. +7. Agent 7 hardens internet-facing deployment paths. +8. Final pass integrates validation, tests, and Docker packaging. + +## 15. Testing strategy + +### Unit tests + +- version selection logic +- permission checks +- API key hashing/lookup +- checksum generation +- tag scope matching + +### Integration tests + +- login flow +- create project +- attach tags to project +- upload release +- create API key +- fetch latest release via API key +- reject unauthorized project access +- reject blacklisted project or tag access + +### Manual sanity tests + +- upload file from admin UI +- download via curl using bearer API key +- restart container and verify persistence +- verify admin login rate limits +- verify artifacts are not directly exposed by URL guessing + +## 16. My recommendations on product scope + +To avoid overbuilding too early, I would start with: + +- SQLite +- local filesystem storage +- server-rendered admin UI +- one file per release +- one admin role first, but schema ready for more users +- one scope mode per key +- project whitelist and blacklist in v1 +- project tags in schema from day one + +This version will already be useful and can stay very small operationally. + +## 17. Nice first concrete milestone + +The first milestone should be: + +"Admin can log in, create a tagged project, upload a versioned file, create an API key with project or tag-based access, and a client can download the latest file using that key." + +If that works end-to-end, the rest can be layered on safely. diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..fc55d4e --- /dev/null +++ b/Justfile @@ -0,0 +1,25 @@ +set shell := ["zsh", "-cu"] + +default: + @just --list + +run: + go run ./cmd/server + +migrate: + go run ./cmd/migrate + +build: + mkdir -p .bin + go build -o .bin/update-server ./cmd/server + +test: + go test ./... + +fmt: + gofmt -w ./cmd ./internal + +tidy: + go mod tidy + +check: fmt test build diff --git a/PRODUCT_SPEC.md b/PRODUCT_SPEC.md new file mode 100644 index 0000000..c291747 --- /dev/null +++ b/PRODUCT_SPEC.md @@ -0,0 +1,486 @@ +# Update Server - Product Specification + +## 1. Goal + +Build a small self-hosted update server for distributing application updates. + +The system should allow an administrator to: + +- create projects; +- upload update files for those projects; +- manage client API keys; +- configure which API keys can access which projects and which actions are allowed; +- group projects with tags; +- provide a simple web UI for administration; +- expose an HTTP API for client applications to check, fetch, and manage updates. + +Primary implementation language: Go. + +## 2. Product idea in one sentence + +An admin uploads versioned update artifacts into projects, and client applications use API keys to securely discover and download only the updates they are allowed to access. + +## 3. Main entities + +### 3.1 Project + +A project represents one distributable application, file, package, or update stream. + +Suggested fields: + +- `id` +- `name` +- `slug` +- `description` +- `created_at` +- `updated_at` +- `is_active` + +Notes: + +- In MVP, one project can simply correspond to one app or one downloadable file family. +- Later this can be extended with channels like `stable`, `beta`, `nightly`. +- Projects should support tags from the start. + +### 3.2 Release / Update + +A release is an uploaded update artifact belonging to a project. + +Suggested fields: + +- `id` +- `project_id` +- `version` +- `build` +- `filename` +- `storage_path` +- `checksum_sha256` +- `size_bytes` +- `content_type` +- `release_notes` +- `created_at` +- `uploaded_by_user_id` +- `is_active` + +Optional future fields: + +- `channel` +- `min_client_version` +- `rollout_percent` +- `metadata_json` + +### 3.3 API Key + +An API key is used by client software to authenticate against the update API. + +Suggested fields: + +- `id` +- `name` +- `key_prefix` +- `key_hash` +- `description` +- `scope_mode` +- `is_active` +- `expires_at` +- `created_at` +- `updated_at` +- `last_used_at` + +Security note: + +- store only a hash of the full API key, never the raw key; +- show the full key only once when created. + +### 3.4 User + +At least one admin user is needed for the web UI. + +Suggested fields: + +- `id` +- `email` or `login` +- `password_hash` +- `role` +- `is_active` +- `created_at` +- `updated_at` +- `last_login_at` + +MVP note: + +- start with one admin user; +- design schema so multiple users can be supported without a major rewrite. + +### 3.5 Tag + +A tag is a reusable label attached to projects and later used in access rules. + +Examples: + +- `windows` +- `beta` +- `internal` +- `customer-acme` +- `desktop` + +Suggested fields: + +- `id` +- `name` +- `slug` +- `description` +- `created_at` + +Relations: + +- one project can have many tags; +- one tag can be attached to many projects; +- API keys can reference tags in access rules. + +## 4. Access control model + +Each API key should have both action permissions and project scope. + +### 4.1 Action permissions + +Suggested permissions: + +- `updates.read` - check available updates and download files +- `updates.upload` - upload releases +- `updates.delete` - delete releases +- `projects.read` - list accessible projects +- `projects.write` - create or edit projects +- `admin.read` - view admin-level metadata +- `admin.write` - manage API keys and access rules + +For MVP, this can be simplified to: + +- `can_download` +- `can_upload` +- `can_delete` +- `can_manage_projects` + +### 4.2 Scope strategies + +Each API key should support one access scope strategy: + +- access to all projects; +- project white list; +- project black list; +- tag white list; +- tag black list. + +Recommended v1 model: + +- mode `all_projects` +- mode `project_allow_list` +- mode `project_deny_list` +- mode `tag_allow_list` +- mode `tag_deny_list` + +Why: + +- this supports both explicit project lists and future tag-based grouping; +- it avoids the complexity of mixing many overlapping rule types in one key; +- it keeps evaluation logic predictable and safer. + +### 4.3 Access evaluation + +Recommended effective access logic: + +- `all_projects`: key can access all active projects; +- `project_allow_list`: key can access only linked projects; +- `project_deny_list`: key can access all active projects except linked projects; +- `tag_allow_list`: key can access projects that have at least one linked tag; +- `tag_deny_list`: key can access all active projects except projects that have one of the blocked tags. + +Recommended v1 restriction: + +- one API key uses exactly one scope strategy at a time. + +This keeps the admin UI and permission checks much simpler. + +## 5. Admin web UI + +### 5.1 Pages for MVP + +1. Login page +2. Dashboard +3. Projects list +4. Create/edit project page +5. Project detail page +6. Release upload page or upload block inside project detail +7. Tags list +8. Create/edit tag page +9. API keys list +10. Create API key page +11. API key detail/edit page +12. Users page or at least admin bootstrap settings page + +### 5.2 Minimal UI capabilities + +Projects: + +- create project; +- edit project; +- archive or disable project; +- view releases for a project; +- upload new release; +- delete or disable release; +- attach and detach tags. + +API keys: + +- generate key; +- disable key; +- set expiration; +- select scope strategy; +- assign project white list or black list; +- assign tag white list or black list; +- assign action permissions; +- inspect last usage. + +Tags: + +- create tag; +- edit tag; +- delete unused tag; +- attach tags to projects; +- use tags in API key access rules. + +Users: + +- login/logout; +- change password; +- optionally create another admin/editor later. + +## 6. Client API + +The API should support both admin operations and client update retrieval. + +### 6.1 Auth model + +- web UI uses session or secure cookie auth; +- external clients use API key auth via header, for example: + - `Authorization: Bearer ` + - or `X-API-Key: ` + +Recommendation: + +- use `Authorization: Bearer ` for external API consistency. + +### 6.2 Suggested client endpoints + +#### Check accessible projects + +- `GET /api/v1/projects` + +Returns only projects accessible to the API key. + +#### Check latest release for a project + +- `GET /api/v1/projects/{projectSlug}/releases/latest` + +Possible query parameters: + +- `current_version` +- `channel` +- `platform` +- `arch` + +#### List releases for a project + +- `GET /api/v1/projects/{projectSlug}/releases` + +#### Download release file + +- `GET /api/v1/releases/{releaseID}/download` + +Alternative: + +- `GET /api/v1/projects/{projectSlug}/releases/{version}/download` + +#### Get release metadata + +- `GET /api/v1/releases/{releaseID}` + +### 6.3 Suggested admin endpoints + +- `POST /api/v1/admin/projects` +- `PATCH /api/v1/admin/projects/{id}` +- `DELETE /api/v1/admin/projects/{id}` +- `GET /api/v1/admin/tags` +- `POST /api/v1/admin/tags` +- `PATCH /api/v1/admin/tags/{id}` +- `DELETE /api/v1/admin/tags/{id}` +- `POST /api/v1/admin/projects/{id}/releases` +- `DELETE /api/v1/admin/releases/{id}` +- `GET /api/v1/admin/api-keys` +- `POST /api/v1/admin/api-keys` +- `PATCH /api/v1/admin/api-keys/{id}` +- `DELETE /api/v1/admin/api-keys/{id}` +- `GET /api/v1/admin/users` +- `POST /api/v1/admin/users` + +## 7. Storage + +### 7.1 Database recommendation + +Recommended for this project: `SQLite`. + +Reasoning: + +- single file database; +- easy to ship inside one container; +- good fit for low-to-medium admin traffic; +- excellent support in Go; +- no need to run a separate DB service. + +Important clarification: + +- `SQLite` is likely what was meant by "MySQL Lite"; +- `MySQL` itself is a separate server database and would add complexity. + +### 7.2 File storage + +Store uploaded release files on local disk in the container or in a mounted volume. + +Suggested layout: + +- `/data/db.sqlite` +- `/data/artifacts/{project_slug}/{version}/{filename}` + +Important: + +- use a mounted persistent volume in deployment, not only container-local ephemeral storage; +- keep uploaded files outside any public static web root; +- downloads should be authorized by the app, not exposed as open file URLs. + +## 8. Roles and authentication + +### 8.1 Web users + +Suggested roles: + +- `admin` - full access +- `editor` - can upload and manage projects, but not system settings +- `viewer` - read-only, optional future role + +MVP: + +- implement `admin` first; +- keep schema ready for extra roles later. + +### 8.2 Initial bootstrap + +At first startup: + +- create admin user from environment variables; +- or provide a one-time bootstrap CLI command. + +Recommended env vars: + +- `ADMIN_EMAIL` +- `ADMIN_PASSWORD` + +## 9. Security requirements + +Because this server may be reachable from the public internet, security is a first-class requirement. + +Core requirements: + +- every admin page must require authenticated session access; +- every API endpoint must enforce permission checks; +- uploaded files must never be executed or unpacked on the server; +- artifacts must be stored outside the public web root; +- downloads must go through authorization checks, not open static directories; +- passwords must be hashed with a modern password hash; +- API keys must be hashed and shown only once; +- rate limiting should protect login and API key endpoints; +- request size limits and timeouts must be enabled; +- CSRF protection must be enabled for admin forms; +- path traversal and arbitrary file overwrite must be prevented; +- audit logs should record login attempts, uploads, deletions, and key usage; +- TLS should be terminated by a reverse proxy such as Caddy or Nginx; +- security headers should be enabled; +- dependencies should be kept updated. + +Recommended additional protection: + +- optionally restrict admin UI by IP allow-list or VPN; +- disable deleted or compromised API keys immediately; +- back up both SQLite DB and artifacts; +- never process uploaded files with shell commands; +- keep secrets in environment variables, not in repository files. + +## 10. Non-functional requirements + +- easy local development; +- deployable as one container; +- persistent storage via volume mount; +- secure API key handling; +- auditability of uploads and key usage; +- basic request logging; +- simple backup strategy for DB and artifacts. + +## 11. MVP scope + +The first usable version should include: + +- admin login; +- project CRUD; +- project tags; +- release upload; +- release listing; +- API key generation and revocation; +- API key white-list or black-list by project; +- schema support for tag-based access rules; +- API endpoint to fetch latest release metadata; +- API endpoint to download release; +- SQLite database; +- local disk artifact storage; +- Docker deployment; +- reverse-proxy-ready setup for HTTPS. + +## 12. Post-MVP ideas + +- release channels (`stable`, `beta`); +- semantic version comparison; +- delta updates / patch files; +- signed manifests; +- download tokens with short TTL; +- release publishing workflow as draft/published; +- webhook notifications; +- S3-compatible artifact storage; +- rate limiting; +- audit log UI; +- per-platform binaries; +- automatic cleanup rules; +- client usage analytics. + +## 13. Open product decisions + +These should be decided before implementation starts: + +1. Should clients always download the latest version, or should they first request metadata and decide themselves? +2. Do releases need channels like `stable` and `beta` from day one? +3. Should upload via API key be allowed in MVP, or only through the web admin UI? +4. Are deleted releases truly removed, or just soft-deleted/hidden? +5. Should one project contain only one file per version, or potentially multiple files per release? +6. Do clients need platform-specific assets like Windows/Linux/macOS builds? +7. Should tag-based access be fully active in v1, or should the schema/UI be prepared first and enforcement added in v1.1? + +## 14. Recommended simplifications for v1 + +To keep the first version small and reliable: + +- one artifact file per release; +- one main admin user; +- SQLite only; +- local filesystem storage only; +- one scope strategy per API key; +- project white-list and black-list in v1; +- tags supported in schema and project UI from day one; +- session auth for web UI, API key auth for clients; +- soft delete for projects/releases/API keys when possible. diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..bd45038 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "os" + + "update_server/internal/config" + "update_server/internal/db" +) + +func main() { + cfg, err := config.Load() + if err != nil { + exitWithError(err) + } + + database, err := db.Open(context.Background(), cfg.SQLitePath) + if err != nil { + exitWithError(err) + } + defer database.Close() + + if err := db.Migrate(context.Background(), database, cfg.MigrationsDir); err != nil { + exitWithError(err) + } + + fmt.Fprintf(os.Stdout, "migrations applied successfully to %s\n", cfg.SQLitePath) +} + +func exitWithError(err error) { + fmt.Fprintf(os.Stderr, "update_server migrate: %v\n", err) + os.Exit(1) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..eed98f0 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + + "update_server/internal/app" + "update_server/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + exitWithError(err) + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) + + application, err := app.New(cfg, logger) + if err != nil { + exitWithError(err) + } + + if err := application.Run(context.Background()); err != nil && !errors.Is(err, http.ErrServerClosed) { + exitWithError(err) + } +} + +func exitWithError(err error) { + fmt.Fprintf(os.Stderr, "update_server: %v\n", err) + os.Exit(1) +} diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 0000000..c442539 --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,23 @@ +updates.example.com { + encode gzip zstd + + request_body { + max_size 1GB + } + + # Optional: restrict the admin UI to VPN or office IP ranges. + # @admin path /admin /admin/* + # handle @admin { + # @blocked not remote_ip 10.0.0.0/8 192.168.0.0/16 100.64.0.0/10 + # respond @blocked "admin access denied" 403 + # reverse_proxy 127.0.0.1:8080 + # } + + reverse_proxy 127.0.0.1:8080 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} + } +} + diff --git a/deploy/nginx.update-server.conf.example b/deploy/nginx.update-server.conf.example new file mode 100644 index 0000000..25358c1 --- /dev/null +++ b/deploy/nginx.update-server.conf.example @@ -0,0 +1,42 @@ +server { + listen 80; + listen [::]:80; + server_name updates.example.com; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name updates.example.com; + + ssl_certificate /etc/letsencrypt/live/updates.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/updates.example.com/privkey.pem; + + client_max_body_size 1g; + proxy_read_timeout 65s; + proxy_send_timeout 65s; + + # Optional: restrict the admin UI to VPN or office IP ranges. + # location /admin/ { + # allow 10.0.0.0/8; + # allow 192.168.0.0/16; + # allow 100.64.0.0/10; + # deny all; + # + # proxy_pass http://127.0.0.1:8080; + # include /etc/nginx/snippets/update-server-proxy.conf; + # } + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + } +} + diff --git a/deploy/update-server.env.example b/deploy/update-server.env.example new file mode 100644 index 0000000..b2cb1f1 --- /dev/null +++ b/deploy/update-server.env.example @@ -0,0 +1,17 @@ +APP_BASE_URL=https://updates.example.com +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-this-before-production +APP_LOG_LEVEL=INFO + +# The app stores session tokens in SQLite, so there is no separate SESSION_SECRET +# in the current architecture. +APP_READ_TIMEOUT=30s +APP_READ_HEADER_TIMEOUT=5s +APP_WRITE_TIMEOUT=60s +APP_IDLE_TIMEOUT=120s +APP_MAX_HEADER_BYTES=1048576 +APP_LOGIN_RATE_LIMIT_PER_MINUTE=10 +APP_LOGIN_RATE_LIMIT_BURST=5 +APP_CLIENT_RATE_LIMIT_PER_MINUTE=120 +APP_CLIENT_RATE_LIMIT_BURST=60 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..635a967 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + app: + build: + context: . + container_name: update-server + image: update-server:latest + restart: unless-stopped + init: true + ports: + - "127.0.0.1:8080:8080" + environment: + APP_ADDR: "0.0.0.0:8080" + APP_BASE_URL: "${APP_BASE_URL:?set APP_BASE_URL to the public HTTPS URL}" + DATA_DIR: "/data" + SQLITE_PATH: "/data/db.sqlite" + ARTIFACTS_DIR: "/data/artifacts" + MIGRATIONS_DIR: "/app/migrations" + TEMPLATES_DIR: "/app/web/templates" + STATIC_DIR: "/app/web/static" + TRUST_PROXY_HEADERS: "true" + ADMIN_EMAIL: "${ADMIN_EMAIL:?set ADMIN_EMAIL}" + ADMIN_PASSWORD: "${ADMIN_PASSWORD:?set ADMIN_PASSWORD}" + APP_LOG_LEVEL: "${APP_LOG_LEVEL:-INFO}" + APP_READ_TIMEOUT: "${APP_READ_TIMEOUT:-30s}" + APP_READ_HEADER_TIMEOUT: "${APP_READ_HEADER_TIMEOUT:-5s}" + APP_WRITE_TIMEOUT: "${APP_WRITE_TIMEOUT:-60s}" + APP_IDLE_TIMEOUT: "${APP_IDLE_TIMEOUT:-120s}" + APP_MAX_HEADER_BYTES: "${APP_MAX_HEADER_BYTES:-1048576}" + APP_LOGIN_RATE_LIMIT_PER_MINUTE: "${APP_LOGIN_RATE_LIMIT_PER_MINUTE:-10}" + APP_LOGIN_RATE_LIMIT_BURST: "${APP_LOGIN_RATE_LIMIT_BURST:-5}" + APP_CLIENT_RATE_LIMIT_PER_MINUTE: "${APP_CLIENT_RATE_LIMIT_PER_MINUTE:-120}" + APP_CLIENT_RATE_LIMIT_BURST: "${APP_CLIENT_RATE_LIMIT_BURST:-60}" + volumes: + - update-server-data:/data + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + update-server-data: + diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..6c5a3da --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,160 @@ +# Deployment Notes + +## Runtime shape + +Recommended production layout: + +- run the Go app as a single container; +- bind the app container only to `127.0.0.1:8080`; +- terminate HTTPS in front of it with Caddy or Nginx; +- persist `/data` so both SQLite and artifacts survive restarts; +- expose only the reverse proxy to the internet. + +This repository now includes: + +- `Dockerfile` +- `docker-compose.yml` +- `deploy/Caddyfile.example` +- `deploy/nginx.update-server.conf.example` +- `deploy/update-server.env.example` + +Important deployment assumptions: + +- set `APP_BASE_URL` to the public `https://...` URL; +- leave `TRUST_PROXY_HEADERS=true` only when the app is actually behind a trusted reverse proxy; +- do not mount `/data` into a public web root; +- do not serve artifacts directly from Caddy or Nginx. Downloads must continue flowing through `/api/v1/releases/{id}/download`. + +## Data layout + +Persist the whole `/data` mount. + +Expected contents: + +- `/data/db.sqlite` +- `/data/db.sqlite-wal` +- `/data/db.sqlite-shm` +- `/data/artifacts/...` + +The app uses SQLite in WAL mode, so cold file copies must include `db.sqlite-wal` and `db.sqlite-shm` unless you use an online SQLite backup. + +## Docker Compose + +1. Copy `deploy/update-server.env.example` to a private env file or export the variables in your shell. +2. Set at least: + - `APP_BASE_URL` + - `ADMIN_EMAIL` + - `ADMIN_PASSWORD` +3. Start the app: + +```bash +docker compose up -d --build +``` + +Security defaults in the provided compose file: + +- non-root container user; +- read-only root filesystem; +- writable `/data` volume only; +- writable `tmpfs` at `/tmp` for multipart form handling; +- `no-new-privileges`; +- all Linux capabilities dropped; +- app port bound only to loopback. + +The current implementation uses database-backed random session tokens, so there is no separate `SESSION_SECRET` environment variable to set. + +## Reverse proxy + +Use one of the example proxy configs and keep the app on loopback. + +Caddy: + +- example file: `deploy/Caddyfile.example` +- automatic HTTPS is the simplest option for first rollout. + +Nginx: + +- example file: `deploy/nginx.update-server.conf.example` +- remember to provision certificates separately. + +Recommended proxy behavior: + +- pass `Host`, `X-Forwarded-For`, `X-Forwarded-Host`, and `X-Forwarded-Proto`; +- keep `client_max_body_size` or equivalent aligned with `MAX_UPLOAD_BYTES`; +- optionally IP-allow-list `/admin` if the admin UI is only for operators; +- do not add any direct `/artifacts` static mapping. + +## Proxmox notes + +Safe first production rollout on Proxmox: + +1. Use a VM or an unprivileged LXC dedicated to this service. +2. Put persistent app data on a host path such as `/srv/update-server/data`. +3. Put backups on a different filesystem or datastore such as `/srv/update-server/backups`. +4. Run the app container on the guest and keep it bound to `127.0.0.1:8080`. +5. Run Caddy or Nginx on the same guest or on a separate reverse-proxy guest. +6. Expose only ports `80` and `443` publicly. +7. Keep `APP_BASE_URL` on the final public HTTPS hostname before testing cookies or HSTS. + +Operational advice for Proxmox: + +- snapshots are useful, but they are not a replacement for app-aware backups; +- if you use LXC, make sure the mounted `/data` path is writable by the container user; +- keep the reverse proxy and app logs outside the repo checkout; +- test one admin login, one upload, and one authenticated download after each upgrade. + +## Backup + +Preferred live-backup flow: + +1. Use SQLite's online backup command so you get a consistent `db.sqlite` snapshot without stopping the app. +2. Back up `/data/artifacts` separately. + +Example with Docker: + +```bash +backup_dir=/srv/update-server/backups/$(date +%Y%m%d-%H%M%S) +mkdir -p "${backup_dir}" + +docker exec update-server sqlite3 /data/db.sqlite ".backup '/tmp/db.sqlite'" +docker cp update-server:/tmp/db.sqlite "${backup_dir}/db.sqlite" +docker exec update-server rm -f /tmp/db.sqlite +tar -C /srv/update-server/data -czf "${backup_dir}/artifacts.tar.gz" artifacts +``` + +If you take a cold backup instead: + +1. stop the container; +2. copy `/data/db.sqlite`, `/data/db.sqlite-wal`, `/data/db.sqlite-shm`, and `/data/artifacts`; +3. start the container again. + +## Restore + +Restore procedure: + +1. stop the app container; +2. restore `db.sqlite`; +3. if the restored database came from SQLite `.backup`, remove any stale `db.sqlite-wal` and `db.sqlite-shm` files before starting; +4. restore `/data/artifacts`; +5. start the container; +6. verify `GET /healthz`, admin login, and at least one authenticated client download. + +Example: + +```bash +docker compose stop app +cp /srv/update-server/backups/20260415-120000/db.sqlite /srv/update-server/data/db.sqlite +rm -f /srv/update-server/data/db.sqlite-wal /srv/update-server/data/db.sqlite-shm +tar -C /srv/update-server/data -xzf /srv/update-server/backups/20260415-120000/artifacts.tar.gz +docker compose up -d app +``` + +## Post-deploy checklist + +- `GET /healthz` returns `200`; +- admin login works through HTTPS; +- `Set-Cookie` on `/admin/login` includes `Secure`, `HttpOnly`, and the `/admin` path; +- one release upload succeeds; +- one bearer-authenticated `/api/v1/projects` request succeeds; +- one `/api/v1/releases/{id}/download` request succeeds through the proxy; +- backups complete and can be restored on a staging copy. diff --git a/docs/agents/HANDOFF_TEMPLATE.md b/docs/agents/HANDOFF_TEMPLATE.md new file mode 100644 index 0000000..750548a --- /dev/null +++ b/docs/agents/HANDOFF_TEMPLATE.md @@ -0,0 +1,41 @@ +# Agent Handoff Template + +## Agent + +Name: + +Stage: + +Date: + +## Scope + +Describe the exact work completed in this stage. + +## Files Changed + +List every important file changed. + +## Database Changes + +List migrations, schema changes, or database assumptions. + +## API Or Route Changes + +List endpoints, handlers, middleware, or route groups added or changed. + +## Commands And Tests Run + +List the commands executed and summarize the result. + +## Known Limitations + +List anything incomplete, risky, or intentionally deferred. + +## Recommended Next Step + +Explain what the next agent should do first. + +## Notes For Validator + +Mention anything that deserves extra attention during validation. diff --git a/docs/agents/OPERATOR_QUICKSTART_RU.md b/docs/agents/OPERATOR_QUICKSTART_RU.md new file mode 100644 index 0000000..32ae07c --- /dev/null +++ b/docs/agents/OPERATOR_QUICKSTART_RU.md @@ -0,0 +1,174 @@ +# Operator Quickstart + +## Куда смотреть в первую очередь + +Тебе для работы нужны только эти файлы: + +1. `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +2. `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +3. `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +4. `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` + +Перед каждым новым агентом дополнительно смотри: + +- последний файл из `/Users/delete/projects/update_server/docs/agents/handoffs` +- последний файл из `/Users/delete/projects/update_server/docs/agents/validation` + +## Что запускать по порядку + +Всегда идёшь по одной и той же схеме: + +1. запускаешь implementation agent; +2. ждёшь, пока он закончит код и создаст handoff; +3. запускаешь validator; +4. если `APPROVED`, переходишь к следующему агенту; +5. если `CHANGES_REQUIRED`, заново запускаешь того же агента с его handoff и validation report. + +Порядок агентов смотри в: + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` + +## Базовый промт для implementation agent + +Копируй этот промт и меняй только название агента и секцию. + +```text +You are . + +Read these files first: +- /Users/delete/projects/update_server/docs/agents/WORKFLOW.md +- /Users/delete/projects/update_server/PRODUCT_SPEC.md +- /Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md +- /Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md + +Then read: +- the section for "" in WORKFLOW.md +- the latest file in /Users/delete/projects/update_server/docs/agents/handoffs if it exists +- the latest file in /Users/delete/projects/update_server/docs/agents/validation if it exists + +Follow the rules and fixed decisions exactly. +Work only within your assigned scope. +Do not redesign unrelated parts. +After implementation: +- create or update code +- run relevant checks if possible +- create a handoff file in /Users/delete/projects/update_server/docs/agents/handoffs using /Users/delete/projects/update_server/docs/agents/HANDOFF_TEMPLATE.md + +In your final response: +- summarize what you changed +- list files changed +- list checks run +- mention limitations +- mention the exact next recommended step +``` + +## Базовый промт для validator + +```text +You are the validation agent. + +Read these files first: +- /Users/delete/projects/update_server/docs/agents/WORKFLOW.md +- /Users/delete/projects/update_server/PRODUCT_SPEC.md +- /Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md +- /Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md + +Then read: +- the section for "" in WORKFLOW.md +- the latest file in /Users/delete/projects/update_server/docs/agents/handoffs + +Review the code produced for that stage. +Run relevant tests or checks if possible. +Look for: +- missing scope items +- regressions +- spec mismatches +- unsafe behavior +- missing tests + +Create a validation report in /Users/delete/projects/update_server/docs/agents/validation using /Users/delete/projects/update_server/docs/agents/VALIDATION_TEMPLATE.md + +Finish with exactly one status: +- APPROVED +- or CHANGES_REQUIRED +``` + +## Базовый промт для доработки после замечаний + +Если валидатор вернул `CHANGES_REQUIRED`, используй такой промт для того же агента: + +```text +You are continuing the same stage. + +Read these files first: +- /Users/delete/projects/update_server/docs/agents/WORKFLOW.md +- /Users/delete/projects/update_server/PRODUCT_SPEC.md +- /Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md +- /Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md + +Then read: +- your stage section in WORKFLOW.md +- your last handoff file +- the latest validation report in /Users/delete/projects/update_server/docs/agents/validation + +Fix all blocking issues from the validation report. +Do not start the next stage. +Stay within your original scope unless a fix strictly requires a small adjacent change. + +After fixes: +- update the code +- update or create a new handoff file +- summarize exactly which validation findings were fixed +``` + +## Что поставить через Homebrew + +### Минимум для локальной разработки + +```bash +brew install go sqlite goose just air +``` + +Что это даёт: + +- `go` — сам Go-компилятор и `go mod` +- `sqlite` — локальная SQLite CLI, удобно смотреть базу руками +- `goose` — миграции базы +- `just` — удобный runner для коротких команд проекта +- `air` — live reload для локальной разработки Go-сервера + +### Опционально, но очень полезно + +```bash +brew install caddy +``` + +`caddy` пригодится позже для локального прогона reverse proxy и HTTPS-подобной схемы. + +### Если захочешь локально проверять контейнеры + +```bash +brew install --cask docker-desktop +``` + +Если Docker Desktop тебе не нравится, можно потом выбрать более лёгкую схему с `colima`, но для старта это не обязательно. + +## Что ещё желательно иметь + +Это не Homebrew-пакет проекта, но на Mac очень желательно: + +```bash +xcode-select --install +``` + +Это ставит Command Line Tools и часто экономит кучу времени при сборке Go-зависимостей. + +## Самый короткий практический сценарий + +1. ставишь `go sqlite goose just air`; +2. открываешь `docs/agents/WORKFLOW.md`; +3. копируешь базовый промт для `Agent 01 - Foundation`; +4. запускаешь агента; +5. проверяешь, что он создал handoff; +6. копируешь промт для validator; +7. после `APPROVED` переходишь к следующему агенту. diff --git a/docs/agents/VALIDATION_TEMPLATE.md b/docs/agents/VALIDATION_TEMPLATE.md new file mode 100644 index 0000000..0ce9961 --- /dev/null +++ b/docs/agents/VALIDATION_TEMPLATE.md @@ -0,0 +1,29 @@ +# Validation Report Template + +## Validator + +Name: + +Stage Reviewed: + +Date: + +## Reviewed Inputs + +List the files, handoffs, diffs, and tests reviewed. + +## Findings + +List concrete bugs, regressions, or risks. + +## Required Fixes + +List the fixes required before approval. + +## Optional Improvements + +List anything useful but non-blocking. + +## Status + +`APPROVED` or `CHANGES_REQUIRED` diff --git a/docs/agents/WORKFLOW.md b/docs/agents/WORKFLOW.md new file mode 100644 index 0000000..50612f2 --- /dev/null +++ b/docs/agents/WORKFLOW.md @@ -0,0 +1,543 @@ +# AI Agent Workflow Runbook + +## 1. Purpose + +This document defines how to implement the update server with sequential AI agents. + +The workflow is designed for: + +- one human operator who launches agents one by one; +- one implementation agent per stage; +- one validation agent after each stage; +- file-based handoff instead of direct agent-to-agent chat. + +## 2. Source Of Truth + +Every implementation agent must read these files before starting: + +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` + +Every agent must also read the latest handoff and validation files if they exist. + +## 3. Fixed Technical Decisions + +These decisions are considered fixed unless the human operator explicitly changes them. + +- Language: Go +- Router: `chi` +- Database: `SQLite` +- HTML strategy: server-rendered HTML templates +- Artifact storage: local filesystem +- Deployment shape: app container behind Caddy or Nginx +- Web auth: session-based authentication +- Client auth: `Authorization: Bearer ` +- API key access modes: + - `all_projects` + - `project_allow_list` + - `project_deny_list` + - `tag_allow_list` + - `tag_deny_list` +- One API key must use exactly one scope mode at a time +- Projects support tags from day one +- Security is mandatory, not optional + +## 4. Shared Workspace Rules + +All code lives in the main repository. + +Agents should use these implementation paths: + +- `/Users/delete/projects/update_server/cmd/server` +- `/Users/delete/projects/update_server/internal` +- `/Users/delete/projects/update_server/web` +- `/Users/delete/projects/update_server/migrations` + +Agents must use these coordination paths: + +- handoffs: `/Users/delete/projects/update_server/docs/agents/handoffs` +- validations: `/Users/delete/projects/update_server/docs/agents/validation` + +If an agent needs to communicate something to the next agent, it must do so through: + +- code changes; +- a handoff file; +- updates to spec or implementation docs if architecture changed. + +Agents should not rely on hidden memory or previous chat state. + +## 5. Handoff Protocol + +After finishing work, every implementation agent must create a handoff file: + +- naming format: `NN-agent-name.md` +- example: `01-foundation.md` + +Each handoff file should be saved in: + +- `/Users/delete/projects/update_server/docs/agents/handoffs` + +The handoff file must include: + +- scope of work; +- files changed; +- migrations added or changed; +- endpoints added or changed; +- tests or commands run; +- known limitations; +- exact next recommended step. + +Use this template: + +- `/Users/delete/projects/update_server/docs/agents/HANDOFF_TEMPLATE.md` + +## 6. Validation Protocol + +After each implementation agent finishes, run a validation agent before moving to the next stage. + +The validation agent should: + +- read the current agent section in this file; +- read the latest handoff file; +- inspect code changes; +- run relevant tests or checks; +- produce a validation report. + +Validation files should be saved in: + +- `/Users/delete/projects/update_server/docs/agents/validation` + +Use this template: + +- `/Users/delete/projects/update_server/docs/agents/VALIDATION_TEMPLATE.md` + +Validation result must end in one of these statuses: + +- `APPROVED` +- `CHANGES_REQUIRED` + +Do not launch the next implementation agent until the previous stage is `APPROVED`. + +## 7. Agent Count + +Recommended sequence: + +- 7 implementation agents +- 1 validation agent role used after every stage + +The validation agent can be the same AI system reused every time. + +## 8. Agent 01 - Foundation + +### Mission + +Set up the repository skeleton and the minimum runnable Go application. + +### Ownership + +This agent owns: + +- Go module initialization +- basic app boot +- config loading +- HTTP router setup +- health endpoint +- base middleware wiring +- template bootstrapping +- initial developer commands + +### Deliverables + +This agent must produce: + +- runnable server entrypoint +- config package +- base router with route groups +- health endpoint +- base HTML layout structure +- initial project structure under `cmd`, `internal`, and `web` + +### Out Of Scope + +This agent must not implement: + +- business logic +- database schema +- authentication flows +- release upload +- API keys + +### Acceptance Criteria + +- the app starts successfully; +- a health endpoint responds; +- the code structure matches the implementation plan; +- the project is ready for migrations and feature modules. + +## 9. Agent 02 - Database And Migrations + +### Mission + +Create the data model, migrations, and database access foundation. + +### Ownership + +This agent owns: + +- migration setup +- database connection layer +- schema design +- repository or query layer foundation + +### Deliverables + +This agent must implement tables for: + +- `users` +- `projects` +- `tags` +- `project_tags` +- `releases` +- `api_keys` +- `api_key_project_access` +- `api_key_tag_access` +- `sessions` if needed +- `audit_logs` if included in the first pass + +### Out Of Scope + +This agent must not implement: + +- login UI +- upload endpoints +- full business workflows + +### Acceptance Criteria + +- migrations can be applied cleanly; +- schema matches the spec; +- access tables support project and tag based key scopes; +- database layer is ready for the next agents. + +## 10. Agent 03 - Authentication And Admin Sessions + +### Mission + +Implement admin authentication and session management. + +### Ownership + +This agent owns: + +- admin bootstrap user creation +- password hashing +- login and logout flow +- session middleware +- role checks foundation + +### Deliverables + +This agent must produce: + +- login form and login handler +- session creation and invalidation +- protected admin route group +- admin bootstrap from environment or setup logic + +### Out Of Scope + +This agent must not implement: + +- project management +- release upload +- API key logic + +### Acceptance Criteria + +- admin can log in and log out; +- protected routes are actually protected; +- passwords are hashed securely; +- session handling works reliably. + +## 11. Agent 04 - Projects, Tags, And Releases + +### Mission + +Implement core product data management: projects, project tags, and release upload metadata flow. + +### Ownership + +This agent owns: + +- project CRUD +- tag CRUD +- attach or detach tags from projects +- release metadata handling +- artifact storage abstraction +- upload flow and checksum generation + +### Deliverables + +This agent must produce: + +- project create, edit, list, archive flow +- tag create, edit, list flow +- project-tag assignment +- release upload service +- artifact persistence on disk +- checksum capture + +### Out Of Scope + +This agent must not implement: + +- API key access logic +- external client update endpoints + +### Acceptance Criteria + +- admin can manage projects and tags; +- admin can upload a release to a project; +- release metadata is stored in the database; +- artifact files are stored outside the public web root. + +## 12. Agent 05 - API Keys And Access Control + +### Mission + +Implement API key generation, hashing, permissions, and scope evaluation. + +### Ownership + +This agent owns: + +- secure API key generation +- API key hashing and lookup +- permission flags +- access scope evaluation +- project allow or deny list rules +- tag allow or deny list rules + +### Deliverables + +This agent must produce: + +- API key creation flow +- one-time key reveal behavior +- key activation and revocation +- permission middleware for API key routes +- project and tag access resolution logic + +### Out Of Scope + +This agent must not implement: + +- final UI polish +- deployment hardening + +### Acceptance Criteria + +- API keys are stored hashed; +- only authorized projects are visible to a given key; +- allow-list and deny-list logic works for projects and tags; +- disabled or expired keys are rejected. + +## 13. Agent 06 - Client API And Admin UI + +### Mission + +Implement the usable product experience for admins and client applications. + +### Ownership + +This agent owns: + +- admin pages for projects, tags, releases, and API keys +- client-facing update endpoints +- latest-release lookup +- release metadata endpoint +- authenticated download endpoint + +### Deliverables + +This agent must produce: + +- admin pages that cover the main flows +- client endpoint to list accessible projects +- client endpoint to get latest release metadata +- client endpoint to download a release + +### Out Of Scope + +This agent must not implement: + +- deep security hardening beyond local route protection +- production reverse proxy setup + +### Acceptance Criteria + +- an admin can complete the full product flow from the browser; +- a client can authenticate with a bearer API key; +- a client can discover and download only authorized updates. + +## 14. Agent 07 - Security And Deployment Hardening + +### Mission + +Make the system safe and deployable on an internet-facing server. + +### Ownership + +This agent owns: + +- security headers +- rate limiting +- request timeouts +- CSRF finishing pass +- safer cookie settings +- reverse proxy examples +- Docker and deployment finishing pass +- backup and operational notes + +### Deliverables + +This agent must produce: + +- hardened HTTP server settings +- production-ready container setup +- Caddy or Nginx example configuration +- deployment notes for Proxmox +- security review fixes that fit the current architecture + +### Out Of Scope + +This agent must not redesign the product or data model unless a critical security issue requires it. + +### Acceptance Criteria + +- the service is ready to run behind HTTPS; +- admin and client traffic have sane security defaults; +- artifacts are not directly exposed; +- deployment instructions are complete enough for first production rollout. + +## 15. Validation Agent Role + +### Mission + +Review each completed stage before the next implementation agent starts. + +### Required Checks + +The validation agent should verify: + +- scope completion; +- obvious regressions; +- schema or route mismatches; +- missing tests; +- unsafe behavior; +- contradictions with the spec. + +### Output + +The validation agent must write one validation file per stage and finish with: + +- `APPROVED` +- or `CHANGES_REQUIRED` + +## 16. Operator Guide For Human User + +Этот раздел специально для тебя, на русском. + +### Общая схема запуска + +Запускаешь агентов строго по порядку: + +1. `Agent 01 - Foundation` +2. `Validator` +3. `Agent 02 - Database And Migrations` +4. `Validator` +5. `Agent 03 - Authentication And Admin Sessions` +6. `Validator` +7. `Agent 04 - Projects, Tags, And Releases` +8. `Validator` +9. `Agent 05 - API Keys And Access Control` +10. `Validator` +11. `Agent 06 - Client API And Admin UI` +12. `Validator` +13. `Agent 07 - Security And Deployment Hardening` +14. `Final Validator` + +### Как запускать каждый этап + +Для каждого нового агента даёшь ему один и тот же базовый контекст: + +- прочитать `PRODUCT_SPEC.md` +- прочитать `IMPLEMENTATION_PLAN.md` +- прочитать `DEVELOPMENT_WORKFLOW.md` +- прочитать `docs/agents/WORKFLOW.md` +- прочитать последний handoff-файл +- прочитать последний validation-файл, если он есть + +После этого говоришь агенту работать только в рамках его секции из этого runbook. + +### Что требовать от каждого агента + +После завершения этапа агент обязан: + +- внести код; +- если нужно, обновить документацию; +- создать handoff-файл в `docs/agents/handoffs`; +- перечислить, что сделано; +- перечислить, что не сделано; +- указать, что должен делать следующий агент. + +### Когда запускать валидатора + +Валидатора запускаешь после каждого агента. + +Ему даёшь задачу: + +- прочитать соответствующую секцию агента из `docs/agents/WORKFLOW.md`; +- прочитать свежий handoff; +- проверить код; +- проверить, не нарушена ли спецификация; +- выдать `APPROVED` или `CHANGES_REQUIRED`; +- сохранить отчёт в `docs/agents/validation`. + +### Если валидатор нашёл проблемы + +Если статус `CHANGES_REQUIRED`, то: + +1. не переходишь к следующему агенту; +2. запускаешь того же самого агента повторно; +3. даёшь ему его прошлый handoff и validation report; +4. просишь закрыть замечания; +5. снова запускаешь валидатора. + +### Когда использовать меня как валидатора + +Меня лучше использовать: + +- после каждого крупного этапа; +- перед миграциями базы; +- перед этапом security hardening; +- перед первым Docker/deploy; +- перед выкладкой на Proxmox. + +### Практически удобный режим + +Самый удобный режим для тебя такой: + +1. локально на Mac запускаешь первого агента; +2. проверяешь, что handoff-файл создался; +3. зовёшь меня как валидатора; +4. после `APPROVED` запускаешь следующего агента; +5. когда дойдёшь до конца, только потом собираешь контейнер и переносишь на Proxmox. + +### Главный принцип + +Следующий агент никогда не стартует без handoff от прошлого и без validation report. + +Это защитит тебя от хаоса, повторной работы и скрытых поломок между этапами. diff --git a/docs/agents/handoffs/01-foundation.md b/docs/agents/handoffs/01-foundation.md new file mode 100644 index 0000000..52dc320 --- /dev/null +++ b/docs/agents/handoffs/01-foundation.md @@ -0,0 +1,79 @@ +# Agent + +Name: Agent 01 - Foundation + +Stage: Foundation + +Date: 2026-04-13 + +## Scope + +Set up the initial runnable Go application skeleton for the update server. + +Completed in this stage: + +- initialized the Go module; +- added the server entrypoint and graceful app bootstrap; +- implemented environment-based config loading with sensible local defaults; +- wired the base `chi` router, middleware stack, and route groups; +- added a JSON health endpoint; +- added template bootstrapping and the first server-rendered HTML pages; +- added base static assets and a `Justfile` for local run/build/test tasks; +- created the `migrations` directory placeholder for the next stage. + +## Files Changed + +- `/Users/delete/projects/update_server/.gitignore` +- `/Users/delete/projects/update_server/go.mod` +- `/Users/delete/projects/update_server/go.sum` +- `/Users/delete/projects/update_server/Justfile` +- `/Users/delete/projects/update_server/cmd/server/main.go` +- `/Users/delete/projects/update_server/internal/app/app.go` +- `/Users/delete/projects/update_server/internal/config/config.go` +- `/Users/delete/projects/update_server/internal/http/router.go` +- `/Users/delete/projects/update_server/internal/http/middleware.go` +- `/Users/delete/projects/update_server/internal/http/handlers.go` +- `/Users/delete/projects/update_server/internal/http/render.go` +- `/Users/delete/projects/update_server/internal/http/response.go` +- `/Users/delete/projects/update_server/web/templates/layouts/base.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/home.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/admin.gohtml` +- `/Users/delete/projects/update_server/web/static/app.css` +- `/Users/delete/projects/update_server/migrations/README.md` + +## Database Changes + +- no schema or migration files were added in this stage; +- created `/Users/delete/projects/update_server/migrations` with a placeholder README so Agent 02 can own the first migration set cleanly. + +## API Or Route Changes + +- added `GET /healthz` returning JSON status; +- added `GET /` as the foundation landing page; +- added `GET /admin` as the reserved admin route group entry; +- added `GET /api/v1` as the reserved versioned API route group entry; +- added static asset serving under `/static/*`. + +## Commands And Tests Run + +- `gofmt -w ./cmd ./internal` - completed successfully; +- `go mod tidy` - completed successfully after running with normal network/cache access; +- `go test ./...` - completed successfully; +- `go build -o /tmp/update-server ./cmd/server` - completed successfully; +- started `/tmp/update-server` on `127.0.0.1:18080` and verified `curl http://127.0.0.1:18080/healthz` returned `200 OK` JSON. + +## Known Limitations + +- no database connection or migration runner exists yet; +- no authentication, sessions, uploads, or business modules are implemented yet; +- template loading currently reads from the filesystem path configured by `TEMPLATES_DIR`; +- a local `data-dev/` directory is created on startup and is intentionally ignored via `.gitignore`. + +## Recommended Next Step + +Agent 02 should implement the SQLite foundation next: add the migration tool flow, create the initial schema files for the spec tables, and introduce the database connection/repository layer without changing the existing app bootstrap, config contract, or route-group structure. + +## Notes For Validator + +- there were no prior stage handoff or validation reports yet; only the directory README placeholders existed; +- pay attention to whether the app boots from the repo root, whether `/healthz` responds, and whether the route groups remain ready for later auth and feature modules. diff --git a/docs/agents/handoffs/02-database.md b/docs/agents/handoffs/02-database.md new file mode 100644 index 0000000..13ac803 --- /dev/null +++ b/docs/agents/handoffs/02-database.md @@ -0,0 +1,105 @@ +# Agent + +Name: Agent 02 - Database And Migrations + +Stage: Database And Migrations + +Date: 2026-04-13 + +## Scope + +Created the SQLite data layer foundation for the update server. + +Completed in this stage: + +- added SQLite config values for `SQLITE_PATH` and `MIGRATIONS_DIR`; +- added a database connection layer with SQLite pragmas, health checks, and transaction scaffolding; +- implemented an ordered SQL migration runner with checksum tracking in `schema_migrations`; +- created the initial schema for `users`, `projects`, `tags`, `project_tags`, `releases`, `api_keys`, `api_key_project_access`, `api_key_tag_access`, `sessions`, and `audit_logs`; +- added indexes and update triggers for mutable tables; +- added schema guards so API key project/tag access rows match the key’s selected `scope_mode`; +- wired database open + migrate into app startup without changing the existing route-group layout; +- added a standalone `cmd/migrate` entrypoint and `just migrate`; +- added migration tests and refreshed the health endpoint to report SQLite readiness. + +## Files Changed + +- `/Users/delete/projects/update_server/go.mod` +- `/Users/delete/projects/update_server/go.sum` +- `/Users/delete/projects/update_server/Justfile` +- `/Users/delete/projects/update_server/cmd/migrate/main.go` +- `/Users/delete/projects/update_server/internal/app/app.go` +- `/Users/delete/projects/update_server/internal/config/config.go` +- `/Users/delete/projects/update_server/internal/db/models.go` +- `/Users/delete/projects/update_server/internal/db/open.go` +- `/Users/delete/projects/update_server/internal/db/migrate.go` +- `/Users/delete/projects/update_server/internal/db/store.go` +- `/Users/delete/projects/update_server/internal/db/migrate_test.go` +- `/Users/delete/projects/update_server/internal/http/router.go` +- `/Users/delete/projects/update_server/internal/http/handlers.go` +- `/Users/delete/projects/update_server/migrations/README.md` +- `/Users/delete/projects/update_server/migrations/0001_initial_schema.sql` +- `/Users/delete/projects/update_server/migrations/0002_indexes_and_triggers.sql` +- `/Users/delete/projects/update_server/migrations/0003_api_key_scope_guards.sql` + +## Database Changes + +Added migrations: + +- `0001_initial_schema.sql` +- `0002_indexes_and_triggers.sql` +- `0003_api_key_scope_guards.sql` + +Schema notes: + +- timestamps are stored as UTC RFC3339-like text values; +- `api_keys.scope_mode` is constrained to the agreed modes: + - `all_projects` + - `project_allow_list` + - `project_deny_list` + - `tag_allow_list` + - `tag_deny_list` +- `api_key_project_access` only accepts keys in project-based modes; +- `api_key_tag_access` only accepts keys in tag-based modes; +- changing an API key to an incompatible scope mode is blocked if incompatible access rows already exist; +- `sessions` stores hashed session tokens, not raw tokens; +- `audit_logs` storage is included, but no runtime writes are wired yet. + +## API Or Route Changes + +- no new route groups or business endpoints were added; +- `GET /healthz` now includes SQLite readiness and returns `503` if the store is unavailable; +- updated the placeholder `/` and `/api/v1` responses to reflect database readiness. + +## Commands And Tests Run + +- `gofmt -w ./cmd ./internal` - completed successfully; +- `go mod tidy` - initially failed in the sandbox due Go cache/network restrictions; completed successfully after rerunning with normal access and temporary Go caches; +- `GOCACHE=/tmp/go-build-sqlite3 GOMODCACHE=/tmp/go-mod-sqlite3 go test ./...` - passed; +- `GOCACHE=/tmp/go-build-sqlite3 GOMODCACHE=/tmp/go-mod-sqlite3 go build -o /tmp/update-server ./cmd/server` - passed; +- `GOCACHE=/tmp/go-build-sqlite3 GOMODCACHE=/tmp/go-mod-sqlite3 go build -o /tmp/update-migrate ./cmd/migrate` - passed; +- `APP_BASE_URL=http://127.0.0.1:18080 DATA_DIR=/tmp/update-server-db-dev-2 /tmp/update-migrate` - applied migrations successfully to a fresh temp SQLite database; +- `sqlite3 /tmp/update-server-db-dev-2/db.sqlite 'SELECT name FROM schema_migrations ORDER BY name;'` - confirmed all three migrations were recorded; +- started `/tmp/update-server` on `127.0.0.1:18080` and verified: + - `GET /` -> `200 OK` + - `GET /api/v1` -> `200 OK` + - `GET /healthz` -> `200 OK` with `"database":"ok"` + +## Known Limitations + +- repository structs are intentionally scaffolding-only in this stage; feature-specific query methods are still for later agents to add; +- no auth/session business logic is implemented yet beyond the schema and store foundation; +- `audit_logs` exists in schema only; event production is deferred; +- scope-mode enforcement now covers schema consistency for access-link rows, but full project/tag access evaluation logic is still for the API key stage; +- existing local databases created before the final scope-guard addition need the new `0003_api_key_scope_guards.sql` migration applied, which the updated startup path now does automatically. + +## Recommended Next Step + +Agent 03 should implement admin authentication next using the new `users` and `sessions` tables: bootstrap the first admin user from environment, hash passwords, create/invalidate session records, and protect the `/admin` route group with session middleware. + +## Notes For Validator + +- pay extra attention to migration immutability: `schema_migrations` stores a checksum and should reject edited applied files; +- validate that the default server startup path now opens SQLite and auto-applies `0001` -> `0003`; +- validate that project/tag access link rows are rejected when the parent API key uses the wrong `scope_mode`; +- the workspace is not a git repository, so diff-based review may need to rely on direct file inspection. diff --git a/docs/agents/handoffs/03-auth.md b/docs/agents/handoffs/03-auth.md new file mode 100644 index 0000000..41d96a9 --- /dev/null +++ b/docs/agents/handoffs/03-auth.md @@ -0,0 +1,92 @@ +# Agent + +Name: Agent 03 - Authentication And Admin Sessions + +Stage: Authentication And Admin Sessions + +Date: 2026-04-14 + +## Scope + +Implemented admin authentication and session management on top of the existing `users` and `sessions` tables. + +Completed in this stage: + +- added auth-related config for bootstrap credentials, session cookie naming, session TTL, and secure-cookie detection from `APP_BASE_URL`; +- implemented bcrypt password hashing and verification in a dedicated `internal/auth` service; +- added admin bootstrap logic that creates the first active admin user from `ADMIN_EMAIL` and `ADMIN_PASSWORD` when no active admin exists yet; +- implemented user/session repository methods for bootstrap lookup, login, session lookup, session touch, and logout invalidation; +- added secure random session token generation with SHA-256 hashed token storage in SQLite; +- wired login and logout handlers plus authenticated session middleware into the existing `chi` route-group structure; +- protected the `/admin` route group with session and role checks; +- replaced the placeholder admin page with a protected authenticated dashboard and added a server-rendered login form; +- added tests for bootstrap behavior and the login/protected-route/logout flow. + +## Files Changed + +- `/Users/delete/projects/update_server/go.mod` +- `/Users/delete/projects/update_server/go.sum` +- `/Users/delete/projects/update_server/internal/app/app.go` +- `/Users/delete/projects/update_server/internal/config/config.go` +- `/Users/delete/projects/update_server/internal/auth/context.go` +- `/Users/delete/projects/update_server/internal/auth/password.go` +- `/Users/delete/projects/update_server/internal/auth/service.go` +- `/Users/delete/projects/update_server/internal/auth/service_test.go` +- `/Users/delete/projects/update_server/internal/db/errors.go` +- `/Users/delete/projects/update_server/internal/db/models.go` +- `/Users/delete/projects/update_server/internal/db/time.go` +- `/Users/delete/projects/update_server/internal/db/users.go` +- `/Users/delete/projects/update_server/internal/db/sessions.go` +- `/Users/delete/projects/update_server/internal/http/router.go` +- `/Users/delete/projects/update_server/internal/http/render.go` +- `/Users/delete/projects/update_server/internal/http/handlers.go` +- `/Users/delete/projects/update_server/internal/http/auth_handlers.go` +- `/Users/delete/projects/update_server/internal/http/auth_middleware.go` +- `/Users/delete/projects/update_server/internal/http/auth_integration_test.go` +- `/Users/delete/projects/update_server/web/templates/layouts/base.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/home.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/admin.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/login.gohtml` +- `/Users/delete/projects/update_server/web/static/app.css` + +## Database Changes + +- no schema or migration changes were required in this stage; +- the existing `users` and `sessions` tables from Agent 02 are now actively used for bootstrap, login, session lookup, and logout invalidation; +- session tokens continue to be stored hashed only; +- user `last_login_at` and session `last_seen_at` are now updated by runtime auth flows. + +## API Or Route Changes + +- added `GET /admin/login` for the server-rendered login form; +- added `POST /admin/login` to authenticate an admin user and issue a session cookie; +- added `POST /admin/logout` to invalidate the current session and clear the cookie; +- changed `/admin` from a public placeholder to a protected route group guarded by session middleware and admin-role checks; +- retained the existing `/api/v1` route scaffold unchanged. + +## Commands And Tests Run + +- `gofmt -w ./cmd ./internal` - passed; +- `GOCACHE=/tmp/go-build-auth GOMODCACHE=/tmp/go-mod-auth go mod tidy` - initially failed in the sandbox due network/DNS restrictions, then passed after rerunning with approval; +- `GOCACHE=/tmp/go-build-auth GOMODCACHE=/tmp/go-mod-auth go test ./...` - passed; +- `GOCACHE=/tmp/go-build-auth GOMODCACHE=/tmp/go-mod-auth go build -o /tmp/update-server-auth ./cmd/server` - passed; +- `GOCACHE=/tmp/go-build-auth GOMODCACHE=/tmp/go-mod-auth go build -o /tmp/update-migrate-auth ./cmd/migrate` - passed. + +## Known Limitations + +- CSRF protection is not implemented yet for admin forms; this remains for the hardening stage; +- login rate limiting is not implemented yet; +- bootstrap is environment-driven only in this stage; there is no separate one-time bootstrap CLI yet; +- if no active admin exists and the configured `ADMIN_EMAIL` already belongs to a non-admin user, bootstrap currently logs a warning and leaves that conflict for an operator to resolve; +- there is no admin user-management UI yet beyond the initial bootstrap/login foundation. + +## Recommended Next Step + +Agent 04 should build project, tag, and release management on top of the now-protected `/admin` route group, reusing the authenticated session context instead of adding a parallel auth path. + +## Notes For Validator + +- verify that `POST /admin/login` creates a DB-backed session and stores only the token hash in SQLite, not the raw cookie value; +- verify that `/admin` redirects when unauthenticated and succeeds when the issued session cookie is replayed; +- verify that `POST /admin/logout` invalidates the existing session record so the old cookie no longer grants access; +- verify bootstrap behavior both when `ADMIN_EMAIL` and `ADMIN_PASSWORD` are present and when they are absent. diff --git a/docs/agents/handoffs/04-projects-releases.md b/docs/agents/handoffs/04-projects-releases.md new file mode 100644 index 0000000..08b0aab --- /dev/null +++ b/docs/agents/handoffs/04-projects-releases.md @@ -0,0 +1,105 @@ +# Agent + +Name: Agent 04 - Projects, Tags, And Releases + +Stage: Projects, Tags, And Releases + +Date: 2026-04-14 + +## Scope + +Implemented the core product data management stage on top of the existing authenticated `/admin` route group. + +Completed in this stage: + +- added artifact storage configuration rooted under `DATA_DIR/artifacts`, with validation that artifacts stay outside the public static directory; +- implemented local filesystem artifact storage with temp-file staging and final path validation; +- added project repository methods for create, update, list, archive toggle, and project-tag assignment queries; +- added tag repository methods for create, update, list, delete-if-unused, and project usage queries; +- added release repository methods for create, lookup, and per-project release listing; +- implemented a release upload service that sanitizes filenames, streams uploads to a temp artifact, computes SHA-256 checksums, detects content type, moves the file into structured private storage, and writes release metadata to SQLite; +- extended the protected admin router with project list/create/detail/archive routes, tag list/create/detail/delete routes, project-tag attach or detach actions, and release upload handling; +- replaced the placeholder dashboard with project/tag/release-aware admin pages and added server-rendered templates for project list, project detail, project creation, tag list, and tag detail; +- added integration coverage for the admin flow that logs in, creates and edits project/tag data, attaches tags, uploads a release, verifies the checksum and sanitized filename, and checks the artifact on disk. + +## Files Changed + +- `/Users/delete/projects/update_server/internal/app/app.go` +- `/Users/delete/projects/update_server/internal/config/config.go` +- `/Users/delete/projects/update_server/internal/db/errors.go` +- `/Users/delete/projects/update_server/internal/db/models.go` +- `/Users/delete/projects/update_server/internal/db/projects.go` +- `/Users/delete/projects/update_server/internal/db/tags.go` +- `/Users/delete/projects/update_server/internal/db/releases.go` +- `/Users/delete/projects/update_server/internal/http/router.go` +- `/Users/delete/projects/update_server/internal/http/render.go` +- `/Users/delete/projects/update_server/internal/http/view_data.go` +- `/Users/delete/projects/update_server/internal/http/handlers.go` +- `/Users/delete/projects/update_server/internal/http/auth_handlers.go` +- `/Users/delete/projects/update_server/internal/http/admin_common.go` +- `/Users/delete/projects/update_server/internal/http/admin_projects.go` +- `/Users/delete/projects/update_server/internal/http/admin_tags.go` +- `/Users/delete/projects/update_server/internal/http/auth_integration_test.go` +- `/Users/delete/projects/update_server/internal/http/projects_integration_test.go` +- `/Users/delete/projects/update_server/internal/releases/service.go` +- `/Users/delete/projects/update_server/internal/slug/slug.go` +- `/Users/delete/projects/update_server/internal/storage/local.go` +- `/Users/delete/projects/update_server/web/templates/layouts/base.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/admin.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/home.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/login.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/projects.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/project_form.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/project_detail.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/tags.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/tag_form.gohtml` +- `/Users/delete/projects/update_server/web/static/app.css` +- `/Users/delete/projects/update_server/docs/agents/handoffs/04-projects-releases.md` + +## Database Changes + +- no schema or migration changes were required in this stage; +- the existing `projects`, `tags`, `project_tags`, and `releases` tables are now actively used by repository methods and protected admin workflows; +- release `storage_path` values are now persisted as relative paths underneath the configured artifact root; +- release rows now store sanitized filenames, SHA-256 checksum values, detected content type, file size, release notes, and uploader linkage. + +## API Or Route Changes + +- added `GET /admin/projects` for the protected project list page; +- added `GET /admin/projects/new` and `POST /admin/projects` for project creation; +- added `GET /admin/projects/{projectID}` and `POST /admin/projects/{projectID}` for project detail and editing; +- added `POST /admin/projects/{projectID}/archive` for archive or restore actions; +- added `POST /admin/projects/{projectID}/tags` and `POST /admin/projects/{projectID}/tags/{tagID}/detach` for project-tag assignment; +- added `POST /admin/projects/{projectID}/releases` for protected release uploads; +- added `GET /admin/tags`, `GET /admin/tags/new`, and `POST /admin/tags` for tag list and creation; +- added `GET /admin/tags/{tagID}`, `POST /admin/tags/{tagID}`, and `POST /admin/tags/{tagID}/delete` for tag edit and delete-if-unused behavior; +- updated `GET /admin` to show project/tag/release summary data instead of the auth-only placeholder; +- kept `/api/v1` unchanged and intentionally did not add API key or client update endpoints in this stage. + +## Commands And Tests Run + +- `gofmt -w ./internal ./cmd` - passed; +- `GOCACHE=/tmp/go-build-agent04 GOMODCACHE=/tmp/go-mod-agent04 go test ./...` - initially failed in the sandbox due dependency download DNS restrictions, then passed after rerunning with approval; +- `GOCACHE=/tmp/go-build-agent04 GOMODCACHE=/tmp/go-mod-agent04 go build -o /tmp/update-server-agent04 ./cmd/server` - passed; +- `GOCACHE=/tmp/go-build-agent04 GOMODCACHE=/tmp/go-mod-agent04 go build -o /tmp/update-migrate-agent04 ./cmd/migrate` - passed. + +## Known Limitations + +- CSRF protection is still not implemented for admin forms, including the new project, tag, archive, and upload actions; +- release deletion or disable flows are not implemented in this stage; +- there is still no client-facing download or metadata endpoint for releases; only the protected admin upload and metadata path is present; +- duplicate project slugs, tag slugs, and `(project_id, version, build)` release combinations are rejected rather than offering replace-in-place behavior; +- upload handling currently relies on the standard multipart temp-file path before final storage, which is acceptable for MVP but not yet a custom streaming parser. + +## Recommended Next Step + +Agent 05 should implement API key generation, hashing, permissions, and scope evaluation on top of the now-working project and tag data model, using the existing `projects`, `tags`, and assignment relationships for allow-list and deny-list management. + +## Notes For Validator + +- verify that all new admin project and tag routes remain inside the existing authenticated `/admin` route group and reuse the session context; +- verify that uploaded artifacts land under `DATA_DIR/artifacts` and not under `web/static`; +- verify that uploaded filenames are sanitized before persistence and that `releases.checksum_sha256` matches the actual file bytes on disk; +- verify that release metadata rows are written to SQLite with the expected storage path, size, content type, and uploader linkage; +- verify that project archive or restore toggles `projects.is_active`; +- verify that tag deletion is blocked while the tag is still attached to a project. diff --git a/docs/agents/handoffs/05-api-keys-fix.md b/docs/agents/handoffs/05-api-keys-fix.md new file mode 100644 index 0000000..41a3aec --- /dev/null +++ b/docs/agents/handoffs/05-api-keys-fix.md @@ -0,0 +1,63 @@ +# Agent + +Name: Agent 05 - API Keys And Access Control (fix round) + +Stage: API Keys And Access Control + +Date: 2026-04-14 + +## Scope + +Fixed the validation findings for Agent 05 without expanding into the client API stage. + +Completed in this fix round: + +- added explicit anti-cache headers for the one-time raw API key reveal response so the secret-bearing HTML is marked non-cacheable; +- changed the immediate post-create detail render to hydrate from the persisted API key record and persisted scope rows while still showing the raw key only on that one response; +- extended HTTP integration coverage to verify: + - the creation response includes anti-cache headers; + - the creation response shows saved name, description, scope mode, permissions, and selected project or tag rules; + - the raw key is present on the creation response and absent on later detail loads. + +## Files Changed + +- `/Users/delete/projects/update_server/internal/http/admin_api_keys.go` +- `/Users/delete/projects/update_server/internal/http/api_keys_integration_test.go` +- `/Users/delete/projects/update_server/docs/agents/handoffs/05-api-keys-fix.md` + +## Database Changes + +- no schema or migration changes were required; +- the existing API key hash and scope storage model remains unchanged. + +## API Or Route Changes + +- no routes were added or removed; +- `POST /admin/api-keys` now returns the one-time reveal page with explicit anti-cache headers: + - `Cache-Control: no-store, private, max-age=0` + - `Pragma: no-cache` + - `Expires: 0` + +## Commands And Tests Run + +- `gofmt -w internal/http/admin_api_keys.go internal/http/api_keys_integration_test.go` - passed; +- `GOCACHE=/tmp/go-build-agent05-fix GOMODCACHE=/tmp/go-mod-agent05-2 go test ./internal/http` - passed; +- `GOCACHE=/tmp/go-build-agent05-fix-all GOMODCACHE=/tmp/go-mod-agent05-2 go test ./...` - passed; +- `GOCACHE=/tmp/go-build-agent05-fix-build GOMODCACHE=/tmp/go-mod-agent05-2 go build -o /tmp/update-server-agent05-fix ./cmd/server` - passed; +- `GOCACHE=/tmp/go-build-agent05-fix-build2 GOMODCACHE=/tmp/go-mod-agent05-2 go build -o /tmp/update-migrate-agent05-fix ./cmd/migrate` - passed. + +## Known Limitations + +- the one-time reveal still intentionally happens on the direct POST response rather than a redirect flow so the raw key never has to enter query strings or persistent storage; +- CSRF protection is still not implemented for admin API key forms; +- the client `/api/v1` endpoints still do not use the API key middleware yet; that remains for Agent 06. + +## Recommended Next Step + +Agent 06 should wire the existing API key middleware into the client `/api/v1` endpoints for accessible project listing, latest release lookup, and authenticated artifact download. + +## Notes For Validator + +- verify that the post-create response shows persisted saved values immediately, without requiring a manual refresh; +- verify that the raw key appears only on the secret-bearing creation response and not on later `GET /admin/api-keys/{id}` detail loads; +- verify the one-time reveal response includes the anti-cache headers listed above. diff --git a/docs/agents/handoffs/05-api-keys.md b/docs/agents/handoffs/05-api-keys.md new file mode 100644 index 0000000..d255c15 --- /dev/null +++ b/docs/agents/handoffs/05-api-keys.md @@ -0,0 +1,90 @@ +# Agent + +Name: Agent 05 - API Keys And Access Control + +Stage: API Keys And Access Control + +Date: 2026-04-14 + +## Scope + +Implemented the API key lifecycle and access-control stage on top of the existing authenticated admin workspace and the project or tag model from Agent 04. + +Completed in this stage: + +- added a dedicated `internal/apikeys` service for secure API key generation, SHA-256 hashing, lookup, lifecycle checks, and project scope evaluation; +- implemented full API key repository support for create, update, list, revoke or activate, last-used tracking, project and tag rule storage, and effective access queries; +- added minimal protected admin pages for API key list, creation, detail, update, and revoke or activate actions; +- implemented one-time raw key reveal behavior by showing the generated key only in the immediate creation response while persisting only the short prefix and hash; +- added permission and project-scope middleware helpers for future `/api/v1` client routes using bearer authentication; +- added integration and unit coverage for hashed storage, expired or revoked key rejection, all five scope modes, scope transitions, middleware enforcement, and admin key management flow. + +## Files Changed + +- `/Users/delete/projects/update_server/internal/app/app.go` +- `/Users/delete/projects/update_server/internal/db/models.go` +- `/Users/delete/projects/update_server/internal/db/apikeys.go` +- `/Users/delete/projects/update_server/internal/apikeys/context.go` +- `/Users/delete/projects/update_server/internal/apikeys/service.go` +- `/Users/delete/projects/update_server/internal/apikeys/service_test.go` +- `/Users/delete/projects/update_server/internal/http/router.go` +- `/Users/delete/projects/update_server/internal/http/handlers.go` +- `/Users/delete/projects/update_server/internal/http/view_data.go` +- `/Users/delete/projects/update_server/internal/http/admin_api_keys.go` +- `/Users/delete/projects/update_server/internal/http/api_key_middleware.go` +- `/Users/delete/projects/update_server/internal/http/api_key_middleware_test.go` +- `/Users/delete/projects/update_server/internal/http/api_keys_integration_test.go` +- `/Users/delete/projects/update_server/internal/http/auth_integration_test.go` +- `/Users/delete/projects/update_server/web/templates/layouts/base.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/api_keys.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/api_key_form.gohtml` +- `/Users/delete/projects/update_server/web/static/app.css` +- `/Users/delete/projects/update_server/docs/agents/handoffs/05-api-keys.md` + +## Database Changes + +- no new migrations were required because the existing `api_keys`, `api_key_project_access`, and `api_key_tag_access` tables already existed from Agent 02 and the scope guard triggers already existed from migration `0003_api_key_scope_guards.sql`; +- the new repository code now actively uses those tables and trigger rules for key lifecycle, project allow or deny lists, and tag allow or deny lists; +- API keys are stored with a short visible `key_prefix` and a hashed `key_hash`; the raw key is never persisted. + +## API Or Route Changes + +- added protected admin routes: + - `GET /admin/api-keys` + - `GET /admin/api-keys/new` + - `POST /admin/api-keys` + - `GET /admin/api-keys/{apiKeyID}` + - `POST /admin/api-keys/{apiKeyID}` + - `POST /admin/api-keys/{apiKeyID}/activate` +- added reusable middleware helpers for future client API routes: + - `requireAPIKey` + - `requireAPIKeyPermission` + - `requireAPIKeyProjectAccess` +- kept `/api/v1` client endpoints themselves out of scope for this stage so Agent 06 can wire them onto the new middleware and access-resolution layer. + +## Commands And Tests Run + +- `gofmt -w internal/app/app.go internal/db/models.go internal/db/apikeys.go internal/apikeys/context.go internal/apikeys/service.go internal/http/router.go internal/http/handlers.go internal/http/view_data.go internal/http/admin_api_keys.go internal/http/api_key_middleware.go internal/http/auth_integration_test.go internal/apikeys/service_test.go internal/http/api_key_middleware_test.go internal/http/api_keys_integration_test.go` - passed; +- `GOCACHE=/tmp/go-build-agent05-2 GOMODCACHE=/tmp/go-mod-agent05-2 go test ./...` - passed after downloading dependencies with approval; +- `GOCACHE=/tmp/go-build-agent05-build GOMODCACHE=/tmp/go-mod-agent05-2 go build -o /tmp/update-server-agent05 ./cmd/server` - passed; +- `GOCACHE=/tmp/go-build-agent05-build2 GOMODCACHE=/tmp/go-mod-agent05-2 go build -o /tmp/update-migrate-agent05 ./cmd/migrate` - passed. + +## Known Limitations + +- CSRF protection is still not implemented for admin forms, including the new API key creation, update, and revoke or activate actions; +- the admin API key UI is intentionally minimal and functional rather than polished; +- the one-time reveal happens on the direct POST response instead of a PRG redirect because the raw key must not be placed into query strings or persistent storage; +- no live client `/api/v1` endpoints use the new middleware yet; the groundwork is ready but the actual project list, latest-release, and download endpoints remain for Agent 06; +- expiration input currently accepts either empty, `YYYY-MM-DD`, or full RFC3339 UTC text rather than a more polished timezone-aware widget. + +## Recommended Next Step + +Agent 06 should wire the new `requireAPIKey`, `requireAPIKeyPermission`, and `requireAPIKeyProjectAccess` middleware into the client `/api/v1` endpoints for accessible-project listing, latest release lookup, and authenticated artifact download. + +## Notes For Validator + +- verify that only `key_prefix` and `key_hash` are stored in SQLite and that the raw key appears only in the immediate creation response; +- verify that revoked or expired keys are rejected by `requireAPIKey` with `401` and that missing permissions or blocked project scope return `403`; +- verify effective access resolution across all five scope modes, especially that archived projects do not appear in accessible-project results; +- verify scope transitions clear the incompatible access rows so the existing migration guards continue to succeed; +- verify the admin detail page preview matches the effective active project set for the key. diff --git a/docs/agents/handoffs/06-client-api-ui.md b/docs/agents/handoffs/06-client-api-ui.md new file mode 100644 index 0000000..a556d63 --- /dev/null +++ b/docs/agents/handoffs/06-client-api-ui.md @@ -0,0 +1,100 @@ +# Agent + +Name: Agent 06 - Client API And Admin UI + +Stage: Client API And Admin UI + +Date: 2026-04-15 + +## Scope + +Implemented the usable end-to-end product flow for admins and client applications on top of the existing project, release, and API key work from Agents 04 and 05. + +Completed in this stage: + +- added bearer-authenticated client endpoints under `/api/v1` for: + - listing accessible active projects; + - looking up the latest active release for an accessible project; + - fetching release metadata by release ID for an accessible project; + - downloading private release artifacts by release ID after auth, permission, and scope checks; +- reused the existing API key middleware and scope-evaluation service so disabled or expired keys return `401`, missing download permission returns `403`, and inaccessible projects or releases return `404` to avoid leaking unauthorized resources; +- added project slug lookup and latest-active-release repository queries so client handlers can resolve project-scoped metadata cleanly from persisted release rows; +- added a safe artifact-open path on top of the existing private local storage so downloads stream from the artifact root instead of exposing raw filesystem paths or static URLs; +- updated the API index and the home or dashboard copy so the live client API is discoverable instead of still looking like a placeholder; +- added small admin UI improvements on project detail and API key detail pages that show the client API paths and a bearer-auth quick-start example after an admin uploads a release or creates a key; +- added integration coverage for the client flow, including auth failures, scope enforcement, latest-release selection, release metadata lookup, and authorized or unauthorized download behavior. + +## Files Changed + +- `/Users/delete/projects/update_server/internal/db/projects.go` +- `/Users/delete/projects/update_server/internal/db/releases.go` +- `/Users/delete/projects/update_server/internal/storage/local.go` +- `/Users/delete/projects/update_server/internal/releases/service.go` +- `/Users/delete/projects/update_server/internal/http/router.go` +- `/Users/delete/projects/update_server/internal/http/handlers.go` +- `/Users/delete/projects/update_server/internal/http/admin_common.go` +- `/Users/delete/projects/update_server/internal/http/admin_projects.go` +- `/Users/delete/projects/update_server/internal/http/view_data.go` +- `/Users/delete/projects/update_server/internal/http/client_api.go` +- `/Users/delete/projects/update_server/internal/http/client_api_integration_test.go` +- `/Users/delete/projects/update_server/internal/http/api_keys_integration_test.go` +- `/Users/delete/projects/update_server/internal/http/projects_integration_test.go` +- `/Users/delete/projects/update_server/web/templates/pages/project_detail.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/api_key_form.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/api_keys.gohtml` +- `/Users/delete/projects/update_server/web/static/app.css` +- `/Users/delete/projects/update_server/docs/agents/handoffs/06-client-api-ui.md` + +## Database Changes + +- no new migrations or schema changes were required; +- added repository queries against the existing `projects` and `releases` tables: + - project lookup by slug; + - latest active release lookup ordered by persisted `created_at DESC, id DESC`; +- continued using the existing API key scope tables and scope modes without redesign. + +## API Or Route Changes + +- kept `GET /api/v1` public and upgraded it from a placeholder to a real client API index; +- added protected client routes behind bearer API key auth plus `can_download` permission: + - `GET /api/v1/projects` + - `GET /api/v1/projects/{projectSlug}/releases/latest` + - `GET /api/v1/releases/{releaseID}` + - `GET /api/v1/releases/{releaseID}/download` +- resource access behavior for client routes: + - missing, invalid, disabled, or expired API key -> `401` + - authenticated key without download permission -> `403` + - inaccessible or archived project or release -> `404` +- admin UI additions: + - project detail page now shows the relevant client API paths for that project and latest release; + - API key detail page now shows a bearer-auth quick-start example plus the accessible project metadata paths. + +## Commands And Tests Run + +- `gofmt -w internal/db/projects.go internal/db/releases.go internal/storage/local.go internal/http/view_data.go internal/http/admin_common.go internal/http/admin_projects.go internal/http/router.go internal/http/handlers.go internal/http/client_api.go internal/releases/service.go internal/http/api_keys_integration_test.go internal/http/projects_integration_test.go internal/http/client_api_integration_test.go` - passed +- `GOCACHE=/tmp/go-build-agent06-http GOMODCACHE=/tmp/go-mod-agent06-http go test ./internal/http ./internal/releases ./internal/db ./internal/apikeys` - initially failed in sandbox because module downloads could not resolve DNS, then passed after rerunning with approval +- `GOCACHE=/tmp/go-build-agent06-all GOMODCACHE=/tmp/go-mod-agent06-http go test ./...` - passed +- `GOCACHE=/tmp/go-build-agent06-build GOMODCACHE=/tmp/go-mod-agent06-http go build -o /tmp/update-server-agent06 ./cmd/server` - passed +- `GOCACHE=/tmp/go-build-agent06-migrate GOMODCACHE=/tmp/go-mod-agent06-http go build -o /tmp/update-migrate-agent06 ./cmd/migrate` - passed + +## Known Limitations + +- “Latest” currently means the newest active persisted release by `created_at` and `id`, not semantic-version comparison; +- the optional client query parameters mentioned in the product spec (`current_version`, `channel`, `platform`, `arch`) are still not implemented; +- the client API does not yet expose full per-project release listing; this stage focused on the required list, latest-metadata, metadata-by-ID, and download flow; +- the admin quick-start example can show the real raw key only on the one-time create response; later detail views intentionally fall back to a placeholder token; +- broader hardening work such as CSRF completion, rate limiting, security headers, audit logging polish, and deployment proxy setup remains out of scope for this stage. + +## Recommended Next Step + +Run the validation pass for Agent 06, then move to Agent 07 to implement the planned hardening and deployment-focused follow-up: CSRF, security headers, rate limiting, audit/logging polish, and deployment readiness around the now-working end-to-end product flow. + +## Notes For Validator + +- verify that client routes require `Authorization: Bearer ` and that disabled or expired keys are rejected with `401`; +- verify that client routes require `can_download` and return `403` when the key authenticates but lacks download permission; +- verify that unauthorized, archived, or out-of-scope projects and releases return `404` from metadata and download paths rather than leaking through a `403`; +- verify that `GET /api/v1/projects` returns only active projects allowed by the key’s scope evaluation; +- verify that the latest-release endpoint selects the newest active persisted release row for the project; +- verify that the download endpoint streams bytes from private artifact storage and does not expose static URLs or raw filesystem paths; +- verify that the project detail and API key detail admin pages visibly expose the new client API quick-start information. diff --git a/docs/agents/handoffs/07-security-deploy.md b/docs/agents/handoffs/07-security-deploy.md new file mode 100644 index 0000000..5868a2d --- /dev/null +++ b/docs/agents/handoffs/07-security-deploy.md @@ -0,0 +1,134 @@ +# Agent + +Name: Agent 07 - Security And Deployment Hardening + +Stage: Security And Deployment Hardening + +Date: 2026-04-15 + +## Scope + +Hardened the existing Go/chi/SQLite application for internet-facing deployment without changing the product model or admin/client flow. + +Completed in this stage: + +- added stricter HTTP server defaults: + - `ReadHeaderTimeout` + - `MaxHeaderBytes` + - higher default read/write/idle timeouts suitable for uploads and authenticated downloads +- added global security headers and route-specific cache controls: + - `Content-Security-Policy` + - `X-Frame-Options` + - `X-Content-Type-Options` + - `Referrer-Policy` + - `Permissions-Policy` + - `Cross-Origin-Opener-Policy` + - `Cross-Origin-Resource-Policy` + - `Strict-Transport-Security` when `APP_BASE_URL` is `https` + - `Cache-Control: no-store` on admin and protected client API responses + - `Vary: Cookie` or `Vary: Authorization` on the appropriate routes +- added trusted-proxy awareness so forwarded headers are only trusted when `TRUST_PROXY_HEADERS=true`; +- added in-memory rate limiting for: + - `POST /admin/login` + - protected client API routes under `/api/v1` +- added CSRF protection for all admin POST flows, including login, logout, project/tag/API key forms, tag attach or detach, archive or restore, and multipart release upload; +- rotated or cleared the admin CSRF cookie on login and logout, and scoped both session and CSRF cookies to `/admin`; +- tightened local data directory permissions for SQLite and data storage; +- added regression coverage for CSRF rejection, hardened cookie defaults, security headers, and both rate-limited route groups; +- added first-pass deployment assets: + - multi-stage `Dockerfile` + - hardened `docker-compose.yml` + - Caddy example + - Nginx example + - env example + - deployment, Proxmox, backup, and restore notes. + +## Files Changed + +- `/Users/delete/projects/update_server/internal/config/config.go` +- `/Users/delete/projects/update_server/internal/app/app.go` +- `/Users/delete/projects/update_server/internal/db/open.go` +- `/Users/delete/projects/update_server/internal/auth/service.go` +- `/Users/delete/projects/update_server/internal/http/router.go` +- `/Users/delete/projects/update_server/internal/http/security.go` +- `/Users/delete/projects/update_server/internal/http/rate_limit.go` +- `/Users/delete/projects/update_server/internal/http/csrf.go` +- `/Users/delete/projects/update_server/internal/http/auth_handlers.go` +- `/Users/delete/projects/update_server/internal/http/admin_common.go` +- `/Users/delete/projects/update_server/internal/http/admin_api_keys.go` +- `/Users/delete/projects/update_server/internal/http/view_data.go` +- `/Users/delete/projects/update_server/internal/http/auth_integration_test.go` +- `/Users/delete/projects/update_server/internal/http/projects_integration_test.go` +- `/Users/delete/projects/update_server/internal/http/api_keys_integration_test.go` +- `/Users/delete/projects/update_server/internal/http/security_integration_test.go` +- `/Users/delete/projects/update_server/web/templates/layouts/base.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/login.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/project_form.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/project_detail.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/tag_form.gohtml` +- `/Users/delete/projects/update_server/web/templates/pages/api_key_form.gohtml` +- `/Users/delete/projects/update_server/web/templates/partials/csrf_field.gohtml` +- `/Users/delete/projects/update_server/.dockerignore` +- `/Users/delete/projects/update_server/Dockerfile` +- `/Users/delete/projects/update_server/docker-compose.yml` +- `/Users/delete/projects/update_server/deploy/Caddyfile.example` +- `/Users/delete/projects/update_server/deploy/nginx.update-server.conf.example` +- `/Users/delete/projects/update_server/deploy/update-server.env.example` +- `/Users/delete/projects/update_server/docs/DEPLOYMENT.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/07-security-deploy.md` + +## Database Changes + +- no migrations or schema changes were required; +- SQLite remains the production database; +- deployment notes now explicitly call out WAL-mode backup and restore handling for: + - `db.sqlite` + - `db.sqlite-wal` + - `db.sqlite-shm` + - `artifacts/`. + +## API Or Route Changes + +- no new product endpoints were added; +- middleware behavior changed for existing routes: + - all `/admin` responses now emit hardened headers and `no-store` caching rules; + - all `/admin` POST routes now require a valid CSRF token; + - `POST /admin/login` is rate limited by client IP; + - protected `/api/v1` routes now emit hardened headers and `no-store` caching rules; + - protected `/api/v1` routes are rate limited by client IP before API-key auth; +- session cookies are now scoped to `/admin` and retain `HttpOnly`, `SameSite=Lax`, and conditional `Secure`; +- CSRF cookies are now scoped to `/admin` and use `HttpOnly`, `SameSite=Strict`, and conditional `Secure`. + +## Commands And Tests Run + +- `gofmt -w internal/config/config.go internal/app/app.go internal/db/open.go internal/auth/service.go internal/http/router.go internal/http/security.go internal/http/rate_limit.go internal/http/csrf.go internal/http/auth_handlers.go internal/http/view_data.go internal/http/admin_common.go internal/http/admin_api_keys.go internal/http/auth_integration_test.go internal/http/projects_integration_test.go internal/http/api_keys_integration_test.go internal/http/security_integration_test.go` - passed +- `go test ./internal/http` - passed +- `go test ./...` - passed +- `go build ./cmd/server` - passed +- `go build ./cmd/migrate` - passed +- `docker compose config` - could not run in this environment because `docker` is not installed + +## Known Limitations + +- rate limiting is in-memory and per-process, so counters reset on restart and are not shared across multiple app instances; +- Docker and reverse-proxy examples were added and reviewed statically, but they were not live-validated here because Docker is unavailable in this workspace; +- `Strict-Transport-Security` only appears when `APP_BASE_URL` is configured with `https`, which is the intended production setup behind TLS termination; +- the proxy still must be configured correctly to keep the app private and to strip or control forwarded headers before `TRUST_PROXY_HEADERS=true` is safe. + +## Recommended Next Step + +Run the Agent 07 validation pass, with special attention to: + +- CSRF enforcement on every admin POST flow, including multipart upload; +- security headers and private-cache headers on admin and protected client API responses; +- login and client API rate limiting behavior; +- cookie flags and `/admin` cookie scoping; +- deployment docs and example proxy/container files on a host that has Docker and a reverse proxy available. + +## Notes For Validator + +- verify that a missing or invalid admin CSRF token returns `403` and that the normal admin flows still succeed when the token is present; +- verify that session cookies are only scoped to `/admin` and that secure-cookie behavior follows the configured `APP_BASE_URL` scheme; +- verify that `TRUST_PROXY_HEADERS=false` leaves client IP handling on the socket remote address; +- verify that protected `/api/v1` responses include `Cache-Control: no-store`, `Vary: Authorization`, and the shared security headers; +- if possible, validate `docker compose up --build` plus one reverse-proxy example on a real machine, since that could not be executed in this environment. diff --git a/docs/agents/handoffs/README.md b/docs/agents/handoffs/README.md new file mode 100644 index 0000000..184e632 --- /dev/null +++ b/docs/agents/handoffs/README.md @@ -0,0 +1,13 @@ +# Handoffs Directory + +Save one handoff markdown file here after every implementation stage. + +Naming convention: + +- `01-foundation.md` +- `02-database.md` +- `03-auth.md` +- `04-projects-releases.md` +- `05-api-keys.md` +- `06-client-api-ui.md` +- `07-security-deploy.md` diff --git a/docs/agents/validation/01-foundation-validation.md b/docs/agents/validation/01-foundation-validation.md new file mode 100644 index 0000000..d7197ca --- /dev/null +++ b/docs/agents/validation/01-foundation-validation.md @@ -0,0 +1,45 @@ +# Validation Report Template + +## Validator + +Name: Codex Validation Agent + +Stage Reviewed: Agent 01 - Foundation + +Date: 2026-04-13 + +## Reviewed Inputs + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/OPERATOR_QUICKSTART_RU.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/01-foundation.md` +- implementation files under `cmd/server`, `internal/app`, `internal/config`, `internal/http`, `web/templates`, `web/static`, and `migrations` +- `go test ./...` -> passed +- `go build -o /tmp/update-server ./cmd/server` -> passed +- live smoke checks against `127.0.0.1:18080`: + - `GET /healthz` -> `200 OK` + - `GET /` -> `200 OK` + - `GET /admin` -> `200 OK` + - `GET /api/v1` -> `200 OK` + +## Findings + +No blocking findings. + +The stage stays within the agreed scope, the server boots successfully, the route scaffolding is present, and the template/static foundation is ready for Agent 02. + +## Required Fixes + +None. + +## Optional Improvements + +- Add a small smoke-test suite for the foundation routes in a later stage once the HTTP surface becomes more stable. +- Initialize a git repository if you want future validators to compare diffs with `git status` and `git diff`. + +## Status + +`APPROVED` diff --git a/docs/agents/validation/02-database-validation.md b/docs/agents/validation/02-database-validation.md new file mode 100644 index 0000000..cb032e9 --- /dev/null +++ b/docs/agents/validation/02-database-validation.md @@ -0,0 +1,54 @@ +# Validation Report Template + +## Validator + +Name: Codex Validation Agent + +Stage Reviewed: Agent 02 - Database And Migrations + +Date: 2026-04-14 + +## Reviewed Inputs + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/OPERATOR_QUICKSTART_RU.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/02-database.md` +- previous validation report: `/Users/delete/projects/update_server/docs/agents/validation/01-foundation-validation.md` +- implementation files under `cmd/migrate`, `internal/db`, `internal/config`, `internal/app`, `internal/http`, and `migrations` +- `.gitignore` and `Justfile` +- `GOCACHE=/tmp/go-build-agent02 GOMODCACHE=/tmp/go-mod-agent02 go test ./...` -> passed +- `GOCACHE=/tmp/go-build-agent02 GOMODCACHE=/tmp/go-mod-agent02 go build -o /tmp/update-server-agent02-bin ./cmd/server` -> passed +- `GOCACHE=/tmp/go-build-agent02 GOMODCACHE=/tmp/go-mod-agent02 go build -o /tmp/update-migrate-agent02-bin ./cmd/migrate` -> passed +- `DATA_DIR=/tmp/update-server-agent02 APP_BASE_URL=http://127.0.0.1:18080 /tmp/update-migrate` -> passed +- live smoke checks against `127.0.0.1:18081` after starting `/tmp/update-server-agent02-bin` with a fresh `DATA_DIR`: + - `GET /healthz` -> `200 OK` with `"database":"ok"` + - `GET /` -> `200 OK` + - `GET /api/v1` -> `200 OK` + +## Findings + +No blocking findings. + +The stage satisfies the agreed scope: + +- the SQLite schema covers the required tables; +- migrations are present and executable; +- migration checksum tracking is implemented and tested; +- the server startup path opens SQLite and auto-applies migrations; +- the health endpoint now reflects database readiness. + +## Required Fixes + +None. + +## Optional Improvements + +- Add explicit tests for `scope_mode` transition guard behavior, not just incompatible access-row insertion. +- Update the small home-page eyebrow copy from `Foundation Stage` to something database-specific for consistency. + +## Status + +`APPROVED` diff --git a/docs/agents/validation/03-auth-validation.md b/docs/agents/validation/03-auth-validation.md new file mode 100644 index 0000000..fdabb1e --- /dev/null +++ b/docs/agents/validation/03-auth-validation.md @@ -0,0 +1,57 @@ +# Validation Report Template + +## Validator + +Name: Codex Validation Agent + +Stage Reviewed: Agent 03 - Authentication And Admin Sessions + +Date: 2026-04-14 + +## Reviewed Inputs + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/OPERATOR_QUICKSTART_RU.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/03-auth.md` +- previous validation report: `/Users/delete/projects/update_server/docs/agents/validation/02-database-validation.md` +- implementation files under `internal/auth`, `internal/db`, `internal/http`, `internal/app`, `internal/config`, and `web/templates` +- `GOCACHE=/tmp/go-build-auth-validate GOMODCACHE=/tmp/go-mod-auth-validate go test ./...` -> passed +- `GOCACHE=/tmp/go-build-auth-validate GOMODCACHE=/tmp/go-mod-auth-validate go build -o /tmp/update-server-auth-validate ./cmd/server` -> passed +- `GOCACHE=/tmp/go-build-auth-validate GOMODCACHE=/tmp/go-mod-auth-validate go build -o /tmp/update-migrate-auth-validate ./cmd/migrate` -> passed +- live smoke checks against `127.0.0.1:18082` after starting `/tmp/update-server-auth-validate` with fresh bootstrap credentials and a fresh `DATA_DIR`: + - `GET /admin` -> `303 See Other` to `/admin/login` + - `GET /admin/login` -> `200 OK` + - `POST /admin/login` -> `303 See Other` with session cookie + - `GET /admin` with valid cookie -> `200 OK` + - SQLite inspection confirmed `sessions.token_hash` stores the SHA-256 of the cookie token, not the raw token + - `POST /admin/logout` -> `303 See Other` + - `GET /admin` with the old cookie after logout -> `303 See Other` to `/admin/login` + - SQLite inspection confirmed the session row was invalidated + +## Findings + +No blocking findings. + +The stage satisfies the agreed scope: + +- bootstrap admin creation is wired from environment configuration; +- passwords are hashed with bcrypt; +- session tokens are generated securely and stored hashed in SQLite; +- the `/admin` route group is protected by session middleware and role checks; +- login and logout behavior works in live validation, including old-cookie rejection after logout. + +## Required Fixes + +None. + +## Optional Improvements + +- Decide explicitly in a later stage whether the login endpoint should stay generic for any active user session or reject non-admin roles up front for the current admin-only UI. +- Add CSRF protection when the security-hardening stage begins, especially for `POST /admin/logout` and future admin forms. + +## Status + +`APPROVED` diff --git a/docs/agents/validation/04-projects-releases-validation.md b/docs/agents/validation/04-projects-releases-validation.md new file mode 100644 index 0000000..f2153aa --- /dev/null +++ b/docs/agents/validation/04-projects-releases-validation.md @@ -0,0 +1,62 @@ +# Validation Report Template + +## Validator + +Name: Codex Validation Agent + +Stage Reviewed: Agent 04 - Projects, Tags, And Releases + +Date: 2026-04-14 + +## Reviewed Inputs + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/OPERATOR_QUICKSTART_RU.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/04-projects-releases.md` +- previous validation report: `/Users/delete/projects/update_server/docs/agents/validation/03-auth-validation.md` +- implementation files under `internal/db`, `internal/http`, `internal/releases`, `internal/storage`, `internal/slug`, `internal/app`, `internal/config`, and `web/templates` +- `GOCACHE=/tmp/go-build-agent04-validate GOMODCACHE=/tmp/go-mod-agent04-validate go test ./...` -> passed +- `GOCACHE=/tmp/go-build-agent04-validate GOMODCACHE=/tmp/go-mod-agent04-validate go build -o /tmp/update-server-agent04-validate ./cmd/server` -> passed +- `GOCACHE=/tmp/go-build-agent04-validate GOMODCACHE=/tmp/go-mod-agent04-validate go build -o /tmp/update-migrate-agent04-validate ./cmd/migrate` -> passed +- live smoke checks against `127.0.0.1:18083` after starting `/tmp/update-server-agent04-validate` with fresh bootstrap credentials and a fresh `DATA_DIR`: + - `GET /admin/projects` without session -> `303 See Other` to `/admin/login?next=%2Fadmin%2Fprojects` + - `POST /admin/login` -> `303 See Other` with session cookie + - `GET /admin/projects` with session -> `200 OK` + - `POST /admin/projects` -> project created + - `POST /admin/tags` -> tag created + - `POST /admin/projects/1/tags` -> tag attached + - `POST /admin/projects/1/releases` with multipart file upload -> `303 See Other` + - SQLite inspection confirmed project, tag, project-tag link, and release metadata rows + - filesystem inspection confirmed artifact saved under `DATA_DIR/artifacts/...`, outside `web/static` + - checksum of stored artifact matched `releases.checksum_sha256` + - `POST /admin/tags/1/delete` while tag was still attached -> redirected with `tag-in-use` + - `POST /admin/projects/1/archive` -> project became archived in SQLite and UI + +## Findings + +No blocking findings. + +The stage satisfies the agreed scope: + +- protected admin CRUD flows exist for projects and tags; +- project-tag assignment works; +- release uploads are stored under the private artifact directory; +- release metadata is persisted with sanitized filename, checksum, size, content type, storage path, and uploader linkage; +- archive and tag-in-use protection behaviors work in live validation. + +## Required Fixes + +None. + +## Optional Improvements + +- Add an explicit unit or integration test for rejecting uploads that exceed `MAX_UPLOAD_BYTES`. +- Consider surfacing a success flash on the projects list page for newly created items instead of only redirecting to detail pages. +- Add release disable/delete flows in a later stage when the product needs release lifecycle management beyond upload. + +## Status + +`APPROVED` diff --git a/docs/agents/validation/05-api-keys-validation.md b/docs/agents/validation/05-api-keys-validation.md new file mode 100644 index 0000000..e59602b --- /dev/null +++ b/docs/agents/validation/05-api-keys-validation.md @@ -0,0 +1,64 @@ +# Validation Report Template + +## Validator + +Name: Codex Validation Agent + +Stage Reviewed: Agent 05 - API Keys And Access Control + +Date: 2026-04-14 + +## Reviewed Inputs + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/OPERATOR_QUICKSTART_RU.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/05-api-keys.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/05-api-keys-fix.md` +- previous stage validation report: `/Users/delete/projects/update_server/docs/agents/validation/04-projects-releases-validation.md` +- implementation files under `internal/apikeys`, `internal/db`, `internal/http`, `internal/app`, and `web/templates` +- `GOCACHE=/tmp/go-build-agent05-revalidate GOMODCACHE=/tmp/go-mod-agent05-revalidate go test ./...` -> passed +- `GOCACHE=/tmp/go-build-agent05-rebuild GOMODCACHE=/tmp/go-mod-agent05-rebuild go build -o /tmp/update-server-agent05-revalidate ./cmd/server` -> passed +- `GOCACHE=/tmp/go-build-agent05-remigrate GOMODCACHE=/tmp/go-mod-agent05-remigrate go build -o /tmp/update-migrate-agent05-revalidate ./cmd/migrate` -> passed +- live smoke checks against `127.0.0.1:18085` after starting `/tmp/update-server-agent05-revalidate` with a fresh `DATA_DIR`: + - `GET /healthz` -> `200 OK` + - `GET /admin/api-keys` without session -> `303 See Other` to `/admin/login?next=%2Fadmin%2Fapi-keys` + - `POST /admin/login` -> `303 See Other` with session cookie + - `POST /admin/projects` -> project created + - `POST /admin/tags` -> tag created + - `POST /admin/projects/1/tags` -> tag attached + - `POST /admin/api-keys` -> `200 OK` with `Cache-Control: no-store, private, max-age=0`, `Pragma: no-cache`, and `Expires: 0` + - the create response showed the raw key once and also showed the persisted saved values immediately: name, description, selected scope mode, permissions, selected tag rule, and effective access preview + - `GET /admin/api-keys/1` after creation -> `200 OK`, raw key no longer present in HTML + - SQLite inspection confirmed only `key_hash` was stored and confirmed the persisted tag scope row in `api_key_tag_access` + +## Findings + +No blocking findings. + +The previously reported issues are fixed: + +- the secret-bearing creation response is explicitly marked non-cacheable; +- the immediate post-create detail page is hydrated from persisted data while still showing the raw key only on that one response. + +The stage now satisfies the agreed scope: + +- API keys are generated securely and stored hashed; +- admin UI supports create, update, and revoke flows; +- allow-list and deny-list groundwork is in place for both projects and tags; +- middleware and scope evaluation are covered by tests and the create-flow regressions are fixed. + +## Required Fixes + +None. + +## Optional Improvements + +- Consider a future POST-Redirect-GET plus short-lived flash-secret design if you want to avoid browser refresh resubmitting the create form while still keeping the raw key out of persistent storage. +- Later client API validation should explicitly exercise disabled and expired keys through the public `/api/v1` endpoints once Agent 06 wires them in. + +## Status + +`APPROVED` diff --git a/docs/agents/validation/06-client-api-ui-validation.md b/docs/agents/validation/06-client-api-ui-validation.md new file mode 100644 index 0000000..22b44c6 --- /dev/null +++ b/docs/agents/validation/06-client-api-ui-validation.md @@ -0,0 +1,77 @@ +# Validation Report Template + +## Validator + +Name: Codex Validation Agent + +Stage Reviewed: Agent 06 - Client API And Admin UI + +Date: 2026-04-15 + +## Reviewed Inputs + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/OPERATOR_QUICKSTART_RU.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/06-client-api-ui.md` +- previous stage validation report: `/Users/delete/projects/update_server/docs/agents/validation/05-api-keys-validation.md` +- implementation files under `internal/http`, `internal/db`, `internal/releases`, `internal/storage`, `web/templates`, and `web/static` +- `GOCACHE=/tmp/go-build-agent06-validate GOMODCACHE=/tmp/go-mod-agent06-http go test ./...` -> passed +- `GOCACHE=/tmp/go-build-agent06-build-validate GOMODCACHE=/tmp/go-mod-agent06-http go build -o /tmp/update-server-agent06-validate ./cmd/server` -> passed +- `GOCACHE=/tmp/go-build-agent06-migrate-validate GOMODCACHE=/tmp/go-mod-agent06-http go build -o /tmp/update-migrate-agent06-validate ./cmd/migrate` -> passed +- live smoke checks against `127.0.0.1:18086` after starting `/tmp/update-server-agent06-validate` with a fresh `DATA_DIR`: + - `GET /healthz` -> `200 OK` + - `GET /api/v1` -> `200 OK` with public JSON index documenting bearer auth and the client routes + - `GET /api/v1/projects` without bearer token -> `401 Unauthorized` with `WWW-Authenticate: Bearer` + - admin login succeeded and a fresh test dataset was created: + - active projects: `desktop-app`, `mobile-app` + - archived project: `legacy-app` + - releases created for desktop, mobile, and legacy projects + - tag-scoped API key with `can_download` + - API key without `can_download` + - expired API key + - `GET /api/v1/projects` with the allowed tag-scoped key -> `200 OK`, returning only active authorized project `desktop-app` + - `GET /api/v1/projects` with key lacking `can_download` -> `403 Forbidden` + - `GET /api/v1/projects` with expired key -> `401 Unauthorized` + - `GET /api/v1/projects/desktop-app/releases/latest` with allowed key -> `200 OK`, returning latest active desktop release `1.1.0 / build-2` + - `GET /api/v1/projects/mobile-app/releases/latest` with allowed key -> `404 Not Found` + - `GET /api/v1/releases/3` for out-of-scope mobile release -> `404 Not Found` + - `GET /api/v1/releases/4` for archived-project release -> `404 Not Found` + - `GET /api/v1/releases/2` with allowed key -> `200 OK`, returning authorized release metadata + - `GET /api/v1/releases/2/download` with allowed key -> `200 OK`, streamed `desktop-release-1.1.0` with `Content-Disposition: attachment; filename=desktop-app-1.1.0.zip` + - `GET /admin/projects/1` with admin session -> `200 OK`, page showed `/api/v1/projects/desktop-app/releases/latest`, `/api/v1/releases/2`, and `/api/v1/releases/2/download` + - `GET /admin/api-keys/1` with admin session -> `200 OK`, page showed bearer-auth quick-start guidance and accessible client API paths +- SQLite inspection of `/tmp/update-server-agent06-live/db.sqlite` confirmed: + - latest desktop release persisted as release ID `2` + - mobile release persisted as release ID `3` + - archived-project release persisted as release ID `4` + - validation API keys persisted with the expected permission and expiry states + +## Findings + +No blocking findings. + +The stage satisfies the agreed scope: + +- the public `/api/v1` index is usable and documents the client routes; +- bearer-authenticated client endpoints enforce API key authentication and `can_download`; +- client project listing returns only active authorized projects; +- latest-release lookup, metadata lookup, and artifact download work for authorized resources; +- out-of-scope and archived resources are hidden behind `404`; +- downloads stream from private artifact storage instead of exposing static URLs; +- admin pages expose the new client API paths and quick-start guidance. + +## Required Fixes + +None. + +## Optional Improvements + +- If the product later needs richer updater logic, add support for semantic-version comparison or explicit release channels instead of using newest persisted `created_at` as the definition of “latest”. +- Agent 07 should harden the now-working client API with security headers, rate limiting, CSRF coverage for admin POST forms, and deployment-ready reverse proxy guidance. + +## Status + +`APPROVED` diff --git a/docs/agents/validation/07-security-deploy-validation.md b/docs/agents/validation/07-security-deploy-validation.md new file mode 100644 index 0000000..d9407fc --- /dev/null +++ b/docs/agents/validation/07-security-deploy-validation.md @@ -0,0 +1,88 @@ +# Validation Report Template + +## Validator + +Name: Codex Validation Agent + +Stage Reviewed: Agent 07 - Security And Deployment Hardening + +Date: 2026-04-15 + +## Reviewed Inputs + +- `/Users/delete/projects/update_server/docs/agents/WORKFLOW.md` +- `/Users/delete/projects/update_server/PRODUCT_SPEC.md` +- `/Users/delete/projects/update_server/IMPLEMENTATION_PLAN.md` +- `/Users/delete/projects/update_server/DEVELOPMENT_WORKFLOW.md` +- `/Users/delete/projects/update_server/docs/agents/OPERATOR_QUICKSTART_RU.md` +- `/Users/delete/projects/update_server/docs/agents/handoffs/07-security-deploy.md` +- previous stage validation report: `/Users/delete/projects/update_server/docs/agents/validation/06-client-api-ui-validation.md` +- implementation files under `internal/config`, `internal/app`, `internal/db`, `internal/auth`, `internal/http`, `web/templates`, `deploy`, and root deployment files +- `GOCACHE=/tmp/go-build-agent07-validate GOMODCACHE=/tmp/go-mod-agent06-http go test ./...` -> passed +- `GOCACHE=/tmp/go-build-agent07-build GOMODCACHE=/tmp/go-mod-agent06-http go build -o /tmp/update-server-agent07-validate ./cmd/server` -> passed +- `GOCACHE=/tmp/go-build-agent07-migrate GOMODCACHE=/tmp/go-mod-agent06-http go build -o /tmp/update-migrate-agent07-validate ./cmd/migrate` -> passed +- `command -v docker` -> command not available in this environment, so `docker compose config` and container runtime checks could not be executed here +- live smoke checks against an HTTP-configured hardened server on `127.0.0.1:18088`: + - `GET /admin/login` -> `200 OK` with: + - `Cache-Control: no-store, private, max-age=0` + - `Content-Security-Policy` + - `X-Frame-Options: DENY` + - `X-Content-Type-Options: nosniff` + - `Referrer-Policy: no-referrer` + - `Permissions-Policy` + - `Cross-Origin-Opener-Policy: same-origin` + - `Cross-Origin-Resource-Policy: same-origin` + - `Vary: Cookie` + - CSRF cookie scoped to `/admin`, `HttpOnly`, `SameSite=Strict` + - `POST /admin/login` without CSRF token -> `403 Forbidden` + - `POST /admin/login` with valid CSRF token -> `303 See Other` with: + - session cookie scoped to `/admin` + - `HttpOnly` + - `SameSite=Lax` + - rotated CSRF cookie scoped to `/admin` + - `GET /api/v1/projects` without bearer token -> `401 Unauthorized` with: + - `Cache-Control: no-store, private, max-age=0` + - `Vary: Authorization` + - shared security headers + - created a download-capable API key through the hardened admin flow and verified: + - first two `GET /api/v1/projects` requests -> `200 OK` + - third request -> `429 Too Many Requests` with `Retry-After: 1` + - changing `X-Forwarded-For` between those requests did not bypass the rate limit while `TRUST_PROXY_HEADERS` remained false, confirming socket-address behavior in practice +- live smoke checks against an HTTPS-configured hardened server on `127.0.0.1:18089` with `APP_BASE_URL=https://updates.example.com`: + - `GET /admin/login` -> `200 OK` with `Strict-Transport-Security: max-age=31536000` + - CSRF cookie carried `Secure`, `Path=/admin`, `HttpOnly`, `SameSite=Strict` + - `POST /admin/login` with valid CSRF token -> `303 See Other` with session cookie carrying `Secure`, `Path=/admin`, `HttpOnly`, `SameSite=Lax` +- static review of deployment artifacts: + - `Dockerfile` + - `docker-compose.yml` + - `deploy/Caddyfile.example` + - `deploy/nginx.update-server.conf.example` + - `deploy/update-server.env.example` + - `docs/DEPLOYMENT.md` + +## Findings + +No blocking findings. + +The stage satisfies the agreed scope: + +- admin and protected client routes emit sane security defaults; +- CSRF protection is enforced on admin POST flows; +- cookies are scoped to `/admin` and switch to `Secure` when the public base URL is HTTPS; +- login and protected client API rate limits work; +- trusted proxy handling is opt-in; +- deployment and backup or restore documentation is present and coherent with the current architecture; +- artifacts remain private and continue flowing through authenticated application endpoints. + +## Required Fixes + +None. + +## Optional Improvements + +- Run `docker compose up --build` and one reverse-proxy example on the real target host before first production exposure, since Docker was unavailable in this validation environment. +- If you later run multiple app instances, replace the current in-memory rate limiter with a shared store-backed or proxy-backed limiter. + +## Status + +`APPROVED` diff --git a/docs/agents/validation/README.md b/docs/agents/validation/README.md new file mode 100644 index 0000000..1fda0f6 --- /dev/null +++ b/docs/agents/validation/README.md @@ -0,0 +1,13 @@ +# Validation Directory + +Save one validation markdown file here after every validation pass. + +Naming convention: + +- `01-foundation-validation.md` +- `02-database-validation.md` +- `03-auth-validation.md` +- `04-projects-releases-validation.md` +- `05-api-keys-validation.md` +- `06-client-api-ui-validation.md` +- `07-security-deploy-validation.md` diff --git a/example/__pycache__/autoupdate.cpython-314.pyc b/example/__pycache__/autoupdate.cpython-314.pyc new file mode 100644 index 0000000..f7fe6a7 Binary files /dev/null and b/example/__pycache__/autoupdate.cpython-314.pyc differ diff --git a/example/__pycache__/update_client.cpython-314.pyc b/example/__pycache__/update_client.cpython-314.pyc new file mode 100644 index 0000000..94a9c46 Binary files /dev/null and b/example/__pycache__/update_client.cpython-314.pyc differ diff --git a/example/autoupdate.py b/example/autoupdate.py new file mode 100644 index 0000000..2fcb4a4 --- /dev/null +++ b/example/autoupdate.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Minimal self-update helper for Python scripts.""" + +from __future__ import annotations + +import hashlib +import json +import os +import stat +import sys +import tempfile +from pathlib import Path +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urljoin +from urllib.request import Request, urlopen + + +class UpdateError(RuntimeError): + """Raised when the update flow cannot be completed safely.""" + + +ReleaseHandler = Callable[[Path, dict[str, Any]], None] + + +def ensure_updated( + base_url: str, + api_key: str, + project_slug: str, + current_version: str, + apply_update: ReleaseHandler | None = None, +) -> bool: + """ + Check for a newer release and restart the current script after update. + + If `apply_update` is omitted, the current script file is replaced in place. + If `apply_update` is provided, it receives `(downloaded_artifact_path, release_dict)` + and is responsible for applying the downloaded artifact before restart. + """ + if not api_key or api_key == "upsk_replace_me": + raise UpdateError("set a real API key before calling ensure_updated") + + script_path = _current_script_path() + release = _load_latest_release( + base_url=base_url, + api_key=api_key, + project_slug=project_slug, + ) + + latest_version = str(release.get("version") or "") + if not latest_version or latest_version == current_version: + return False + + download_url = urljoin(base_url.rstrip("/") + "/", str(release.get("download_url") or "")) + if not download_url: + raise UpdateError("latest release does not contain a download URL") + + temp_path = _download_release( + url=download_url, + api_key=api_key, + checksum_sha256=str(release.get("checksum_sha256") or ""), + size_bytes=int(release.get("size_bytes") or 0), + target_name=script_path.name, + ) + + try: + if apply_update is None: + _replace_file(temp_path, script_path) + else: + apply_update(temp_path, release) + finally: + temp_path.unlink(missing_ok=True) + + _restart_current_process(script_path) + return True + + +def _current_script_path() -> Path: + main_module = sys.modules.get("__main__") + main_file = getattr(main_module, "__file__", None) + if not main_file: + raise UpdateError("cannot resolve the current script path") + return Path(main_file).resolve() + + +def _make_request(url: str, api_key: str, accept_json: bool = False) -> Request: + headers = { + "Authorization": f"Bearer {api_key}", + "User-Agent": "autoupdate/1.0", + } + if accept_json: + headers["Accept"] = "application/json" + return Request(url, headers=headers) + + +def _load_latest_release( + base_url: str, + api_key: str, + project_slug: str, +) -> dict[str, Any]: + latest_url = urljoin( + base_url.rstrip("/") + "/", + f"api/v1/projects/{quote(project_slug, safe='')}/releases/latest", + ) + request = _make_request(latest_url, api_key, accept_json=True) + + try: + with urlopen(request, timeout=15) as response: + charset = response.headers.get_content_charset() or "utf-8" + payload = json.loads(response.read().decode(charset)) + except HTTPError as exc: + if exc.code == 404: + raise UpdateError(f"no releases found for project '{project_slug}'") from exc + raise UpdateError(f"HTTP {exc.code} while checking updates: {exc.reason}") from exc + except URLError as exc: + raise UpdateError(f"network error while checking updates: {exc.reason}") from exc + + release = payload.get("release") + if not isinstance(release, dict): + raise UpdateError("update server returned an unexpected response") + return release + + +def _download_release( + url: str, + api_key: str, + checksum_sha256: str, + size_bytes: int, + target_name: str, +) -> Path: + request = _make_request(url, api_key) + digest = hashlib.sha256() + written = 0 + tmp_path: Path | None = None + + try: + with urlopen(request, timeout=60) as response: + with tempfile.NamedTemporaryFile( + prefix=target_name + ".", + suffix=".download", + delete=False, + ) as tmp_file: + tmp_path = Path(tmp_file.name) + while True: + chunk = response.read(64 * 1024) + if not chunk: + break + tmp_file.write(chunk) + digest.update(chunk) + written += len(chunk) + + tmp_file.flush() + os.fsync(tmp_file.fileno()) + except HTTPError as exc: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + raise UpdateError(f"HTTP {exc.code} while downloading update: {exc.reason}") from exc + except URLError as exc: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + raise UpdateError(f"network error while downloading update: {exc.reason}") from exc + except Exception: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + raise + + actual_sha256 = digest.hexdigest() + if checksum_sha256 and actual_sha256 != checksum_sha256: + tmp_path.unlink(missing_ok=True) + raise UpdateError( + f"checksum mismatch: expected {checksum_sha256}, got {actual_sha256}" + ) + + if size_bytes and written != size_bytes: + tmp_path.unlink(missing_ok=True) + raise UpdateError(f"size mismatch: expected {size_bytes}, got {written}") + + return tmp_path + + +def _replace_file(downloaded_path: Path, target_path: Path) -> None: + target_path.parent.mkdir(parents=True, exist_ok=True) + + if target_path.exists(): + current_mode = stat.S_IMODE(target_path.stat().st_mode) + os.chmod(downloaded_path, current_mode) + + os.replace(downloaded_path, target_path) + + +def _restart_current_process(script_path: Path) -> None: + os.execv(sys.executable, [sys.executable, str(script_path), *sys.argv[1:]]) diff --git a/example/update_client.py b/example/update_client.py new file mode 100644 index 0000000..3bf6b3d --- /dev/null +++ b/example/update_client.py @@ -0,0 +1,12 @@ +from autoupdate import ensure_updated + +ensure_updated( + base_url="http://127.0.0.1:8080", + api_key="upsk_zdQu4gosIJjvIaQfn5A_ux9msFyDfEnc8c29F0ZCTmk", + project_slug="test", + current_version="0.1.3", +) + + + +print("TEst") diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7f2c2e5 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module update_server + +go 1.26.0 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/mattn/go-sqlite3 v1.14.42 + golang.org/x/crypto v0.50.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f15fb81 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= diff --git a/internal/apikeys/context.go b/internal/apikeys/context.go new file mode 100644 index 0000000..5471c59 --- /dev/null +++ b/internal/apikeys/context.go @@ -0,0 +1,32 @@ +package apikeys + +import ( + "context" + + "update_server/internal/db" +) + +type contextKey string + +const requestContextKey contextKey = "api-key" + +type AuthState struct { + APIKey db.APIKey +} + +func NewContext(ctx context.Context, state *AuthState) context.Context { + if state == nil { + return ctx + } + + return context.WithValue(ctx, requestContextKey, *state) +} + +func FromContext(ctx context.Context) (*AuthState, bool) { + state, ok := ctx.Value(requestContextKey).(AuthState) + if !ok { + return nil, false + } + + return &state, true +} diff --git a/internal/apikeys/service.go b/internal/apikeys/service.go new file mode 100644 index 0000000..1f8fa16 --- /dev/null +++ b/internal/apikeys/service.go @@ -0,0 +1,407 @@ +package apikeys + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "update_server/internal/db" +) + +const ( + rawKeyPrefix = "upsk_" + rawKeyBytes = 32 + rawKeyPreviewLength = 17 + createRetryLimit = 4 +) + +var ( + ErrUnauthenticated = errors.New("api key unauthenticated") + ErrUnauthorized = errors.New("api key unauthorized") +) + +type Permission string + +const ( + PermissionDownload Permission = "can_download" + PermissionUpload Permission = "can_upload" + PermissionDelete Permission = "can_delete" + PermissionManageProjects Permission = "can_manage_projects" +) + +type Service struct { + store *db.Store +} + +type CreateParams struct { + Name string + Description string + ScopeMode db.ScopeMode + CanDownload bool + CanUpload bool + CanDelete bool + CanManageProjects bool + ExpiresAt *time.Time + ProjectIDs []int64 + TagIDs []int64 + CreatedByUserID *int64 +} + +type UpdateParams struct { + Name string + Description string + ScopeMode db.ScopeMode + CanDownload bool + CanUpload bool + CanDelete bool + CanManageProjects bool + ExpiresAt *time.Time + ProjectIDs []int64 + TagIDs []int64 +} + +type CreateResult struct { + APIKey *db.APIKey + RawKey string +} + +func NewService(store *db.Store) *Service { + return &Service{store: store} +} + +func (s *Service) Create(ctx context.Context, params CreateParams) (*CreateResult, error) { + normalized, err := normalizeCreateParams(params) + if err != nil { + return nil, err + } + + for attempt := 0; attempt < createRetryLimit; attempt++ { + rawKey, keyPrefix, keyHash, err := generateAPIKey() + if err != nil { + return nil, err + } + + var created *db.APIKey + err = s.store.WithTx(ctx, func(tx *db.TxStore) error { + inserted, err := tx.APIKeys.Create(ctx, db.CreateAPIKeyParams{ + Name: normalized.Name, + KeyPrefix: keyPrefix, + KeyHash: keyHash, + Description: normalized.Description, + ScopeMode: normalized.ScopeMode, + CanDownload: normalized.CanDownload, + CanUpload: normalized.CanUpload, + CanDelete: normalized.CanDelete, + CanManageProjects: normalized.CanManageProjects, + IsActive: true, + ExpiresAt: normalized.ExpiresAt, + CreatedByUserID: normalized.CreatedByUserID, + }) + if err != nil { + return err + } + + if err := syncScopeAccess(ctx, tx.APIKeys, inserted.ID, normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs); err != nil { + return err + } + + created = inserted + return nil + }) + if err != nil { + if errors.Is(err, db.ErrConflict) { + continue + } + + return nil, fmt.Errorf("create api key: %w", err) + } + + return &CreateResult{ + APIKey: created, + RawKey: rawKey, + }, nil + } + + return nil, fmt.Errorf("create api key: could not generate a unique key") +} + +func (s *Service) Update(ctx context.Context, apiKeyID int64, params UpdateParams) (*db.APIKey, error) { + normalized, err := normalizeUpdateParams(params) + if err != nil { + return nil, err + } + + var updated *db.APIKey + if err := s.store.WithTx(ctx, func(tx *db.TxStore) error { + if _, err := tx.APIKeys.GetByID(ctx, apiKeyID); err != nil { + return err + } + + switch { + case normalized.ScopeMode == db.ScopeModeAllProjects: + if err := tx.APIKeys.ClearProjectAccess(ctx, apiKeyID); err != nil { + return err + } + if err := tx.APIKeys.ClearTagAccess(ctx, apiKeyID); err != nil { + return err + } + case normalized.ScopeMode.UsesProjectRules(): + if err := tx.APIKeys.ClearTagAccess(ctx, apiKeyID); err != nil { + return err + } + case normalized.ScopeMode.UsesTagRules(): + if err := tx.APIKeys.ClearProjectAccess(ctx, apiKeyID); err != nil { + return err + } + } + + record, err := tx.APIKeys.Update(ctx, apiKeyID, db.UpdateAPIKeyParams{ + Name: normalized.Name, + Description: normalized.Description, + ScopeMode: normalized.ScopeMode, + CanDownload: normalized.CanDownload, + CanUpload: normalized.CanUpload, + CanDelete: normalized.CanDelete, + CanManageProjects: normalized.CanManageProjects, + ExpiresAt: normalized.ExpiresAt, + }) + if err != nil { + return err + } + + if err := syncScopeAccess(ctx, tx.APIKeys, apiKeyID, normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs); err != nil { + return err + } + + updated = record + return nil + }); err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, err + } + + return nil, fmt.Errorf("update api key: %w", err) + } + + return s.store.APIKeys.GetByID(ctx, updated.ID) +} + +func (s *Service) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error { + if err := s.store.APIKeys.SetActive(ctx, apiKeyID, isActive); err != nil { + return err + } + + return nil +} + +func (s *Service) Authenticate(ctx context.Context, rawKey string) (*AuthState, error) { + rawKey = strings.TrimSpace(rawKey) + if rawKey == "" { + return nil, ErrUnauthenticated + } + + record, err := s.store.APIKeys.GetByHash(ctx, hashAPIKey(rawKey)) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, ErrUnauthenticated + } + + return nil, fmt.Errorf("lookup api key: %w", err) + } + + now := time.Now().UTC() + if !record.IsActive || record.Expired(now) { + return nil, ErrUnauthenticated + } + + if err := s.store.APIKeys.TouchLastUsedAt(ctx, record.ID, now); err != nil { + return nil, fmt.Errorf("touch api key last_used_at: %w", err) + } + + record.LastUsedAt = &now + + return &AuthState{APIKey: *record}, nil +} + +func (s *Service) List(ctx context.Context) ([]db.APIKeyListItem, error) { + return s.store.APIKeys.List(ctx) +} + +func (s *Service) GetByID(ctx context.Context, apiKeyID int64) (*db.APIKey, error) { + return s.store.APIKeys.GetByID(ctx, apiKeyID) +} + +func (s *Service) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]db.Project, error) { + return s.store.APIKeys.ListProjectAccess(ctx, apiKeyID) +} + +func (s *Service) ListTagAccess(ctx context.Context, apiKeyID int64) ([]db.Tag, error) { + return s.store.APIKeys.ListTagAccess(ctx, apiKeyID) +} + +func (s *Service) ListAccessibleProjects(ctx context.Context, apiKey db.APIKey) ([]db.Project, error) { + return s.store.APIKeys.ListAccessibleProjects(ctx, apiKey.ID, apiKey.ScopeMode) +} + +func (s *Service) CanAccessProject(ctx context.Context, apiKey db.APIKey, projectID int64) (bool, error) { + return s.store.APIKeys.HasProjectAccess(ctx, apiKey.ID, apiKey.ScopeMode, projectID) +} + +func HasPermission(apiKey db.APIKey, permission Permission) bool { + switch permission { + case PermissionDownload: + return apiKey.CanDownload + case PermissionUpload: + return apiKey.CanUpload + case PermissionDelete: + return apiKey.CanDelete + case PermissionManageProjects: + return apiKey.CanManageProjects + default: + return false + } +} + +func generateAPIKey() (rawKey string, keyPrefix string, keyHash string, err error) { + bytes := make([]byte, rawKeyBytes) + if _, err := rand.Read(bytes); err != nil { + return "", "", "", fmt.Errorf("generate api key: %w", err) + } + + body := base64.RawURLEncoding.EncodeToString(bytes) + rawKey = rawKeyPrefix + body + + keyPrefix = rawKey + if len(keyPrefix) > rawKeyPreviewLength { + keyPrefix = keyPrefix[:rawKeyPreviewLength] + } + + return rawKey, keyPrefix, hashAPIKey(rawKey), nil +} + +func hashAPIKey(rawKey string) string { + sum := sha256.Sum256([]byte(rawKey)) + return hex.EncodeToString(sum[:]) +} + +func syncScopeAccess(ctx context.Context, repo *db.APIKeyRepository, apiKeyID int64, scopeMode db.ScopeMode, projectIDs []int64, tagIDs []int64) error { + switch { + case scopeMode == db.ScopeModeAllProjects: + if err := repo.ClearProjectAccess(ctx, apiKeyID); err != nil { + return err + } + if err := repo.ClearTagAccess(ctx, apiKeyID); err != nil { + return err + } + case scopeMode.UsesProjectRules(): + if err := repo.ClearTagAccess(ctx, apiKeyID); err != nil { + return err + } + if err := repo.ReplaceProjectAccess(ctx, apiKeyID, projectIDs); err != nil { + return err + } + case scopeMode.UsesTagRules(): + if err := repo.ClearProjectAccess(ctx, apiKeyID); err != nil { + return err + } + if err := repo.ReplaceTagAccess(ctx, apiKeyID, tagIDs); err != nil { + return err + } + default: + return fmt.Errorf("unsupported scope mode %q", scopeMode) + } + + return nil +} + +func normalizeCreateParams(params CreateParams) (CreateParams, error) { + normalized := params + normalized.Name = strings.TrimSpace(normalized.Name) + normalized.Description = strings.TrimSpace(normalized.Description) + + switch { + case normalized.Name == "": + return CreateParams{}, fmt.Errorf("api key name is required") + case !normalized.ScopeMode.Valid(): + return CreateParams{}, fmt.Errorf("api key scope mode is required") + case !hasAnyPermission(normalized): + return CreateParams{}, fmt.Errorf("select at least one permission") + } + + normalized.ProjectIDs, normalized.TagIDs = normalizedScopeIDs(normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs) + return normalized, nil +} + +func normalizeUpdateParams(params UpdateParams) (UpdateParams, error) { + normalized := params + normalized.Name = strings.TrimSpace(normalized.Name) + normalized.Description = strings.TrimSpace(normalized.Description) + + switch { + case normalized.Name == "": + return UpdateParams{}, fmt.Errorf("api key name is required") + case !normalized.ScopeMode.Valid(): + return UpdateParams{}, fmt.Errorf("api key scope mode is required") + case !hasAnyPermission(normalized): + return UpdateParams{}, fmt.Errorf("select at least one permission") + } + + normalized.ProjectIDs, normalized.TagIDs = normalizedScopeIDs(normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs) + return normalized, nil +} + +func normalizedScopeIDs(scopeMode db.ScopeMode, projectIDs []int64, tagIDs []int64) ([]int64, []int64) { + switch { + case scopeMode.UsesProjectRules(): + return dedupeIDs(projectIDs), nil + case scopeMode.UsesTagRules(): + return nil, dedupeIDs(tagIDs) + default: + return nil, nil + } +} + +func dedupeIDs(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + deduped := make([]int64, 0, len(values)) + + for _, value := range values { + if value <= 0 { + continue + } + + if _, ok := seen[value]; ok { + continue + } + + seen[value] = struct{}{} + deduped = append(deduped, value) + } + + return deduped +} + +func hasAnyPermission(params interface { + GetCanDownload() bool + GetCanUpload() bool + GetCanDelete() bool + GetCanManageProjects() bool +}) bool { + return params.GetCanDownload() || params.GetCanUpload() || params.GetCanDelete() || params.GetCanManageProjects() +} + +func (p CreateParams) GetCanDownload() bool { return p.CanDownload } +func (p CreateParams) GetCanUpload() bool { return p.CanUpload } +func (p CreateParams) GetCanDelete() bool { return p.CanDelete } +func (p CreateParams) GetCanManageProjects() bool { return p.CanManageProjects } + +func (p UpdateParams) GetCanDownload() bool { return p.CanDownload } +func (p UpdateParams) GetCanUpload() bool { return p.CanUpload } +func (p UpdateParams) GetCanDelete() bool { return p.CanDelete } +func (p UpdateParams) GetCanManageProjects() bool { return p.CanManageProjects } diff --git a/internal/apikeys/service_test.go b/internal/apikeys/service_test.go new file mode 100644 index 0000000..bee716f --- /dev/null +++ b/internal/apikeys/service_test.go @@ -0,0 +1,316 @@ +package apikeys_test + +import ( + "context" + "errors" + "path/filepath" + "runtime" + "testing" + "time" + + "update_server/internal/apikeys" + "update_server/internal/db" +) + +func TestServiceCreateAuthenticateAndRejectInactiveOrExpiredKeys(t *testing.T) { + t.Parallel() + + service, store := newAPIKeyTestService(t) + ctx := context.Background() + + project := createProject(t, ctx, store, "Desktop App", "desktop-app") + + created, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Desktop Clients", + ScopeMode: db.ScopeModeProjectAllowList, + CanDownload: true, + ProjectIDs: []int64{project.ID}, + }) + if err != nil { + t.Fatalf("create api key: %v", err) + } + + stored, err := store.APIKeys.GetByID(ctx, created.APIKey.ID) + if err != nil { + t.Fatalf("load stored api key: %v", err) + } + + if stored.KeyHash == created.RawKey { + t.Fatal("expected raw api key to be hashed before storage") + } + + if stored.KeyPrefix == created.RawKey { + t.Fatal("expected only a short key prefix to be stored") + } + + authState, err := service.Authenticate(ctx, created.RawKey) + if err != nil { + t.Fatalf("authenticate api key: %v", err) + } + + if authState.APIKey.ID != created.APIKey.ID { + t.Fatalf("expected authenticated api key id %d, got %d", created.APIKey.ID, authState.APIKey.ID) + } + + stored, err = store.APIKeys.GetByID(ctx, created.APIKey.ID) + if err != nil { + t.Fatalf("reload stored api key: %v", err) + } + + if stored.LastUsedAt == nil { + t.Fatal("expected successful authentication to update last_used_at") + } + + expiredAt := time.Now().UTC().Add(-time.Hour) + expired, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Expired Clients", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + ExpiresAt: &expiredAt, + }) + if err != nil { + t.Fatalf("create expired api key: %v", err) + } + + if _, err := service.Authenticate(ctx, expired.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) { + t.Fatalf("expected expired api key to be rejected, got %v", err) + } + + revoked, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Revoked Clients", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + }) + if err != nil { + t.Fatalf("create revoked api key: %v", err) + } + + if err := service.SetActive(ctx, revoked.APIKey.ID, false); err != nil { + t.Fatalf("revoke api key: %v", err) + } + + if _, err := service.Authenticate(ctx, revoked.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) { + t.Fatalf("expected revoked api key to be rejected, got %v", err) + } +} + +func TestServiceListAccessibleProjectsByScopeMode(t *testing.T) { + t.Parallel() + + service, store := newAPIKeyTestService(t) + ctx := context.Background() + + desktop := createProject(t, ctx, store, "Desktop App", "desktop-app") + mobile := createProject(t, ctx, store, "Mobile App", "mobile-app") + internal := createProject(t, ctx, store, "Internal App", "internal-app") + archived := createProject(t, ctx, store, "Archived App", "archived-app") + + windows := createTag(t, ctx, store, "Windows", "windows") + beta := createTag(t, ctx, store, "Beta", "beta") + + attachTag(t, ctx, store, desktop.ID, windows.ID) + attachTag(t, ctx, store, mobile.ID, beta.ID) + attachTag(t, ctx, store, archived.ID, windows.ID) + + if err := store.Projects.SetActive(ctx, archived.ID, false); err != nil { + t.Fatalf("archive project: %v", err) + } + + allProjectsKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "All Projects", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + }) + if err != nil { + t.Fatalf("create all projects key: %v", err) + } + + projectAllowKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Project Allow", + ScopeMode: db.ScopeModeProjectAllowList, + CanDownload: true, + ProjectIDs: []int64{mobile.ID, internal.ID}, + }) + if err != nil { + t.Fatalf("create project allow key: %v", err) + } + + projectDenyKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Project Deny", + ScopeMode: db.ScopeModeProjectDenyList, + CanDownload: true, + ProjectIDs: []int64{mobile.ID}, + }) + if err != nil { + t.Fatalf("create project deny key: %v", err) + } + + tagAllowKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Tag Allow", + ScopeMode: db.ScopeModeTagAllowList, + CanDownload: true, + TagIDs: []int64{windows.ID}, + }) + if err != nil { + t.Fatalf("create tag allow key: %v", err) + } + + tagDenyKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Tag Deny", + ScopeMode: db.ScopeModeTagDenyList, + CanDownload: true, + TagIDs: []int64{windows.ID}, + }) + if err != nil { + t.Fatalf("create tag deny key: %v", err) + } + + assertAccessibleProjects(t, ctx, service, *allProjectsKey.APIKey, []string{"Desktop App", "Internal App", "Mobile App"}) + assertAccessibleProjects(t, ctx, service, *projectAllowKey.APIKey, []string{"Internal App", "Mobile App"}) + assertAccessibleProjects(t, ctx, service, *projectDenyKey.APIKey, []string{"Desktop App", "Internal App"}) + assertAccessibleProjects(t, ctx, service, *tagAllowKey.APIKey, []string{"Desktop App"}) + assertAccessibleProjects(t, ctx, service, *tagDenyKey.APIKey, []string{"Internal App", "Mobile App"}) +} + +func TestServiceUpdateTransitionsScopeRules(t *testing.T) { + t.Parallel() + + service, store := newAPIKeyTestService(t) + ctx := context.Background() + + project := createProject(t, ctx, store, "Desktop App", "desktop-app") + otherProject := createProject(t, ctx, store, "Mobile App", "mobile-app") + windows := createTag(t, ctx, store, "Windows", "windows") + attachTag(t, ctx, store, project.ID, windows.ID) + + created, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Transition Key", + ScopeMode: db.ScopeModeProjectAllowList, + CanDownload: true, + ProjectIDs: []int64{otherProject.ID}, + }) + if err != nil { + t.Fatalf("create api key: %v", err) + } + + updated, err := service.Update(ctx, created.APIKey.ID, apikeys.UpdateParams{ + Name: "Transition Key", + ScopeMode: db.ScopeModeTagAllowList, + CanDownload: true, + TagIDs: []int64{windows.ID}, + }) + if err != nil { + t.Fatalf("update api key scope: %v", err) + } + + projectAccess, err := service.ListProjectAccess(ctx, updated.ID) + if err != nil { + t.Fatalf("list project access: %v", err) + } + + if len(projectAccess) != 0 { + t.Fatalf("expected project access rows to be cleared, got %d", len(projectAccess)) + } + + tagAccess, err := service.ListTagAccess(ctx, updated.ID) + if err != nil { + t.Fatalf("list tag access: %v", err) + } + + if len(tagAccess) != 1 || tagAccess[0].ID != windows.ID { + t.Fatalf("expected one retained tag access row, got %+v", tagAccess) + } + + assertAccessibleProjects(t, ctx, service, *updated, []string{"Desktop App"}) +} + +func assertAccessibleProjects(t *testing.T, ctx context.Context, service *apikeys.Service, key db.APIKey, want []string) { + t.Helper() + + projects, err := service.ListAccessibleProjects(ctx, key) + if err != nil { + t.Fatalf("list accessible projects: %v", err) + } + + got := make([]string, 0, len(projects)) + for _, project := range projects { + got = append(got, project.Name) + } + + if len(got) != len(want) { + t.Fatalf("expected accessible projects %v, got %v", want, got) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected accessible projects %v, got %v", want, got) + } + } +} + +func newAPIKeyTestService(t *testing.T) (*apikeys.Service, *db.Store) { + t.Helper() + + ctx := context.Background() + sqlitePath := filepath.Join(t.TempDir(), "apikeys.sqlite") + + database, err := db.Open(ctx, sqlitePath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.Migrate(ctx, database, apiKeyProjectPath(t, "migrations")); err != nil { + _ = database.Close() + t.Fatalf("migrate sqlite: %v", err) + } + + store := db.NewStore(database) + t.Cleanup(func() { + _ = store.Close() + }) + + return apikeys.NewService(store), store +} + +func apiKeyProjectPath(t *testing.T, parts ...string) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve caller path") + } + + root := filepath.Join(filepath.Dir(filename), "..", "..") + items := append([]string{root}, parts...) + return filepath.Join(items...) +} + +func createProject(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Project { + t.Helper() + + project, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: name, Slug: slug}) + if err != nil { + t.Fatalf("create project %s: %v", name, err) + } + + return project +} + +func createTag(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Tag { + t.Helper() + + tag, err := store.Tags.Create(ctx, db.CreateTagParams{Name: name, Slug: slug}) + if err != nil { + t.Fatalf("create tag %s: %v", name, err) + } + + return tag +} + +func attachTag(t *testing.T, ctx context.Context, store *db.Store, projectID, tagID int64) { + t.Helper() + + if err := store.Projects.AttachTag(ctx, projectID, tagID); err != nil { + t.Fatalf("attach tag %d to project %d: %v", tagID, projectID, err) + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..8bdab0a --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,142 @@ +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "update_server/internal/apikeys" + "update_server/internal/auth" + "update_server/internal/config" + database "update_server/internal/db" + httpserver "update_server/internal/http" + "update_server/internal/releases" + "update_server/internal/storage" +) + +type App struct { + config config.Config + logger *slog.Logger + server *http.Server + store *database.Store +} + +func New(cfg config.Config, logger *slog.Logger) (*App, error) { + if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + + renderer, err := httpserver.NewRenderer(cfg.TemplatesDir) + if err != nil { + return nil, fmt.Errorf("create renderer: %w", err) + } + + sqliteDB, err := database.Open(context.Background(), cfg.SQLitePath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + if err := database.Migrate(context.Background(), sqliteDB, cfg.MigrationsDir); err != nil { + sqliteDB.Close() + return nil, fmt.Errorf("apply migrations: %w", err) + } + + store := database.NewStore(sqliteDB) + authService := auth.NewService(cfg, logger, store) + apiKeyService := apikeys.NewService(store) + if err := authService.EnsureBootstrapAdmin(context.Background()); err != nil { + sqliteDB.Close() + return nil, fmt.Errorf("bootstrap admin auth: %w", err) + } + + artifactStore, err := storage.NewLocal(cfg.ArtifactsDir) + if err != nil { + sqliteDB.Close() + return nil, fmt.Errorf("create artifact storage: %w", err) + } + + releaseService := releases.NewService(store, artifactStore) + router := httpserver.NewRouter(cfg, logger, renderer, store, authService, apiKeyService, releaseService) + + server := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: router, + ReadTimeout: cfg.ReadTimeout, + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, + MaxHeaderBytes: cfg.MaxHeaderBytes, + } + + return &App{ + config: cfg, + logger: logger, + server: server, + store: store, + }, nil +} + +func (a *App) Run(ctx context.Context) (runErr error) { + defer func() { + if a.store == nil { + return + } + + if err := a.store.Close(); err != nil { + closeErr := fmt.Errorf("close database: %w", err) + if runErr != nil { + runErr = errors.Join(runErr, closeErr) + return + } + + runErr = closeErr + } + }() + + runCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + serverErr := make(chan error, 1) + + go func() { + a.logger.Info("starting server", + "addr", a.config.HTTPAddr, + "base_url", a.config.BaseURL, + "data_dir", a.config.DataDir, + "sqlite_path", a.config.SQLitePath, + ) + + serverErr <- a.server.ListenAndServe() + }() + + select { + case err := <-serverErr: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil + case <-runCtx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), a.config.ShutdownTimeout) + defer cancel() + + a.logger.Info("shutting down server") + + if err := a.server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown server: %w", err) + } + + err := <-serverErr + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil +} diff --git a/internal/auth/context.go b/internal/auth/context.go new file mode 100644 index 0000000..be4360c --- /dev/null +++ b/internal/auth/context.go @@ -0,0 +1,16 @@ +package auth + +import "context" + +type contextKey string + +const sessionStateKey contextKey = "auth.session-state" + +func NewContext(ctx context.Context, state *SessionState) context.Context { + return context.WithValue(ctx, sessionStateKey, state) +} + +func FromContext(ctx context.Context) (*SessionState, bool) { + state, ok := ctx.Value(sessionStateKey).(*SessionState) + return state, ok +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..a84c3ae --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,35 @@ +package auth + +import ( + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +const bcryptCost = 12 + +func HashPassword(password string) (string, error) { + if strings.TrimSpace(password) == "" { + return "", fmt.Errorf("password is required") + } + + if len(password) > 72 { + return "", fmt.Errorf("password must be 72 bytes or fewer") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + + return string(hashedPassword), nil +} + +func ComparePassword(hash, password string) error { + if hash == "" { + return fmt.Errorf("password hash is required") + } + + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..7238b6c --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,259 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "update_server/internal/config" + "update_server/internal/db" +) + +const sessionTokenBytes = 32 +const sessionCookiePath = "/admin" + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUnauthenticated = errors.New("unauthenticated") + ErrUnauthorized = errors.New("unauthorized") +) + +type Service struct { + config config.Config + logger *slog.Logger + store *db.Store +} + +type SessionState struct { + User db.User + Session db.Session +} + +func NewService(cfg config.Config, logger *slog.Logger, store *db.Store) *Service { + return &Service{ + config: cfg, + logger: logger, + store: store, + } +} + +func (s *Service) EnsureBootstrapAdmin(ctx context.Context) error { + hasActiveAdmin, err := s.store.Users.HasActiveAdmin(ctx) + if err != nil { + return fmt.Errorf("check active admin users: %w", err) + } + + if hasActiveAdmin { + return nil + } + + email := strings.TrimSpace(s.config.AdminEmail) + password := s.config.AdminPassword + if email == "" || strings.TrimSpace(password) == "" { + if s.logger != nil { + s.logger.Warn("no active admin user found; set ADMIN_EMAIL and ADMIN_PASSWORD to bootstrap the first admin") + } + + return nil + } + + if existingUser, err := s.store.Users.GetByEmail(ctx, email); err == nil { + if s.logger != nil { + s.logger.Warn("bootstrap admin skipped because the configured email already exists", "email", existingUser.Email) + } + + return nil + } else if !errors.Is(err, db.ErrNotFound) { + return fmt.Errorf("check bootstrap admin email: %w", err) + } + + passwordHash, err := HashPassword(password) + if err != nil { + return fmt.Errorf("hash bootstrap admin password: %w", err) + } + + if _, err := s.store.Users.Create(ctx, db.CreateUserParams{ + Email: email, + PasswordHash: passwordHash, + Role: db.UserRoleAdmin, + IsActive: true, + }); err != nil { + return fmt.Errorf("create bootstrap admin user: %w", err) + } + + if s.logger != nil { + s.logger.Info("bootstrapped admin user", "email", email) + } + + return nil +} + +func (s *Service) Authenticate(ctx context.Context, email, password, ipAddress, userAgent string) (string, *SessionState, error) { + email = strings.TrimSpace(email) + if email == "" || password == "" { + return "", nil, ErrInvalidCredentials + } + + user, err := s.store.Users.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return "", nil, ErrInvalidCredentials + } + + return "", nil, fmt.Errorf("load user by email: %w", err) + } + + if !user.IsActive { + return "", nil, ErrInvalidCredentials + } + + if err := ComparePassword(user.PasswordHash, password); err != nil { + return "", nil, ErrInvalidCredentials + } + + now := time.Now().UTC() + expiresAt := now.Add(s.config.SessionTTL) + token, tokenHash, err := generateSessionToken() + if err != nil { + return "", nil, err + } + + var session *db.Session + if err := s.store.WithTx(ctx, func(tx *db.TxStore) error { + createdSession, err := tx.Sessions.Create(ctx, db.CreateSessionParams{ + UserID: user.ID, + TokenHash: tokenHash, + ExpiresAt: expiresAt, + IPAddress: ipAddress, + UserAgent: userAgent, + }) + if err != nil { + return err + } + + if err := tx.Users.UpdateLastLoginAt(ctx, user.ID, now); err != nil { + return err + } + + session = createdSession + return nil + }); err != nil { + return "", nil, fmt.Errorf("create authenticated session: %w", err) + } + + user.LastLoginAt = &now + + return token, &SessionState{ + User: *user, + Session: *session, + }, nil +} + +func (s *Service) LoadSession(ctx context.Context, token string) (*SessionState, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, ErrUnauthenticated + } + + now := time.Now().UTC() + record, err := s.store.Sessions.GetActiveWithUserByTokenHash(ctx, hashSessionToken(token), now) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, ErrUnauthenticated + } + + return nil, fmt.Errorf("lookup active session: %w", err) + } + + if err := s.store.Sessions.Touch(ctx, record.Session.ID, now); err != nil { + return nil, fmt.Errorf("touch active session: %w", err) + } + + record.Session.LastSeenAt = &now + + return &SessionState{ + User: record.User, + Session: record.Session, + }, nil +} + +func (s *Service) InvalidateSession(ctx context.Context, token string) error { + token = strings.TrimSpace(token) + if token == "" { + return nil + } + + if err := s.store.Sessions.InvalidateByTokenHash(ctx, hashSessionToken(token), time.Now().UTC()); err != nil && !errors.Is(err, db.ErrNotFound) { + return fmt.Errorf("invalidate session: %w", err) + } + + return nil +} + +func (s *Service) SessionCookie(token string, expiresAt time.Time) *http.Cookie { + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + + return &http.Cookie{ + Name: s.config.SessionCookieName, + Value: token, + Path: sessionCookiePath, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.config.SecureCookies, + Expires: expiresAt.UTC(), + MaxAge: maxAge, + } +} + +func (s *Service) ClearSessionCookie() *http.Cookie { + return &http.Cookie{ + Name: s.config.SessionCookieName, + Value: "", + Path: sessionCookiePath, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.config.SecureCookies, + Expires: time.Unix(0, 0).UTC(), + MaxAge: -1, + } +} + +func (s *Service) SessionCookieName() string { + return s.config.SessionCookieName +} + +func RoleAllowed(actualRole, requiredRole db.UserRole) bool { + ranks := map[db.UserRole]int{ + db.UserRoleViewer: 1, + db.UserRoleEditor: 2, + db.UserRoleAdmin: 3, + } + + return ranks[actualRole] >= ranks[requiredRole] && ranks[requiredRole] > 0 +} + +func generateSessionToken() (string, string, error) { + bytes := make([]byte, sessionTokenBytes) + if _, err := rand.Read(bytes); err != nil { + return "", "", fmt.Errorf("generate session token: %w", err) + } + + token := base64.RawURLEncoding.EncodeToString(bytes) + return token, hashSessionToken(token), nil +} + +func hashSessionToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go new file mode 100644 index 0000000..5e76c12 --- /dev/null +++ b/internal/auth/service_test.go @@ -0,0 +1,101 @@ +package auth_test + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "runtime" + "testing" + "time" + + "update_server/internal/auth" + "update_server/internal/config" + "update_server/internal/db" +) + +func TestEnsureBootstrapAdminCreatesSingleAdminUser(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := newTestStore(t) + defer store.Close() + + cfg := config.Config{ + AdminEmail: "admin@example.com", + AdminPassword: "correct horse battery staple", + SessionCookieName: "update_server_session", + SessionTTL: 24 * time.Hour, + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + service := auth.NewService(cfg, logger, store) + + if err := service.EnsureBootstrapAdmin(ctx); err != nil { + t.Fatalf("bootstrap admin: %v", err) + } + + if err := service.EnsureBootstrapAdmin(ctx); err != nil { + t.Fatalf("bootstrap admin second pass: %v", err) + } + + user, err := store.Users.GetByEmail(ctx, cfg.AdminEmail) + if err != nil { + t.Fatalf("load bootstrapped admin: %v", err) + } + + if user.Role != db.UserRoleAdmin { + t.Fatalf("expected admin role, got %q", user.Role) + } + + if !user.IsActive { + t.Fatal("expected bootstrapped admin to be active") + } + + if user.PasswordHash == cfg.AdminPassword { + t.Fatal("expected bootstrapped password to be hashed") + } + + if err := auth.ComparePassword(user.PasswordHash, cfg.AdminPassword); err != nil { + t.Fatalf("compare hashed password: %v", err) + } + + var userCount int + if err := store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { + t.Fatalf("count users: %v", err) + } + + if userCount != 1 { + t.Fatalf("expected one bootstrapped user, got %d", userCount) + } +} + +func newTestStore(t *testing.T) *db.Store { + t.Helper() + + sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite") + database, err := db.Open(context.Background(), sqlitePath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.Migrate(context.Background(), database, testProjectPath(t, "migrations")); err != nil { + database.Close() + t.Fatalf("migrate sqlite: %v", err) + } + + return db.NewStore(database) +} + +func testProjectPath(t *testing.T, parts ...string) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve caller path") + } + + root := filepath.Join(filepath.Dir(filename), "..", "..") + items := append([]string{root}, parts...) + return filepath.Join(items...) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2d235a9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,347 @@ +package config + +import ( + "fmt" + "log/slog" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" +) + +const appName = "Update Server" + +type Config struct { + AppName string + HTTPAddr string + BaseURL string + DataDir string + SQLitePath string + ArtifactsDir string + MigrationsDir string + TemplatesDir string + StaticDir string + MaxUploadBytes int64 + AdminEmail string + AdminPassword string + SessionCookieName string + CSRFCookieName string + SessionTTL time.Duration + SecureCookies bool + TrustProxyHeaders bool + ReadTimeout time.Duration + ReadHeaderTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + ShutdownTimeout time.Duration + MaxHeaderBytes int + LoginRateLimitPerMinute int + LoginRateLimitBurst int + ClientRateLimitPerMinute int + ClientRateLimitBurst int + LogLevel slog.Level +} + +func Load() (Config, error) { + baseURL := getenv("APP_BASE_URL", "http://127.0.0.1:8080") + parsedBaseURL, err := validateBaseURL(baseURL) + if err != nil { + return Config{}, err + } + + dataDir, err := filepath.Abs(getenv("DATA_DIR", "data-dev")) + if err != nil { + return Config{}, fmt.Errorf("resolve DATA_DIR: %w", err) + } + + sqlitePath, err := resolvePath("SQLITE_PATH", filepath.Join(dataDir, "db.sqlite")) + if err != nil { + return Config{}, err + } + + artifactsDir, err := resolvePath("ARTIFACTS_DIR", filepath.Join(dataDir, "artifacts")) + if err != nil { + return Config{}, err + } + + migrationsDir, err := resolvePath("MIGRATIONS_DIR", "migrations") + if err != nil { + return Config{}, err + } + + templatesDir, err := resolvePath("TEMPLATES_DIR", "web/templates") + if err != nil { + return Config{}, err + } + + staticDir, err := resolvePath("STATIC_DIR", "web/static") + if err != nil { + return Config{}, err + } + + adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminEmail == "" && strings.TrimSpace(adminPassword) != "" { + return Config{}, fmt.Errorf("ADMIN_PASSWORD requires ADMIN_EMAIL") + } + + if adminEmail != "" && strings.TrimSpace(adminPassword) == "" { + return Config{}, fmt.Errorf("ADMIN_EMAIL requires ADMIN_PASSWORD") + } + + sessionCookieName := getenv("SESSION_COOKIE_NAME", "update_server_session") + if strings.TrimSpace(sessionCookieName) == "" { + return Config{}, fmt.Errorf("SESSION_COOKIE_NAME must not be empty") + } + + csrfCookieName := getenv("CSRF_COOKIE_NAME", "update_server_csrf") + if strings.TrimSpace(csrfCookieName) == "" { + return Config{}, fmt.Errorf("CSRF_COOKIE_NAME must not be empty") + } + + sessionTTL, err := parseDuration("SESSION_TTL", "24h") + if err != nil { + return Config{}, err + } + + if sessionTTL <= 0 { + return Config{}, fmt.Errorf("SESSION_TTL must be greater than zero") + } + + maxUploadBytes, err := parseInt64("MAX_UPLOAD_BYTES", 1<<30) + if err != nil { + return Config{}, err + } + + if maxUploadBytes <= 0 { + return Config{}, fmt.Errorf("MAX_UPLOAD_BYTES must be greater than zero") + } + + readTimeout, err := parseDuration("APP_READ_TIMEOUT", "10s") + if err != nil { + return Config{}, err + } + + readHeaderTimeout, err := parseDuration("APP_READ_HEADER_TIMEOUT", "5s") + if err != nil { + return Config{}, err + } + + writeTimeout, err := parseDuration("APP_WRITE_TIMEOUT", "60s") + if err != nil { + return Config{}, err + } + + idleTimeout, err := parseDuration("APP_IDLE_TIMEOUT", "60s") + if err != nil { + return Config{}, err + } + + shutdownTimeout, err := parseDuration("APP_SHUTDOWN_TIMEOUT", "10s") + if err != nil { + return Config{}, err + } + + maxHeaderBytes, err := parseInt("APP_MAX_HEADER_BYTES", 1<<20) + if err != nil { + return Config{}, err + } + + if maxHeaderBytes <= 0 { + return Config{}, fmt.Errorf("APP_MAX_HEADER_BYTES must be greater than zero") + } + + trustProxyHeaders, err := parseBool("TRUST_PROXY_HEADERS", false) + if err != nil { + return Config{}, err + } + + loginRateLimitPerMinute, err := parseInt("APP_LOGIN_RATE_LIMIT_PER_MINUTE", 10) + if err != nil { + return Config{}, err + } + + loginRateLimitBurst, err := parseInt("APP_LOGIN_RATE_LIMIT_BURST", 5) + if err != nil { + return Config{}, err + } + + clientRateLimitPerMinute, err := parseInt("APP_CLIENT_RATE_LIMIT_PER_MINUTE", 120) + if err != nil { + return Config{}, err + } + + clientRateLimitBurst, err := parseInt("APP_CLIENT_RATE_LIMIT_BURST", 60) + if err != nil { + return Config{}, err + } + + for key, value := range map[string]int{ + "APP_LOGIN_RATE_LIMIT_PER_MINUTE": loginRateLimitPerMinute, + "APP_LOGIN_RATE_LIMIT_BURST": loginRateLimitBurst, + "APP_CLIENT_RATE_LIMIT_PER_MINUTE": clientRateLimitPerMinute, + "APP_CLIENT_RATE_LIMIT_BURST": clientRateLimitBurst, + } { + if value <= 0 { + return Config{}, fmt.Errorf("%s must be greater than zero", key) + } + } + + logLevel, err := parseLogLevel(getenv("APP_LOG_LEVEL", "INFO")) + if err != nil { + return Config{}, err + } + + if pathWithin(artifactsDir, staticDir) { + return Config{}, fmt.Errorf("ARTIFACTS_DIR must be outside STATIC_DIR") + } + + return Config{ + AppName: appName, + HTTPAddr: getenv("APP_ADDR", ":8080"), + BaseURL: baseURL, + DataDir: dataDir, + SQLitePath: sqlitePath, + ArtifactsDir: artifactsDir, + MigrationsDir: migrationsDir, + TemplatesDir: templatesDir, + StaticDir: staticDir, + MaxUploadBytes: maxUploadBytes, + AdminEmail: adminEmail, + AdminPassword: adminPassword, + SessionCookieName: sessionCookieName, + CSRFCookieName: csrfCookieName, + SessionTTL: sessionTTL, + SecureCookies: strings.EqualFold(parsedBaseURL.Scheme, "https"), + TrustProxyHeaders: trustProxyHeaders, + ReadTimeout: readTimeout, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + ShutdownTimeout: shutdownTimeout, + MaxHeaderBytes: maxHeaderBytes, + LoginRateLimitPerMinute: loginRateLimitPerMinute, + LoginRateLimitBurst: loginRateLimitBurst, + ClientRateLimitPerMinute: clientRateLimitPerMinute, + ClientRateLimitBurst: clientRateLimitBurst, + LogLevel: logLevel, + }, nil +} + +func getenv(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + + return fallback +} + +func validateBaseURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("parse APP_BASE_URL: %w", err) + } + + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("APP_BASE_URL must include scheme and host, got %q", raw) + } + + if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") { + return nil, fmt.Errorf("APP_BASE_URL scheme must be http or https, got %q", parsed.Scheme) + } + + return parsed, nil +} + +func resolvePath(key, fallback string) (string, error) { + path, err := filepath.Abs(getenv(key, fallback)) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", key, err) + } + + return path, nil +} + +func parseDuration(key, fallback string) (time.Duration, error) { + raw := getenv(key, fallback) + value, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("parse %s: %w", key, err) + } + + return value, nil +} + +func parseInt64(key string, fallback int64) (int64, error) { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback, nil + } + + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse %s: %w", key, err) + } + + return value, nil +} + +func parseInt(key string, fallback int) (int, error) { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback, nil + } + + value, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("parse %s: %w", key, err) + } + + return value, nil +} + +func parseBool(key string, fallback bool) (bool, error) { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback, nil + } + + value, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("parse %s: %w", key, err) + } + + return value, nil +} + +func parseLogLevel(raw string) (slog.Level, error) { + switch strings.ToUpper(strings.TrimSpace(raw)) { + case "DEBUG": + return slog.LevelDebug, nil + case "INFO": + return slog.LevelInfo, nil + case "WARN", "WARNING": + return slog.LevelWarn, nil + case "ERROR": + return slog.LevelError, nil + default: + return 0, fmt.Errorf("APP_LOG_LEVEL must be one of DEBUG, INFO, WARN, ERROR") + } +} + +func pathWithin(candidate, parent string) bool { + rel, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(candidate)) + if err != nil { + return false + } + + if rel == "." { + return true + } + + rel = filepath.ToSlash(rel) + return rel != ".." && !strings.HasPrefix(rel, "../") && path.Clean(rel) != ".." +} diff --git a/internal/db/apikeys.go b/internal/db/apikeys.go new file mode 100644 index 0000000..2dc2c56 --- /dev/null +++ b/internal/db/apikeys.go @@ -0,0 +1,761 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +type CreateAPIKeyParams struct { + Name string + KeyPrefix string + KeyHash string + Description string + ScopeMode ScopeMode + CanDownload bool + CanUpload bool + CanDelete bool + CanManageProjects bool + IsActive bool + ExpiresAt *time.Time + CreatedByUserID *int64 +} + +type UpdateAPIKeyParams struct { + Name string + Description string + ScopeMode ScopeMode + CanDownload bool + CanUpload bool + CanDelete bool + CanManageProjects bool + ExpiresAt *time.Time +} + +func (r *APIKeyRepository) List(ctx context.Context) ([]APIKeyListItem, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + ak.id, + ak.name, + ak.key_prefix, + ak.key_hash, + ak.description, + ak.scope_mode, + ak.can_download, + ak.can_upload, + ak.can_delete, + ak.can_manage_projects, + ak.is_active, + ak.expires_at, + ak.created_at, + ak.updated_at, + ak.last_used_at, + ak.created_by_user_id, + COUNT(DISTINCT ap.project_id) AS project_rule_count, + COUNT(DISTINCT at.tag_id) AS tag_rule_count + FROM api_keys AS ak + LEFT JOIN api_key_project_access AS ap ON ap.api_key_id = ak.id + LEFT JOIN api_key_tag_access AS at ON at.api_key_id = ak.id + GROUP BY ak.id + ORDER BY ak.is_active DESC, ak.updated_at DESC, ak.created_at DESC, ak.name COLLATE NOCASE`, + ) + if err != nil { + return nil, fmt.Errorf("query api keys: %w", err) + } + defer rows.Close() + + items := make([]APIKeyListItem, 0) + for rows.Next() { + item, err := scanAPIKeyListItem(rows) + if err != nil { + return nil, fmt.Errorf("scan api key list item: %w", err) + } + + items = append(items, *item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate api keys: %w", err) + } + + return items, nil +} + +func (r *APIKeyRepository) GetByID(ctx context.Context, id int64) (*APIKey, error) { + key, err := scanAPIKey(r.q.QueryRowContext( + ctx, + `SELECT + id, + name, + key_prefix, + key_hash, + description, + scope_mode, + can_download, + can_upload, + can_delete, + can_manage_projects, + is_active, + expires_at, + created_at, + updated_at, + last_used_at, + created_by_user_id + FROM api_keys + WHERE id = ?`, + id, + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan api key by id: %w", err) + } + + return key, nil +} + +func (r *APIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*APIKey, error) { + key, err := scanAPIKey(r.q.QueryRowContext( + ctx, + `SELECT + id, + name, + key_prefix, + key_hash, + description, + scope_mode, + can_download, + can_upload, + can_delete, + can_manage_projects, + is_active, + expires_at, + created_at, + updated_at, + last_used_at, + created_by_user_id + FROM api_keys + WHERE key_hash = ? + LIMIT 1`, + strings.TrimSpace(keyHash), + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan api key by hash: %w", err) + } + + return key, nil +} + +func (r *APIKeyRepository) Create(ctx context.Context, params CreateAPIKeyParams) (*APIKey, error) { + isActive := 0 + if params.IsActive { + isActive = 1 + } + + result, err := r.q.ExecContext( + ctx, + `INSERT INTO api_keys ( + name, + key_prefix, + key_hash, + description, + scope_mode, + can_download, + can_upload, + can_delete, + can_manage_projects, + is_active, + expires_at, + created_by_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + strings.TrimSpace(params.Name), + strings.TrimSpace(params.KeyPrefix), + strings.TrimSpace(params.KeyHash), + strings.TrimSpace(params.Description), + params.ScopeMode, + boolToInt(params.CanDownload), + boolToInt(params.CanUpload), + boolToInt(params.CanDelete), + boolToInt(params.CanManageProjects), + isActive, + nullableTimestampValue(params.ExpiresAt), + params.CreatedByUserID, + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, errors.Join(ErrConflict, fmt.Errorf("insert api key: %w", err)) + } + + return nil, fmt.Errorf("insert api key: %w", err) + } + + apiKeyID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("load inserted api key id: %w", err) + } + + return r.GetByID(ctx, apiKeyID) +} + +func (r *APIKeyRepository) Update(ctx context.Context, apiKeyID int64, params UpdateAPIKeyParams) (*APIKey, error) { + result, err := r.q.ExecContext( + ctx, + `UPDATE api_keys + SET name = ?, + description = ?, + scope_mode = ?, + can_download = ?, + can_upload = ?, + can_delete = ?, + can_manage_projects = ?, + expires_at = ? + WHERE id = ?`, + strings.TrimSpace(params.Name), + strings.TrimSpace(params.Description), + params.ScopeMode, + boolToInt(params.CanDownload), + boolToInt(params.CanUpload), + boolToInt(params.CanDelete), + boolToInt(params.CanManageProjects), + nullableTimestampValue(params.ExpiresAt), + apiKeyID, + ) + if err != nil { + return nil, fmt.Errorf("update api key: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("read updated api key rows: %w", err) + } + + if rowsAffected == 0 { + return nil, ErrNotFound + } + + return r.GetByID(ctx, apiKeyID) +} + +func (r *APIKeyRepository) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error { + result, err := r.q.ExecContext( + ctx, + `UPDATE api_keys SET is_active = ? WHERE id = ?`, + boolToInt(isActive), + apiKeyID, + ) + if err != nil { + return fmt.Errorf("update api key active state: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read updated api key rows: %w", err) + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *APIKeyRepository) TouchLastUsedAt(ctx context.Context, apiKeyID int64, usedAt time.Time) error { + result, err := r.q.ExecContext( + ctx, + `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, + formatTimestamp(usedAt), + apiKeyID, + ) + if err != nil { + return fmt.Errorf("update api key last_used_at: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read updated api key rows: %w", err) + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *APIKeyRepository) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]Project, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + p.id, + p.name, + p.slug, + p.description, + p.is_active, + p.created_at, + p.updated_at + FROM projects AS p + INNER JOIN api_key_project_access AS ap ON ap.project_id = p.id + WHERE ap.api_key_id = ? + ORDER BY p.name COLLATE NOCASE`, + apiKeyID, + ) + if err != nil { + return nil, fmt.Errorf("query api key project access: %w", err) + } + defer rows.Close() + + projects := make([]Project, 0) + for rows.Next() { + project, err := scanProject(rows) + if err != nil { + return nil, fmt.Errorf("scan api key project access: %w", err) + } + + projects = append(projects, *project) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate api key project access: %w", err) + } + + return projects, nil +} + +func (r *APIKeyRepository) ListTagAccess(ctx context.Context, apiKeyID int64) ([]Tag, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + t.id, + t.name, + t.slug, + t.description, + t.created_at, + t.updated_at + FROM tags AS t + INNER JOIN api_key_tag_access AS at ON at.tag_id = t.id + WHERE at.api_key_id = ? + ORDER BY t.name COLLATE NOCASE`, + apiKeyID, + ) + if err != nil { + return nil, fmt.Errorf("query api key tag access: %w", err) + } + defer rows.Close() + + tags := make([]Tag, 0) + for rows.Next() { + tag, err := scanTag(rows) + if err != nil { + return nil, fmt.Errorf("scan api key tag access: %w", err) + } + + tags = append(tags, *tag) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate api key tag access: %w", err) + } + + return tags, nil +} + +func (r *APIKeyRepository) ReplaceProjectAccess(ctx context.Context, apiKeyID int64, projectIDs []int64) error { + if err := r.ClearProjectAccess(ctx, apiKeyID); err != nil { + return err + } + + for _, projectID := range normalizeIDList(projectIDs) { + if _, err := r.q.ExecContext( + ctx, + `INSERT OR IGNORE INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`, + apiKeyID, + projectID, + ); err != nil { + return fmt.Errorf("insert api key project access: %w", err) + } + } + + return nil +} + +func (r *APIKeyRepository) ReplaceTagAccess(ctx context.Context, apiKeyID int64, tagIDs []int64) error { + if err := r.ClearTagAccess(ctx, apiKeyID); err != nil { + return err + } + + for _, tagID := range normalizeIDList(tagIDs) { + if _, err := r.q.ExecContext( + ctx, + `INSERT OR IGNORE INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`, + apiKeyID, + tagID, + ); err != nil { + return fmt.Errorf("insert api key tag access: %w", err) + } + } + + return nil +} + +func (r *APIKeyRepository) ClearProjectAccess(ctx context.Context, apiKeyID int64) error { + if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_project_access WHERE api_key_id = ?`, apiKeyID); err != nil { + return fmt.Errorf("delete api key project access: %w", err) + } + + return nil +} + +func (r *APIKeyRepository) ClearTagAccess(ctx context.Context, apiKeyID int64) error { + if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_tag_access WHERE api_key_id = ?`, apiKeyID); err != nil { + return fmt.Errorf("delete api key tag access: %w", err) + } + + return nil +} + +func (r *APIKeyRepository) ListAccessibleProjects(ctx context.Context, apiKeyID int64, scopeMode ScopeMode) ([]Project, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + p.id, + p.name, + p.slug, + p.description, + p.is_active, + p.created_at, + p.updated_at + FROM projects AS p + WHERE p.is_active = 1 AND ( + ? = 'all_projects' + OR ( + ? = 'project_allow_list' + AND EXISTS ( + SELECT 1 + FROM api_key_project_access AS ap + WHERE ap.api_key_id = ? + AND ap.project_id = p.id + ) + ) + OR ( + ? = 'project_deny_list' + AND NOT EXISTS ( + SELECT 1 + FROM api_key_project_access AS ap + WHERE ap.api_key_id = ? + AND ap.project_id = p.id + ) + ) + OR ( + ? = 'tag_allow_list' + AND EXISTS ( + SELECT 1 + FROM project_tags AS pt + INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id + WHERE at.api_key_id = ? + AND pt.project_id = p.id + ) + ) + OR ( + ? = 'tag_deny_list' + AND NOT EXISTS ( + SELECT 1 + FROM project_tags AS pt + INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id + WHERE at.api_key_id = ? + AND pt.project_id = p.id + ) + ) + ) + ORDER BY p.name COLLATE NOCASE`, + scopeMode, + scopeMode, apiKeyID, + scopeMode, apiKeyID, + scopeMode, apiKeyID, + scopeMode, apiKeyID, + ) + if err != nil { + return nil, fmt.Errorf("query accessible projects: %w", err) + } + defer rows.Close() + + projects := make([]Project, 0) + for rows.Next() { + project, err := scanProject(rows) + if err != nil { + return nil, fmt.Errorf("scan accessible project: %w", err) + } + + projects = append(projects, *project) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate accessible projects: %w", err) + } + + return projects, nil +} + +func (r *APIKeyRepository) HasProjectAccess(ctx context.Context, apiKeyID int64, scopeMode ScopeMode, projectID int64) (bool, error) { + var exists int + if err := r.q.QueryRowContext( + ctx, + `SELECT EXISTS( + SELECT 1 + FROM projects AS p + WHERE p.id = ? + AND p.is_active = 1 + AND ( + ? = 'all_projects' + OR ( + ? = 'project_allow_list' + AND EXISTS ( + SELECT 1 + FROM api_key_project_access AS ap + WHERE ap.api_key_id = ? + AND ap.project_id = p.id + ) + ) + OR ( + ? = 'project_deny_list' + AND NOT EXISTS ( + SELECT 1 + FROM api_key_project_access AS ap + WHERE ap.api_key_id = ? + AND ap.project_id = p.id + ) + ) + OR ( + ? = 'tag_allow_list' + AND EXISTS ( + SELECT 1 + FROM project_tags AS pt + INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id + WHERE at.api_key_id = ? + AND pt.project_id = p.id + ) + ) + OR ( + ? = 'tag_deny_list' + AND NOT EXISTS ( + SELECT 1 + FROM project_tags AS pt + INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id + WHERE at.api_key_id = ? + AND pt.project_id = p.id + ) + ) + ) + )`, + projectID, + scopeMode, + scopeMode, apiKeyID, + scopeMode, apiKeyID, + scopeMode, apiKeyID, + scopeMode, apiKeyID, + ).Scan(&exists); err != nil { + return false, fmt.Errorf("query api key project access: %w", err) + } + + return exists == 1, nil +} + +func scanAPIKey(scanner rowScanner) (*APIKey, error) { + var ( + key APIKey + scopeMode string + canDownload int + canUpload int + canDelete int + canManageProjects int + isActive int + expiresAtRaw sql.NullString + createdAtRaw string + updatedAtRaw string + lastUsedAtRaw sql.NullString + createdByUserIDRaw sql.NullInt64 + ) + + if err := scanner.Scan( + &key.ID, + &key.Name, + &key.KeyPrefix, + &key.KeyHash, + &key.Description, + &scopeMode, + &canDownload, + &canUpload, + &canDelete, + &canManageProjects, + &isActive, + &expiresAtRaw, + &createdAtRaw, + &updatedAtRaw, + &lastUsedAtRaw, + &createdByUserIDRaw, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key updated_at: %w", err) + } + + expiresAt, err := parseNullableTimestamp(expiresAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key expires_at: %w", err) + } + + lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key last_used_at: %w", err) + } + + key.ScopeMode = ScopeMode(scopeMode) + key.CanDownload = canDownload == 1 + key.CanUpload = canUpload == 1 + key.CanDelete = canDelete == 1 + key.CanManageProjects = canManageProjects == 1 + key.IsActive = isActive == 1 + key.ExpiresAt = expiresAt + key.CreatedAt = createdAt + key.UpdatedAt = updatedAt + key.LastUsedAt = lastUsedAt + if createdByUserIDRaw.Valid { + key.CreatedByUserID = &createdByUserIDRaw.Int64 + } + + return &key, nil +} + +func scanAPIKeyListItem(scanner rowScanner) (*APIKeyListItem, error) { + var ( + item APIKeyListItem + scopeMode string + canDownload int + canUpload int + canDelete int + canManageProjects int + isActive int + expiresAtRaw sql.NullString + createdAtRaw string + updatedAtRaw string + lastUsedAtRaw sql.NullString + createdByUserIDRaw sql.NullInt64 + ) + + if err := scanner.Scan( + &item.APIKey.ID, + &item.APIKey.Name, + &item.APIKey.KeyPrefix, + &item.APIKey.KeyHash, + &item.APIKey.Description, + &scopeMode, + &canDownload, + &canUpload, + &canDelete, + &canManageProjects, + &isActive, + &expiresAtRaw, + &createdAtRaw, + &updatedAtRaw, + &lastUsedAtRaw, + &createdByUserIDRaw, + &item.ProjectRuleCount, + &item.TagRuleCount, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key list created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key list updated_at: %w", err) + } + + expiresAt, err := parseNullableTimestamp(expiresAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key list expires_at: %w", err) + } + + lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse api key list last_used_at: %w", err) + } + + item.APIKey.ScopeMode = ScopeMode(scopeMode) + item.APIKey.CanDownload = canDownload == 1 + item.APIKey.CanUpload = canUpload == 1 + item.APIKey.CanDelete = canDelete == 1 + item.APIKey.CanManageProjects = canManageProjects == 1 + item.APIKey.IsActive = isActive == 1 + item.APIKey.ExpiresAt = expiresAt + item.APIKey.CreatedAt = createdAt + item.APIKey.UpdatedAt = updatedAt + item.APIKey.LastUsedAt = lastUsedAt + if createdByUserIDRaw.Valid { + item.APIKey.CreatedByUserID = &createdByUserIDRaw.Int64 + } + + return &item, nil +} + +func boolToInt(value bool) int { + if value { + return 1 + } + + return 0 +} + +func nullableTimestampValue(value *time.Time) any { + if value == nil { + return nil + } + + return formatTimestamp(value.UTC()) +} + +func normalizeIDList(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + normalized := make([]int64, 0, len(values)) + + for _, value := range values { + if value <= 0 { + continue + } + + if _, exists := seen[value]; exists { + continue + } + + seen[value] = struct{}{} + normalized = append(normalized, value) + } + + sort.Slice(normalized, func(i, j int) bool { + return normalized[i] < normalized[j] + }) + + return normalized +} diff --git a/internal/db/errors.go b/internal/db/errors.go new file mode 100644 index 0000000..58354c5 --- /dev/null +++ b/internal/db/errors.go @@ -0,0 +1,19 @@ +package db + +import ( + "errors" + "strings" +) + +var ( + ErrNotFound = errors.New("record not found") + ErrConflict = errors.New("record conflict") +) + +func isUniqueConstraintError(err error) bool { + if err == nil { + return false + } + + return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..6f85451 --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,146 @@ +package db + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +const migrationsTableDDL = ` +CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + checksum_sha256 TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); +` + +func Migrate(ctx context.Context, database *sql.DB, migrationsDir string) error { + if migrationsDir == "" { + return fmt.Errorf("migrations dir is required") + } + + if _, err := database.ExecContext(ctx, migrationsTableDDL); err != nil { + return fmt.Errorf("ensure schema_migrations table: %w", err) + } + + applied, err := appliedMigrations(ctx, database) + if err != nil { + return err + } + + files, err := listMigrationFiles(migrationsDir) + if err != nil { + return err + } + + for _, filename := range files { + fullPath := filepath.Join(migrationsDir, filename) + + contents, err := os.ReadFile(fullPath) + if err != nil { + return fmt.Errorf("read migration %s: %w", filename, err) + } + + checksum := checksum(contents) + if appliedChecksum, ok := applied[filename]; ok { + if appliedChecksum != checksum { + return fmt.Errorf("migration %s checksum mismatch: applied=%s current=%s", filename, appliedChecksum, checksum) + } + + continue + } + + if err := applyMigration(ctx, database, filename, checksum, string(contents)); err != nil { + return err + } + } + + return nil +} + +func appliedMigrations(ctx context.Context, database *sql.DB) (map[string]string, error) { + rows, err := database.QueryContext(ctx, `SELECT name, checksum_sha256 FROM schema_migrations`) + if err != nil { + return nil, fmt.Errorf("load applied migrations: %w", err) + } + defer rows.Close() + + applied := make(map[string]string) + for rows.Next() { + var name string + var checksum string + + if err := rows.Scan(&name, &checksum); err != nil { + return nil, fmt.Errorf("scan applied migration: %w", err) + } + + applied[name] = checksum + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate applied migrations: %w", err) + } + + return applied, nil +} + +func listMigrationFiles(migrationsDir string) ([]string, error) { + entries, err := os.ReadDir(migrationsDir) + if err != nil { + return nil, fmt.Errorf("read migrations dir: %w", err) + } + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" { + continue + } + + files = append(files, entry.Name()) + } + + slices.Sort(files) + + return files, nil +} + +func applyMigration(ctx context.Context, database *sql.DB, filename, checksum, sqlText string) error { + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration %s: %w", filename, err) + } + + if strings.TrimSpace(sqlText) != "" { + if _, err := tx.ExecContext(ctx, sqlText); err != nil { + tx.Rollback() + return fmt.Errorf("execute migration %s: %w", filename, err) + } + } + + if _, err := tx.ExecContext( + ctx, + `INSERT INTO schema_migrations (name, checksum_sha256) VALUES (?, ?)`, + filename, + checksum, + ); err != nil { + tx.Rollback() + return fmt.Errorf("record migration %s: %w", filename, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", filename, err) + } + + return nil +} + +func checksum(contents []byte) string { + sum := sha256.Sum256(contents) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go new file mode 100644 index 0000000..f5940c5 --- /dev/null +++ b/internal/db/migrate_test.go @@ -0,0 +1,294 @@ +package db_test + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "update_server/internal/db" +) + +func TestMigrateAppliesCoreSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite") + + database, err := db.Open(ctx, sqlitePath) + if err != nil { + t.Fatalf("open database: %v", err) + } + defer database.Close() + + migrationsDir := projectMigrationsDir(t) + if err := db.Migrate(ctx, database, migrationsDir); err != nil { + t.Fatalf("apply migrations: %v", err) + } + + if err := db.Migrate(ctx, database, migrationsDir); err != nil { + t.Fatalf("reapply migrations: %v", err) + } + + expectedTables := []string{ + "schema_migrations", + "users", + "projects", + "tags", + "project_tags", + "releases", + "api_keys", + "api_key_project_access", + "api_key_tag_access", + "sessions", + "audit_logs", + } + + for _, tableName := range expectedTables { + if !tableExists(t, database, tableName) { + t.Fatalf("expected table %q to exist", tableName) + } + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)`, + "admin@example.com", + "hashed-password", + "admin", + ); err != nil { + t.Fatalf("insert user: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`, + "Desktop App", + "desktop-app", + "Primary desktop client", + ); err != nil { + t.Fatalf("insert project: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`, + "Windows", + "windows", + "Windows releases", + ); err != nil { + t.Fatalf("insert tag: %v", err) + } + + var projectID int64 + if err := database.QueryRowContext(ctx, `SELECT id FROM projects WHERE slug = ?`, "desktop-app").Scan(&projectID); err != nil { + t.Fatalf("load project id: %v", err) + } + + var tagID int64 + if err := database.QueryRowContext(ctx, `SELECT id FROM tags WHERE slug = ?`, "windows").Scan(&tagID); err != nil { + t.Fatalf("load tag id: %v", err) + } + + var userID int64 + if err := database.QueryRowContext(ctx, `SELECT id FROM users WHERE email = ?`, "admin@example.com").Scan(&userID); err != nil { + t.Fatalf("load user id: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO project_tags (project_id, tag_id) VALUES (?, ?)`, + projectID, + tagID, + ); err != nil { + t.Fatalf("insert project tag: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO releases (project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, uploaded_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + projectID, + "1.0.0", + "", + "desktop-app-1.0.0.zip", + "artifacts/desktop-app/1.0.0/desktop-app-1.0.0.zip", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + 1024, + "application/zip", + "Initial release", + userID, + ); err != nil { + t.Fatalf("insert release: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, + "Desktop Clients", + "updsrv_project", + "hash-project", + "Project-scoped desktop client access", + "project_allow_list", + 1, + userID, + ); err != nil { + t.Fatalf("insert project api key: %v", err) + } + + var projectAPIKeyID int64 + if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_project").Scan(&projectAPIKeyID); err != nil { + t.Fatalf("load project api key id: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`, + projectAPIKeyID, + projectID, + ); err != nil { + t.Fatalf("insert api key project access: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, + "Tagged Clients", + "updsrv_tag", + "hash-tag", + "Tag-scoped desktop client access", + "tag_allow_list", + 1, + userID, + ); err != nil { + t.Fatalf("insert tag api key: %v", err) + } + + var tagAPIKeyID int64 + if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_tag").Scan(&tagAPIKeyID); err != nil { + t.Fatalf("load tag api key id: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`, + tagAPIKeyID, + tagID, + ); err != nil { + t.Fatalf("insert api key tag access: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`, + userID, + "session-hash", + "2030-01-01T00:00:00Z", + "127.0.0.1", + "test-agent", + ); err != nil { + t.Fatalf("insert session: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO audit_logs (actor_user_id, api_key_id, action, target_type, target_id, target_identifier, metadata_json, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + userID, + tagAPIKeyID, + "api_key.created", + "api_key", + tagAPIKeyID, + "updsrv_tag", + `{"source":"test"}`, + "127.0.0.1", + ); err != nil { + t.Fatalf("insert audit log: %v", err) + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`, + tagAPIKeyID, + projectID, + ); err == nil { + t.Fatal("expected project access insert for tag-scoped key to fail") + } + + if _, err := database.ExecContext( + ctx, + `INSERT INTO api_keys (name, key_prefix, key_hash, scope_mode) VALUES (?, ?, ?, ?)`, + "Broken Key", + "updsrv_invalid", + "hash-invalid", + "invalid_scope", + ); err == nil { + t.Fatal("expected invalid scope_mode insert to fail") + } +} + +func projectMigrationsDir(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve caller path") + } + + return filepath.Join(filepath.Dir(filename), "..", "..", "migrations") +} + +func tableExists(t *testing.T, database *sql.DB, tableName string) bool { + t.Helper() + + var exists int + query := `SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)` + if err := database.QueryRowContext(context.Background(), query, tableName).Scan(&exists); err != nil { + t.Fatalf("check table %s: %v", tableName, err) + } + + return exists == 1 +} + +func TestMigrateRejectsEditedAppliedMigrations(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tempDir := t.TempDir() + sqlitePath := filepath.Join(tempDir, "update-server.sqlite") + + database, err := db.Open(ctx, sqlitePath) + if err != nil { + t.Fatalf("open database: %v", err) + } + defer database.Close() + + migrationsDir := filepath.Join(tempDir, "migrations") + if err := os.MkdirAll(migrationsDir, 0o755); err != nil { + t.Fatalf("create migrations dir: %v", err) + } + + firstMigrationPath := filepath.Join(migrationsDir, "0001_test.sql") + if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY);`), 0o644); err != nil { + t.Fatalf("write migration: %v", err) + } + + if err := db.Migrate(ctx, database, migrationsDir); err != nil { + t.Fatalf("apply migration: %v", err) + } + + if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT);`), 0o644); err != nil { + t.Fatalf("rewrite migration: %v", err) + } + + err = db.Migrate(ctx, database, migrationsDir) + if err == nil { + t.Fatal("expected checksum mismatch error") + } + + expectedMessage := "checksum mismatch" + if !strings.Contains(err.Error(), expectedMessage) { + t.Fatalf("expected error containing %q, got %v", expectedMessage, err) + } +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..c7a85d5 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,177 @@ +package db + +import "time" + +type ScopeMode string + +const ( + ScopeModeAllProjects ScopeMode = "all_projects" + ScopeModeProjectAllowList ScopeMode = "project_allow_list" + ScopeModeProjectDenyList ScopeMode = "project_deny_list" + ScopeModeTagAllowList ScopeMode = "tag_allow_list" + ScopeModeTagDenyList ScopeMode = "tag_deny_list" +) + +func (m ScopeMode) Valid() bool { + switch m { + case ScopeModeAllProjects, + ScopeModeProjectAllowList, + ScopeModeProjectDenyList, + ScopeModeTagAllowList, + ScopeModeTagDenyList: + return true + default: + return false + } +} + +func (m ScopeMode) UsesProjectRules() bool { + return m == ScopeModeProjectAllowList || m == ScopeModeProjectDenyList +} + +func (m ScopeMode) UsesTagRules() bool { + return m == ScopeModeTagAllowList || m == ScopeModeTagDenyList +} + +type UserRole string + +const ( + UserRoleAdmin UserRole = "admin" + UserRoleEditor UserRole = "editor" + UserRoleViewer UserRole = "viewer" +) + +type User struct { + ID int64 + Email string + PasswordHash string + Role UserRole + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time + LastLoginAt *time.Time +} + +type Project struct { + ID int64 + Name string + Slug string + Description string + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type ProjectListItem struct { + Project Project + TagCount int + ReleaseCount int +} + +type Tag struct { + ID int64 + Name string + Slug string + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + +type TagListItem struct { + Tag Tag + ProjectCount int +} + +type Release struct { + ID int64 + ProjectID int64 + Version string + Build string + Filename string + StoragePath string + ChecksumSHA256 string + SizeBytes int64 + ContentType string + ReleaseNotes string + CreatedAt time.Time + UpdatedAt time.Time + UploadedByUserID *int64 + IsActive bool +} + +type ReleaseListItem struct { + Release Release + UploadedByEmail string +} + +type APIKey struct { + ID int64 + Name string + KeyPrefix string + KeyHash string + Description string + ScopeMode ScopeMode + CanDownload bool + CanUpload bool + CanDelete bool + CanManageProjects bool + IsActive bool + ExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time + LastUsedAt *time.Time + CreatedByUserID *int64 +} + +func (k APIKey) Expired(now time.Time) bool { + return k.ExpiresAt != nil && !k.ExpiresAt.After(now.UTC()) +} + +type APIKeyListItem struct { + APIKey APIKey + ProjectRuleCount int + TagRuleCount int + AccessiblePreview int +} + +type APIKeyProjectAccess struct { + APIKeyID int64 + ProjectID int64 + CreatedAt time.Time +} + +type APIKeyTagAccess struct { + APIKeyID int64 + TagID int64 + CreatedAt time.Time +} + +type Session struct { + ID int64 + UserID int64 + TokenHash string + ExpiresAt time.Time + LastSeenAt *time.Time + InvalidatedAt *time.Time + IPAddress string + UserAgent string + CreatedAt time.Time +} + +type SessionWithUser struct { + Session Session + User User +} + +type AuditLog struct { + ID int64 + ActorUserID *int64 + APIKeyID *int64 + Action string + TargetType string + TargetID *int64 + TargetIdentifier string + MetadataJSON string + IPAddress string + CreatedAt time.Time +} diff --git a/internal/db/open.go b/internal/db/open.go new file mode 100644 index 0000000..b7b8943 --- /dev/null +++ b/internal/db/open.go @@ -0,0 +1,60 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" +) + +const sqliteDriverName = "sqlite3" + +func Open(ctx context.Context, sqlitePath string) (*sql.DB, error) { + if sqlitePath == "" { + return nil, fmt.Errorf("sqlite path is required") + } + + if err := os.MkdirAll(filepath.Dir(sqlitePath), 0o750); err != nil { + return nil, fmt.Errorf("create sqlite dir: %w", err) + } + + database, err := sql.Open(sqliteDriverName, sqlitePath) + if err != nil { + return nil, fmt.Errorf("open sqlite database: %w", err) + } + + database.SetMaxOpenConns(1) + database.SetMaxIdleConns(1) + + if err := applyPragmas(ctx, database); err != nil { + database.Close() + return nil, err + } + + if err := database.PingContext(ctx); err != nil { + database.Close() + return nil, fmt.Errorf("ping sqlite database: %w", err) + } + + return database, nil +} + +func applyPragmas(ctx context.Context, database *sql.DB) error { + pragmas := []string{ + "PRAGMA foreign_keys = ON;", + "PRAGMA journal_mode = WAL;", + "PRAGMA busy_timeout = 5000;", + "PRAGMA synchronous = NORMAL;", + } + + for _, pragma := range pragmas { + if _, err := database.ExecContext(ctx, pragma); err != nil { + return fmt.Errorf("apply sqlite pragma %q: %w", pragma, err) + } + } + + return nil +} diff --git a/internal/db/projects.go b/internal/db/projects.go new file mode 100644 index 0000000..0426cce --- /dev/null +++ b/internal/db/projects.go @@ -0,0 +1,326 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +type CreateProjectParams struct { + Name string + Slug string + Description string +} + +type UpdateProjectParams struct { + Name string + Slug string + Description string +} + +func (r *ProjectRepository) List(ctx context.Context) ([]ProjectListItem, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + p.id, + p.name, + p.slug, + p.description, + p.is_active, + p.created_at, + p.updated_at, + COUNT(DISTINCT pt.tag_id) AS tag_count, + COUNT(DISTINCT rel.id) AS release_count + FROM projects AS p + LEFT JOIN project_tags AS pt ON pt.project_id = p.id + LEFT JOIN releases AS rel ON rel.project_id = p.id AND rel.is_active = 1 + GROUP BY p.id + ORDER BY p.is_active DESC, p.updated_at DESC, p.created_at DESC, p.name COLLATE NOCASE`, + ) + if err != nil { + return nil, fmt.Errorf("query projects: %w", err) + } + defer rows.Close() + + projects := make([]ProjectListItem, 0) + for rows.Next() { + item, err := scanProjectListItem(rows) + if err != nil { + return nil, fmt.Errorf("scan project list item: %w", err) + } + + projects = append(projects, *item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate projects: %w", err) + } + + return projects, nil +} + +func (r *ProjectRepository) GetByID(ctx context.Context, id int64) (*Project, error) { + project, err := scanProject(r.q.QueryRowContext( + ctx, + `SELECT id, name, slug, description, is_active, created_at, updated_at + FROM projects + WHERE id = ?`, + id, + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan project by id: %w", err) + } + + return project, nil +} + +func (r *ProjectRepository) GetBySlug(ctx context.Context, slug string) (*Project, error) { + project, err := scanProject(r.q.QueryRowContext( + ctx, + `SELECT id, name, slug, description, is_active, created_at, updated_at + FROM projects + WHERE slug = ? + LIMIT 1`, + strings.TrimSpace(slug), + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan project by slug: %w", err) + } + + return project, nil +} + +func (r *ProjectRepository) Create(ctx context.Context, params CreateProjectParams) (*Project, error) { + result, err := r.q.ExecContext( + ctx, + `INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`, + strings.TrimSpace(params.Name), + strings.TrimSpace(params.Slug), + strings.TrimSpace(params.Description), + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, errors.Join(ErrConflict, fmt.Errorf("insert project: %w", err)) + } + + return nil, fmt.Errorf("insert project: %w", err) + } + + projectID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("load inserted project id: %w", err) + } + + return r.GetByID(ctx, projectID) +} + +func (r *ProjectRepository) Update(ctx context.Context, projectID int64, params UpdateProjectParams) (*Project, error) { + result, err := r.q.ExecContext( + ctx, + `UPDATE projects + SET name = ?, slug = ?, description = ? + WHERE id = ?`, + strings.TrimSpace(params.Name), + strings.TrimSpace(params.Slug), + strings.TrimSpace(params.Description), + projectID, + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, errors.Join(ErrConflict, fmt.Errorf("update project: %w", err)) + } + + return nil, fmt.Errorf("update project: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("read updated project rows: %w", err) + } + + if rowsAffected == 0 { + return nil, ErrNotFound + } + + return r.GetByID(ctx, projectID) +} + +func (r *ProjectRepository) SetActive(ctx context.Context, projectID int64, isActive bool) error { + activeValue := 0 + if isActive { + activeValue = 1 + } + + result, err := r.q.ExecContext( + ctx, + `UPDATE projects + SET is_active = ? + WHERE id = ?`, + activeValue, + projectID, + ) + if err != nil { + return fmt.Errorf("update project active state: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read affected project rows: %w", err) + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *ProjectRepository) ListTags(ctx context.Context, projectID int64) ([]Tag, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + t.id, + t.name, + t.slug, + t.description, + t.created_at, + t.updated_at + FROM tags AS t + INNER JOIN project_tags AS pt ON pt.tag_id = t.id + WHERE pt.project_id = ? + ORDER BY t.name COLLATE NOCASE`, + projectID, + ) + if err != nil { + return nil, fmt.Errorf("query project tags: %w", err) + } + defer rows.Close() + + tags := make([]Tag, 0) + for rows.Next() { + tag, err := scanTag(rows) + if err != nil { + return nil, fmt.Errorf("scan project tag: %w", err) + } + + tags = append(tags, *tag) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate project tags: %w", err) + } + + return tags, nil +} + +func (r *ProjectRepository) AttachTag(ctx context.Context, projectID, tagID int64) error { + if _, err := r.q.ExecContext( + ctx, + `INSERT OR IGNORE INTO project_tags (project_id, tag_id) VALUES (?, ?)`, + projectID, + tagID, + ); err != nil { + return fmt.Errorf("insert project tag link: %w", err) + } + + return nil +} + +func (r *ProjectRepository) DetachTag(ctx context.Context, projectID, tagID int64) error { + if _, err := r.q.ExecContext( + ctx, + `DELETE FROM project_tags WHERE project_id = ? AND tag_id = ?`, + projectID, + tagID, + ); err != nil { + return fmt.Errorf("delete project tag link: %w", err) + } + + return nil +} + +func scanProject(scanner rowScanner) (*Project, error) { + var ( + project Project + isActive int + createdAtRaw string + updatedAtRaw string + ) + + if err := scanner.Scan( + &project.ID, + &project.Name, + &project.Slug, + &project.Description, + &isActive, + &createdAtRaw, + &updatedAtRaw, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse project created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse project updated_at: %w", err) + } + + project.IsActive = isActive == 1 + project.CreatedAt = createdAt + project.UpdatedAt = updatedAt + + return &project, nil +} + +func scanProjectListItem(scanner rowScanner) (*ProjectListItem, error) { + var ( + item ProjectListItem + isActive int + createdAtRaw string + updatedAtRaw string + ) + + if err := scanner.Scan( + &item.Project.ID, + &item.Project.Name, + &item.Project.Slug, + &item.Project.Description, + &isActive, + &createdAtRaw, + &updatedAtRaw, + &item.TagCount, + &item.ReleaseCount, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse project list created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse project list updated_at: %w", err) + } + + item.Project.IsActive = isActive == 1 + item.Project.CreatedAt = createdAt + item.Project.UpdatedAt = updatedAt + + return &item, nil +} diff --git a/internal/db/releases.go b/internal/db/releases.go new file mode 100644 index 0000000..46519ed --- /dev/null +++ b/internal/db/releases.go @@ -0,0 +1,285 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +type CreateReleaseParams struct { + ProjectID int64 + Version string + Build string + Filename string + StoragePath string + ChecksumSHA256 string + SizeBytes int64 + ContentType string + ReleaseNotes string + UploadedByUserID *int64 + IsActive bool +} + +func (r *ReleaseRepository) Create(ctx context.Context, params CreateReleaseParams) (*Release, error) { + isActive := 0 + if params.IsActive { + isActive = 1 + } + + result, err := r.q.ExecContext( + ctx, + `INSERT INTO releases ( + project_id, + version, + build, + filename, + storage_path, + checksum_sha256, + size_bytes, + content_type, + release_notes, + uploaded_by_user_id, + is_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + params.ProjectID, + strings.TrimSpace(params.Version), + strings.TrimSpace(params.Build), + strings.TrimSpace(params.Filename), + strings.TrimSpace(params.StoragePath), + strings.TrimSpace(params.ChecksumSHA256), + params.SizeBytes, + strings.TrimSpace(params.ContentType), + strings.TrimSpace(params.ReleaseNotes), + params.UploadedByUserID, + isActive, + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, errors.Join(ErrConflict, fmt.Errorf("insert release: %w", err)) + } + + return nil, fmt.Errorf("insert release: %w", err) + } + + releaseID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("load inserted release id: %w", err) + } + + return r.GetByID(ctx, releaseID) +} + +func (r *ReleaseRepository) GetByID(ctx context.Context, id int64) (*Release, error) { + release, err := scanRelease(r.q.QueryRowContext( + ctx, + `SELECT + id, + project_id, + version, + build, + filename, + storage_path, + checksum_sha256, + size_bytes, + content_type, + release_notes, + created_at, + updated_at, + uploaded_by_user_id, + is_active + FROM releases + WHERE id = ?`, + id, + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan release by id: %w", err) + } + + return release, nil +} + +func (r *ReleaseRepository) GetLatestByProjectID(ctx context.Context, projectID int64) (*Release, error) { + release, err := scanRelease(r.q.QueryRowContext( + ctx, + `SELECT + id, + project_id, + version, + build, + filename, + storage_path, + checksum_sha256, + size_bytes, + content_type, + release_notes, + created_at, + updated_at, + uploaded_by_user_id, + is_active + FROM releases + WHERE project_id = ? + AND is_active = 1 + ORDER BY created_at DESC, id DESC + LIMIT 1`, + projectID, + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan latest release by project id: %w", err) + } + + return release, nil +} + +func (r *ReleaseRepository) ListByProjectID(ctx context.Context, projectID int64) ([]ReleaseListItem, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + r.id, + r.project_id, + r.version, + r.build, + r.filename, + r.storage_path, + r.checksum_sha256, + r.size_bytes, + r.content_type, + r.release_notes, + r.created_at, + r.updated_at, + r.uploaded_by_user_id, + r.is_active, + COALESCE(u.email, '') + FROM releases AS r + LEFT JOIN users AS u ON u.id = r.uploaded_by_user_id + WHERE r.project_id = ? + ORDER BY r.created_at DESC, r.id DESC`, + projectID, + ) + if err != nil { + return nil, fmt.Errorf("query project releases: %w", err) + } + defer rows.Close() + + releases := make([]ReleaseListItem, 0) + for rows.Next() { + item, err := scanReleaseListItem(rows) + if err != nil { + return nil, fmt.Errorf("scan project release: %w", err) + } + + releases = append(releases, *item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate project releases: %w", err) + } + + return releases, nil +} + +func scanRelease(scanner rowScanner) (*Release, error) { + var ( + release Release + createdAtRaw string + updatedAtRaw string + uploadedByUserID sql.NullInt64 + isActive int + ) + + if err := scanner.Scan( + &release.ID, + &release.ProjectID, + &release.Version, + &release.Build, + &release.Filename, + &release.StoragePath, + &release.ChecksumSHA256, + &release.SizeBytes, + &release.ContentType, + &release.ReleaseNotes, + &createdAtRaw, + &updatedAtRaw, + &uploadedByUserID, + &isActive, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse release created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse release updated_at: %w", err) + } + + release.CreatedAt = createdAt + release.UpdatedAt = updatedAt + release.IsActive = isActive == 1 + if uploadedByUserID.Valid { + release.UploadedByUserID = &uploadedByUserID.Int64 + } + + return &release, nil +} + +func scanReleaseListItem(scanner rowScanner) (*ReleaseListItem, error) { + var ( + item ReleaseListItem + createdAtRaw string + updatedAtRaw string + uploadedByUserID sql.NullInt64 + isActive int + ) + + if err := scanner.Scan( + &item.Release.ID, + &item.Release.ProjectID, + &item.Release.Version, + &item.Release.Build, + &item.Release.Filename, + &item.Release.StoragePath, + &item.Release.ChecksumSHA256, + &item.Release.SizeBytes, + &item.Release.ContentType, + &item.Release.ReleaseNotes, + &createdAtRaw, + &updatedAtRaw, + &uploadedByUserID, + &isActive, + &item.UploadedByEmail, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse release list created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse release list updated_at: %w", err) + } + + item.Release.CreatedAt = createdAt + item.Release.UpdatedAt = updatedAt + item.Release.IsActive = isActive == 1 + if uploadedByUserID.Valid { + item.Release.UploadedByUserID = &uploadedByUserID.Int64 + } + + return &item, nil +} diff --git a/internal/db/sessions.go b/internal/db/sessions.go new file mode 100644 index 0000000..1b1e851 --- /dev/null +++ b/internal/db/sessions.go @@ -0,0 +1,284 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +type CreateSessionParams struct { + UserID int64 + TokenHash string + ExpiresAt time.Time + IPAddress string + UserAgent string +} + +func (r *SessionRepository) Create(ctx context.Context, params CreateSessionParams) (*Session, error) { + result, err := r.q.ExecContext( + ctx, + `INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`, + params.UserID, + params.TokenHash, + formatTimestamp(params.ExpiresAt), + params.IPAddress, + params.UserAgent, + ) + if err != nil { + return nil, fmt.Errorf("insert session: %w", err) + } + + sessionID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("load inserted session id: %w", err) + } + + return r.GetByID(ctx, sessionID) +} + +func (r *SessionRepository) GetByID(ctx context.Context, id int64) (*Session, error) { + session, err := scanSession(r.q.QueryRowContext( + ctx, + `SELECT id, user_id, token_hash, expires_at, last_seen_at, invalidated_at, ip_address, user_agent, created_at + FROM sessions + WHERE id = ?`, + id, + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan session by id: %w", err) + } + + return session, nil +} + +func (r *SessionRepository) GetActiveWithUserByTokenHash(ctx context.Context, tokenHash string, now time.Time) (*SessionWithUser, error) { + record, err := scanSessionWithUser(r.q.QueryRowContext( + ctx, + `SELECT + s.id, + s.user_id, + s.token_hash, + s.expires_at, + s.last_seen_at, + s.invalidated_at, + s.ip_address, + s.user_agent, + s.created_at, + u.id, + u.email, + u.password_hash, + u.role, + u.is_active, + u.created_at, + u.updated_at, + u.last_login_at + FROM sessions AS s + INNER JOIN users AS u ON u.id = s.user_id + WHERE s.token_hash = ? + AND s.invalidated_at IS NULL + AND s.expires_at > ? + AND u.is_active = 1 + LIMIT 1`, + tokenHash, + formatTimestamp(now), + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan active session by token hash: %w", err) + } + + return record, nil +} + +func (r *SessionRepository) Touch(ctx context.Context, sessionID int64, seenAt time.Time) error { + result, err := r.q.ExecContext( + ctx, + `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, + formatTimestamp(seenAt), + sessionID, + ) + if err != nil { + return fmt.Errorf("update session last_seen_at: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read affected session rows: %w", err) + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *SessionRepository) InvalidateByTokenHash(ctx context.Context, tokenHash string, invalidatedAt time.Time) error { + result, err := r.q.ExecContext( + ctx, + `UPDATE sessions + SET invalidated_at = ? + WHERE token_hash = ? + AND invalidated_at IS NULL`, + formatTimestamp(invalidatedAt), + tokenHash, + ) + if err != nil { + return fmt.Errorf("invalidate session by token hash: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read invalidated session rows: %w", err) + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func scanSession(scanner rowScanner) (*Session, error) { + var ( + session Session + expiresAtRaw string + lastSeenAtRaw sql.NullString + invalidatedAtRaw sql.NullString + createdAtRaw string + ) + + if err := scanner.Scan( + &session.ID, + &session.UserID, + &session.TokenHash, + &expiresAtRaw, + &lastSeenAtRaw, + &invalidatedAtRaw, + &session.IPAddress, + &session.UserAgent, + &createdAtRaw, + ); err != nil { + return nil, err + } + + expiresAt, err := parseTimestamp(expiresAtRaw) + if err != nil { + return nil, fmt.Errorf("parse session expires_at: %w", err) + } + + lastSeenAt, err := parseNullableTimestamp(lastSeenAtRaw) + if err != nil { + return nil, fmt.Errorf("parse session last_seen_at: %w", err) + } + + invalidatedAt, err := parseNullableTimestamp(invalidatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse session invalidated_at: %w", err) + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse session created_at: %w", err) + } + + session.ExpiresAt = expiresAt + session.LastSeenAt = lastSeenAt + session.InvalidatedAt = invalidatedAt + session.CreatedAt = createdAt + + return &session, nil +} + +func scanSessionWithUser(scanner rowScanner) (*SessionWithUser, error) { + var ( + record SessionWithUser + sessionExpiresRaw string + sessionLastSeenRaw sql.NullString + sessionInvalidRaw sql.NullString + sessionCreatedRaw string + userRole string + userIsActive int + userCreatedRaw string + userUpdatedRaw string + userLastLoginRaw sql.NullString + ) + + if err := scanner.Scan( + &record.Session.ID, + &record.Session.UserID, + &record.Session.TokenHash, + &sessionExpiresRaw, + &sessionLastSeenRaw, + &sessionInvalidRaw, + &record.Session.IPAddress, + &record.Session.UserAgent, + &sessionCreatedRaw, + &record.User.ID, + &record.User.Email, + &record.User.PasswordHash, + &userRole, + &userIsActive, + &userCreatedRaw, + &userUpdatedRaw, + &userLastLoginRaw, + ); err != nil { + return nil, err + } + + sessionExpiresAt, err := parseTimestamp(sessionExpiresRaw) + if err != nil { + return nil, fmt.Errorf("parse joined session expires_at: %w", err) + } + + sessionLastSeenAt, err := parseNullableTimestamp(sessionLastSeenRaw) + if err != nil { + return nil, fmt.Errorf("parse joined session last_seen_at: %w", err) + } + + sessionInvalidatedAt, err := parseNullableTimestamp(sessionInvalidRaw) + if err != nil { + return nil, fmt.Errorf("parse joined session invalidated_at: %w", err) + } + + sessionCreatedAt, err := parseTimestamp(sessionCreatedRaw) + if err != nil { + return nil, fmt.Errorf("parse joined session created_at: %w", err) + } + + userCreatedAt, err := parseTimestamp(userCreatedRaw) + if err != nil { + return nil, fmt.Errorf("parse joined user created_at: %w", err) + } + + userUpdatedAt, err := parseTimestamp(userUpdatedRaw) + if err != nil { + return nil, fmt.Errorf("parse joined user updated_at: %w", err) + } + + userLastLoginAt, err := parseNullableTimestamp(userLastLoginRaw) + if err != nil { + return nil, fmt.Errorf("parse joined user last_login_at: %w", err) + } + + record.Session.ExpiresAt = sessionExpiresAt + record.Session.LastSeenAt = sessionLastSeenAt + record.Session.InvalidatedAt = sessionInvalidatedAt + record.Session.CreatedAt = sessionCreatedAt + record.User.Role = UserRole(userRole) + record.User.IsActive = userIsActive == 1 + record.User.CreatedAt = userCreatedAt + record.User.UpdatedAt = userUpdatedAt + record.User.LastLoginAt = userLastLoginAt + + return &record, nil +} diff --git a/internal/db/store.go b/internal/db/store.go new file mode 100644 index 0000000..4465f96 --- /dev/null +++ b/internal/db/store.go @@ -0,0 +1,131 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +type querier interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type Store struct { + DB *sql.DB + Users *UserRepository + Projects *ProjectRepository + Tags *TagRepository + Releases *ReleaseRepository + APIKeys *APIKeyRepository + Sessions *SessionRepository + AuditLogs *AuditLogRepository +} + +type TxStore struct { + Tx *sql.Tx + Users *UserRepository + Projects *ProjectRepository + Tags *TagRepository + Releases *ReleaseRepository + APIKeys *APIKeyRepository + Sessions *SessionRepository + AuditLogs *AuditLogRepository +} + +type UserRepository struct { + q querier +} + +type ProjectRepository struct { + q querier +} + +type TagRepository struct { + q querier +} + +type ReleaseRepository struct { + q querier +} + +type APIKeyRepository struct { + q querier +} + +type SessionRepository struct { + q querier +} + +type AuditLogRepository struct { + q querier +} + +func NewStore(database *sql.DB) *Store { + return &Store{ + DB: database, + Users: &UserRepository{q: database}, + Projects: &ProjectRepository{q: database}, + Tags: &TagRepository{q: database}, + Releases: &ReleaseRepository{q: database}, + APIKeys: &APIKeyRepository{q: database}, + Sessions: &SessionRepository{q: database}, + AuditLogs: &AuditLogRepository{q: database}, + } +} + +func (s *Store) Close() error { + if s == nil || s.DB == nil { + return nil + } + + return s.DB.Close() +} + +func (s *Store) HealthCheck(ctx context.Context) error { + if s == nil || s.DB == nil { + return fmt.Errorf("database store is not initialized") + } + + return s.DB.PingContext(ctx) +} + +func (s *Store) WithTx(ctx context.Context, fn func(*TxStore) error) error { + if s == nil || s.DB == nil { + return fmt.Errorf("database store is not initialized") + } + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + if err := fn(newTxStore(tx)); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + return errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr)) + } + + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func newTxStore(tx *sql.Tx) *TxStore { + return &TxStore{ + Tx: tx, + Users: &UserRepository{q: tx}, + Projects: &ProjectRepository{q: tx}, + Tags: &TagRepository{q: tx}, + Releases: &ReleaseRepository{q: tx}, + APIKeys: &APIKeyRepository{q: tx}, + Sessions: &SessionRepository{q: tx}, + AuditLogs: &AuditLogRepository{q: tx}, + } +} diff --git a/internal/db/tags.go b/internal/db/tags.go new file mode 100644 index 0000000..75a3355 --- /dev/null +++ b/internal/db/tags.go @@ -0,0 +1,314 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +type CreateTagParams struct { + Name string + Slug string + Description string +} + +type UpdateTagParams struct { + Name string + Slug string + Description string +} + +func (r *TagRepository) List(ctx context.Context) ([]TagListItem, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + t.id, + t.name, + t.slug, + t.description, + t.created_at, + t.updated_at, + COUNT(DISTINCT pt.project_id) AS project_count + FROM tags AS t + LEFT JOIN project_tags AS pt ON pt.tag_id = t.id + GROUP BY t.id + ORDER BY t.name COLLATE NOCASE`, + ) + if err != nil { + return nil, fmt.Errorf("query tags: %w", err) + } + defer rows.Close() + + tags := make([]TagListItem, 0) + for rows.Next() { + item, err := scanTagListItem(rows) + if err != nil { + return nil, fmt.Errorf("scan tag list item: %w", err) + } + + tags = append(tags, *item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate tags: %w", err) + } + + return tags, nil +} + +func (r *TagRepository) ListAvailableForProject(ctx context.Context, projectID int64) ([]Tag, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + t.id, + t.name, + t.slug, + t.description, + t.created_at, + t.updated_at + FROM tags AS t + WHERE NOT EXISTS ( + SELECT 1 + FROM project_tags AS pt + WHERE pt.project_id = ? + AND pt.tag_id = t.id + ) + ORDER BY t.name COLLATE NOCASE`, + projectID, + ) + if err != nil { + return nil, fmt.Errorf("query available project tags: %w", err) + } + defer rows.Close() + + tags := make([]Tag, 0) + for rows.Next() { + tag, err := scanTag(rows) + if err != nil { + return nil, fmt.Errorf("scan available project tag: %w", err) + } + + tags = append(tags, *tag) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate available project tags: %w", err) + } + + return tags, nil +} + +func (r *TagRepository) GetByID(ctx context.Context, id int64) (*Tag, error) { + tag, err := scanTag(r.q.QueryRowContext( + ctx, + `SELECT id, name, slug, description, created_at, updated_at + FROM tags + WHERE id = ?`, + id, + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan tag by id: %w", err) + } + + return tag, nil +} + +func (r *TagRepository) Create(ctx context.Context, params CreateTagParams) (*Tag, error) { + result, err := r.q.ExecContext( + ctx, + `INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`, + strings.TrimSpace(params.Name), + strings.TrimSpace(params.Slug), + strings.TrimSpace(params.Description), + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, errors.Join(ErrConflict, fmt.Errorf("insert tag: %w", err)) + } + + return nil, fmt.Errorf("insert tag: %w", err) + } + + tagID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("load inserted tag id: %w", err) + } + + return r.GetByID(ctx, tagID) +} + +func (r *TagRepository) Update(ctx context.Context, tagID int64, params UpdateTagParams) (*Tag, error) { + result, err := r.q.ExecContext( + ctx, + `UPDATE tags + SET name = ?, slug = ?, description = ? + WHERE id = ?`, + strings.TrimSpace(params.Name), + strings.TrimSpace(params.Slug), + strings.TrimSpace(params.Description), + tagID, + ) + if err != nil { + if isUniqueConstraintError(err) { + return nil, errors.Join(ErrConflict, fmt.Errorf("update tag: %w", err)) + } + + return nil, fmt.Errorf("update tag: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("read updated tag rows: %w", err) + } + + if rowsAffected == 0 { + return nil, ErrNotFound + } + + return r.GetByID(ctx, tagID) +} + +func (r *TagRepository) Delete(ctx context.Context, tagID int64) error { + var projectCount int + if err := r.q.QueryRowContext( + ctx, + `SELECT COUNT(1) FROM project_tags WHERE tag_id = ?`, + tagID, + ).Scan(&projectCount); err != nil { + return fmt.Errorf("count tag assignments: %w", err) + } + + if projectCount > 0 { + return errors.Join(ErrConflict, fmt.Errorf("tag is assigned to %d project(s)", projectCount)) + } + + result, err := r.q.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, tagID) + if err != nil { + return fmt.Errorf("delete tag: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read deleted tag rows: %w", err) + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *TagRepository) ListProjects(ctx context.Context, tagID int64) ([]Project, error) { + rows, err := r.q.QueryContext( + ctx, + `SELECT + p.id, + p.name, + p.slug, + p.description, + p.is_active, + p.created_at, + p.updated_at + FROM projects AS p + INNER JOIN project_tags AS pt ON pt.project_id = p.id + WHERE pt.tag_id = ? + ORDER BY p.name COLLATE NOCASE`, + tagID, + ) + if err != nil { + return nil, fmt.Errorf("query tag projects: %w", err) + } + defer rows.Close() + + projects := make([]Project, 0) + for rows.Next() { + project, err := scanProject(rows) + if err != nil { + return nil, fmt.Errorf("scan tag project: %w", err) + } + + projects = append(projects, *project) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate tag projects: %w", err) + } + + return projects, nil +} + +func scanTag(scanner rowScanner) (*Tag, error) { + var ( + tag Tag + createdAtRaw string + updatedAtRaw string + ) + + if err := scanner.Scan( + &tag.ID, + &tag.Name, + &tag.Slug, + &tag.Description, + &createdAtRaw, + &updatedAtRaw, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse tag created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse tag updated_at: %w", err) + } + + tag.CreatedAt = createdAt + tag.UpdatedAt = updatedAt + + return &tag, nil +} + +func scanTagListItem(scanner rowScanner) (*TagListItem, error) { + var ( + item TagListItem + createdAtRaw string + updatedAtRaw string + ) + + if err := scanner.Scan( + &item.Tag.ID, + &item.Tag.Name, + &item.Tag.Slug, + &item.Tag.Description, + &createdAtRaw, + &updatedAtRaw, + &item.ProjectCount, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse tag list created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse tag list updated_at: %w", err) + } + + item.Tag.CreatedAt = createdAt + item.Tag.UpdatedAt = updatedAt + + return &item, nil +} diff --git a/internal/db/time.go b/internal/db/time.go new file mode 100644 index 0000000..1ebb665 --- /dev/null +++ b/internal/db/time.go @@ -0,0 +1,38 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" + "time" +) + +type rowScanner interface { + Scan(dest ...any) error +} + +func formatTimestamp(value time.Time) string { + return value.UTC().Format(time.RFC3339) +} + +func parseTimestamp(raw string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err) + } + + return parsed.UTC(), nil +} + +func parseNullableTimestamp(raw sql.NullString) (*time.Time, error) { + if !raw.Valid || strings.TrimSpace(raw.String) == "" { + return nil, nil + } + + parsed, err := parseTimestamp(raw.String) + if err != nil { + return nil, err + } + + return &parsed, nil +} diff --git a/internal/db/users.go b/internal/db/users.go new file mode 100644 index 0000000..760e478 --- /dev/null +++ b/internal/db/users.go @@ -0,0 +1,165 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type CreateUserParams struct { + Email string + PasswordHash string + Role UserRole + IsActive bool +} + +func (r *UserRepository) HasActiveAdmin(ctx context.Context) (bool, error) { + var exists int + if err := r.q.QueryRowContext( + ctx, + `SELECT EXISTS(SELECT 1 FROM users WHERE role = ? AND is_active = 1 LIMIT 1)`, + UserRoleAdmin, + ).Scan(&exists); err != nil { + return false, fmt.Errorf("query active admin existence: %w", err) + } + + return exists == 1, nil +} + +func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) { + user, err := scanUser(r.q.QueryRowContext( + ctx, + `SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at + FROM users + WHERE id = ?`, + id, + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan user by id: %w", err) + } + + return user, nil +} + +func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*User, error) { + user, err := scanUser(r.q.QueryRowContext( + ctx, + `SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at + FROM users + WHERE email = ? COLLATE NOCASE + LIMIT 1`, + strings.TrimSpace(email), + )) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("scan user by email: %w", err) + } + + return user, nil +} + +func (r *UserRepository) Create(ctx context.Context, params CreateUserParams) (*User, error) { + isActive := 0 + if params.IsActive { + isActive = 1 + } + + result, err := r.q.ExecContext( + ctx, + `INSERT INTO users (email, password_hash, role, is_active) VALUES (?, ?, ?, ?)`, + strings.TrimSpace(params.Email), + params.PasswordHash, + params.Role, + isActive, + ) + if err != nil { + return nil, fmt.Errorf("insert user: %w", err) + } + + userID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("load inserted user id: %w", err) + } + + return r.GetByID(ctx, userID) +} + +func (r *UserRepository) UpdateLastLoginAt(ctx context.Context, userID int64, loggedInAt time.Time) error { + result, err := r.q.ExecContext( + ctx, + `UPDATE users SET last_login_at = ? WHERE id = ?`, + formatTimestamp(loggedInAt), + userID, + ) + if err != nil { + return fmt.Errorf("update user last_login_at: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read affected user rows: %w", err) + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func scanUser(scanner rowScanner) (*User, error) { + var ( + user User + role string + isActive int + createdAtRaw string + updatedAtRaw string + lastLoginRaw sql.NullString + ) + + if err := scanner.Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &role, + &isActive, + &createdAtRaw, + &updatedAtRaw, + &lastLoginRaw, + ); err != nil { + return nil, err + } + + createdAt, err := parseTimestamp(createdAtRaw) + if err != nil { + return nil, fmt.Errorf("parse user created_at: %w", err) + } + + updatedAt, err := parseTimestamp(updatedAtRaw) + if err != nil { + return nil, fmt.Errorf("parse user updated_at: %w", err) + } + + lastLoginAt, err := parseNullableTimestamp(lastLoginRaw) + if err != nil { + return nil, fmt.Errorf("parse user last_login_at: %w", err) + } + + user.Role = UserRole(role) + user.IsActive = isActive == 1 + user.CreatedAt = createdAt + user.UpdatedAt = updatedAt + user.LastLoginAt = lastLoginAt + + return &user, nil +} diff --git a/internal/http/admin_api_keys.go b/internal/http/admin_api_keys.go new file mode 100644 index 0000000..5879990 --- /dev/null +++ b/internal/http/admin_api_keys.go @@ -0,0 +1,513 @@ +package httpserver + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "update_server/internal/apikeys" + "update_server/internal/db" +) + +func (h *handler) adminAPIKeys(w http.ResponseWriter, r *http.Request) { + keys, err := h.apiKeys.List(r.Context()) + if err != nil { + http.Error(w, "api key listing failed", http.StatusInternalServerError) + return + } + + data := APIKeysPageData{ + PageData: h.basePageData( + r, + "API Keys", + "Protected Admin", + "API Keys", + "Generate client credentials, assign action permissions, and control project visibility through project or tag allow and deny rules.", + ), + APIKeys: keys, + } + data.Flash = apiKeyListFlash(r.URL.Query().Get("status")) + + renderPage(w, h.renderer, "api_keys", data) +} + +func (h *handler) adminAPIKeyNew(w http.ResponseWriter, r *http.Request) { + data := APIKeyPageData{ + PageData: h.basePageData( + r, + "New API Key", + "Protected Admin", + "Create API Key", + "Generate a secure key, choose the minimum permissions it needs, and define access with one scope mode at a time.", + ), + Form: APIKeyFormData{ + Action: "/admin/api-keys", + SubmitLabel: "Create API key", + ScopeMode: db.ScopeModeProjectAllowList, + CanDownload: true, + }, + } + + if err := h.populateAPIKeyChoices(r, &data); err != nil { + http.Error(w, "api key form failed", http.StatusInternalServerError) + return + } + + renderPage(w, h.renderer, "api_key_form", data) +} + +func (h *handler) adminAPIKeyCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid api key form", http.StatusBadRequest) + return + } + + form, inputErr := apiKeyFormDataFromRequest(r, "/admin/api-keys", "Create API key") + if inputErr != "" { + form.Error = inputErr + h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form) + return + } + + expiresAt, err := parseAPIKeyExpiration(form.ExpiresAt) + if err != nil { + form.Error = "Expiration must be empty, YYYY-MM-DD, or RFC3339 UTC." + h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form) + return + } + + result, err := h.apiKeys.Create(r.Context(), apikeys.CreateParams{ + Name: form.Name, + Description: form.Description, + ScopeMode: form.ScopeMode, + CanDownload: form.CanDownload, + CanUpload: form.CanUpload, + CanDelete: form.CanDelete, + CanManageProjects: form.CanManageProjects, + ExpiresAt: expiresAt, + ProjectIDs: form.SelectedProjectIDs, + TagIDs: form.SelectedTagIDs, + CreatedByUserID: h.currentUserID(r), + }) + if err != nil { + if userError := apiKeyUserError(err); userError != "" { + form.Error = userError + h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form) + return + } + + http.Error(w, "api key creation failed", http.StatusInternalServerError) + return + } + + detailForm := APIKeyFormData{ + RevealKey: result.RawKey, + } + + h.renderAPIKeyDetailPage(w, r, result.APIKey.ID, http.StatusOK, detailForm) +} + +func (h *handler) adminAPIKeyDetail(w http.ResponseWriter, r *http.Request) { + apiKeyID, err := routeID(r, "apiKeyID") + if err != nil { + http.NotFound(w, r) + return + } + + data, err := h.apiKeyPageData(r, apiKeyID, APIKeyFormData{}) + if err != nil { + h.renderAPIKeyDetailError(w, r, err) + return + } + + data.Flash = apiKeyDetailFlash(r.URL.Query().Get("status")) + renderPage(w, h.renderer, "api_key_form", *data) +} + +func (h *handler) adminAPIKeyUpdate(w http.ResponseWriter, r *http.Request) { + apiKeyID, err := routeID(r, "apiKeyID") + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid api key form", http.StatusBadRequest) + return + } + + form, inputErr := apiKeyFormDataFromRequest(r, fmt.Sprintf("/admin/api-keys/%d", apiKeyID), "Save API key") + if inputErr != "" { + form.Error = inputErr + h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form) + return + } + + expiresAt, err := parseAPIKeyExpiration(form.ExpiresAt) + if err != nil { + form.Error = "Expiration must be empty, YYYY-MM-DD, or RFC3339 UTC." + h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form) + return + } + + _, err = h.apiKeys.Update(r.Context(), apiKeyID, apikeys.UpdateParams{ + Name: form.Name, + Description: form.Description, + ScopeMode: form.ScopeMode, + CanDownload: form.CanDownload, + CanUpload: form.CanUpload, + CanDelete: form.CanDelete, + CanManageProjects: form.CanManageProjects, + ExpiresAt: expiresAt, + ProjectIDs: form.SelectedProjectIDs, + TagIDs: form.SelectedTagIDs, + }) + if err != nil { + switch { + case errors.Is(err, db.ErrNotFound): + http.NotFound(w, r) + case apiKeyUserError(err) != "": + form.Error = apiKeyUserError(err) + h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form) + default: + http.Error(w, "api key update failed", http.StatusInternalServerError) + } + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/api-keys/%d?status=api-key-updated", apiKeyID), http.StatusSeeOther) +} + +func (h *handler) adminAPIKeyToggleActive(w http.ResponseWriter, r *http.Request) { + apiKeyID, err := routeID(r, "apiKeyID") + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid api key lifecycle form", http.StatusBadRequest) + return + } + + var ( + isActive bool + statusCode string + ) + + switch strings.TrimSpace(r.FormValue("state")) { + case "activate": + isActive = true + statusCode = "api-key-activated" + case "revoke": + isActive = false + statusCode = "api-key-revoked" + default: + http.Error(w, "invalid lifecycle action", http.StatusBadRequest) + return + } + + if err := h.apiKeys.SetActive(r.Context(), apiKeyID, isActive); err != nil { + if errors.Is(err, db.ErrNotFound) { + http.NotFound(w, r) + return + } + + http.Error(w, "api key lifecycle update failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/api-keys/%d?status=%s", apiKeyID, statusCode), http.StatusSeeOther) +} + +func (h *handler) renderAPIKeyNewPage(w http.ResponseWriter, r *http.Request, status int, form APIKeyFormData) { + data := APIKeyPageData{ + PageData: h.basePageData( + r, + "New API Key", + "Protected Admin", + "Create API Key", + "Generate a secure key, choose the minimum permissions it needs, and define access with one scope mode at a time.", + ), + Form: form, + } + + if err := h.populateAPIKeyChoices(r, &data); err != nil { + http.Error(w, "api key form failed", http.StatusInternalServerError) + return + } + + renderPageStatus(w, h.renderer, "api_key_form", status, data) +} + +func (h *handler) renderAPIKeyDetailPage(w http.ResponseWriter, r *http.Request, apiKeyID int64, status int, form APIKeyFormData) { + if form.RevealKey != "" { + applyNoStoreHeaders(w) + } + + data, err := h.apiKeyPageData(r, apiKeyID, form) + if err != nil { + h.renderAPIKeyDetailError(w, r, err) + return + } + + renderPageStatus(w, h.renderer, "api_key_form", status, *data) +} + +func (h *handler) renderAPIKeyDetailError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, db.ErrNotFound) { + http.NotFound(w, r) + return + } + + http.Error(w, "api key page failed", http.StatusInternalServerError) +} + +func (h *handler) apiKeyPageData(r *http.Request, apiKeyID int64, form APIKeyFormData) (*APIKeyPageData, error) { + apiKey, err := h.apiKeys.GetByID(r.Context(), apiKeyID) + if err != nil { + return nil, err + } + + projectAccess, err := h.apiKeys.ListProjectAccess(r.Context(), apiKeyID) + if err != nil { + return nil, err + } + + tagAccess, err := h.apiKeys.ListTagAccess(r.Context(), apiKeyID) + if err != nil { + return nil, err + } + + accessibleProjects, err := h.apiKeys.ListAccessibleProjects(r.Context(), *apiKey) + if err != nil { + return nil, err + } + + if form.Action == "" { + form = APIKeyFormData{ + Action: fmt.Sprintf("/admin/api-keys/%d", apiKeyID), + SubmitLabel: "Save API key", + Name: apiKey.Name, + Description: apiKey.Description, + ScopeMode: apiKey.ScopeMode, + ExpiresAt: formatOptionalTimestamp(apiKey.ExpiresAt), + CanDownload: apiKey.CanDownload, + CanUpload: apiKey.CanUpload, + CanDelete: apiKey.CanDelete, + CanManageProjects: apiKey.CanManageProjects, + SelectedProjectIDs: projectIDs(projectAccess), + SelectedTagIDs: tagIDs(tagAccess), + Error: form.Error, + RevealKey: form.RevealKey, + } + } + + data := &APIKeyPageData{ + PageData: h.basePageData( + r, + apiKey.Name, + "Protected Admin", + apiKey.Name, + "Rotate permissions, change scope mode, and preview which active projects this key can currently reach.", + ), + APIKey: apiKey, + Form: form, + AccessibleProjects: accessibleProjects, + ToggleAction: fmt.Sprintf("/admin/api-keys/%d/activate", apiKeyID), + } + + if apiKey.IsActive { + data.ToggleState = "revoke" + data.ToggleLabel = "Revoke key" + } else { + data.ToggleState = "activate" + data.ToggleLabel = "Activate key" + } + + if err := h.populateAPIKeyChoices(r, data); err != nil { + return nil, err + } + + return data, nil +} + +func (h *handler) populateAPIKeyChoices(r *http.Request, data *APIKeyPageData) error { + projectItems, err := h.store.Projects.List(r.Context()) + if err != nil { + return err + } + + tagItems, err := h.store.Tags.List(r.Context()) + if err != nil { + return err + } + + selectedProjects := make(map[int64]struct{}, len(data.Form.SelectedProjectIDs)) + for _, id := range data.Form.SelectedProjectIDs { + selectedProjects[id] = struct{}{} + } + + selectedTags := make(map[int64]struct{}, len(data.Form.SelectedTagIDs)) + for _, id := range data.Form.SelectedTagIDs { + selectedTags[id] = struct{}{} + } + + data.ProjectChoices = make([]APIKeyProjectChoice, 0, len(projectItems)) + for _, item := range projectItems { + _, selected := selectedProjects[item.Project.ID] + data.ProjectChoices = append(data.ProjectChoices, APIKeyProjectChoice{ + Project: item.Project, + Selected: selected, + }) + } + + data.TagChoices = make([]APIKeyTagChoice, 0, len(tagItems)) + for _, item := range tagItems { + _, selected := selectedTags[item.Tag.ID] + data.TagChoices = append(data.TagChoices, APIKeyTagChoice{ + Tag: item.Tag, + Selected: selected, + }) + } + + return nil +} + +func apiKeyFormDataFromRequest(r *http.Request, action, submitLabel string) (APIKeyFormData, string) { + form := APIKeyFormData{ + Action: action, + SubmitLabel: submitLabel, + Name: strings.TrimSpace(r.FormValue("name")), + Description: strings.TrimSpace(r.FormValue("description")), + ScopeMode: db.ScopeMode(strings.TrimSpace(r.FormValue("scope_mode"))), + ExpiresAt: strings.TrimSpace(r.FormValue("expires_at")), + CanDownload: r.FormValue("can_download") != "", + CanUpload: r.FormValue("can_upload") != "", + CanDelete: r.FormValue("can_delete") != "", + CanManageProjects: r.FormValue("can_manage_projects") != "", + } + + projectIDs, err := parseMultiIDList(r.Form["project_id"]) + if err != nil { + return form, "Choose only valid projects." + } + form.SelectedProjectIDs = projectIDs + + tagIDs, err := parseMultiIDList(r.Form["tag_id"]) + if err != nil { + return form, "Choose only valid tags." + } + form.SelectedTagIDs = tagIDs + + if form.Name == "" { + return form, "API key name is required." + } + + return form, "" +} + +func parseMultiIDList(values []string) ([]int64, error) { + result := make([]int64, 0, len(values)) + for _, raw := range values { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value <= 0 { + return nil, fmt.Errorf("invalid id") + } + + result = append(result, value) + } + + return result, nil +} + +func parseAPIKeyExpiration(raw string) (*time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + utc := parsed.UTC() + return &utc, nil + } + + if parsed, err := time.Parse("2006-01-02", raw); err == nil { + utc := parsed.UTC() + return &utc, nil + } + + return nil, fmt.Errorf("invalid expiration") +} + +func formatOptionalTimestamp(value *time.Time) string { + if value == nil { + return "" + } + + return value.UTC().Format(time.RFC3339) +} + +func apiKeyUserError(err error) string { + message := strings.TrimSpace(err.Error()) + lower := strings.ToLower(message) + + switch { + case strings.Contains(lower, "api key name is required"): + return "API key name is required." + case strings.Contains(lower, "scope mode is required"): + return "Choose one scope mode." + case strings.Contains(lower, "select at least one permission"): + return "Select at least one permission." + case strings.Contains(lower, "foreign key"): + return "One of the selected projects or tags no longer exists." + default: + return "" + } +} + +func projectIDs(projects []db.Project) []int64 { + ids := make([]int64, 0, len(projects)) + for _, project := range projects { + ids = append(ids, project.ID) + } + + return ids +} + +func tagIDs(tags []db.Tag) []int64 { + ids := make([]int64, 0, len(tags)) + for _, tag := range tags { + ids = append(ids, tag.ID) + } + + return ids +} + +func apiKeyListFlash(code string) *FlashMessage { + switch code { + default: + return nil + } +} + +func apiKeyDetailFlash(code string) *FlashMessage { + switch code { + case "api-key-updated": + return &FlashMessage{Kind: "success", Message: "API key updated."} + case "api-key-activated": + return &FlashMessage{Kind: "success", Message: "API key activated."} + case "api-key-revoked": + return &FlashMessage{Kind: "success", Message: "API key revoked."} + default: + return nil + } +} diff --git a/internal/http/admin_common.go b/internal/http/admin_common.go new file mode 100644 index 0000000..4305104 --- /dev/null +++ b/internal/http/admin_common.go @@ -0,0 +1,135 @@ +package httpserver + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + authservice "update_server/internal/auth" + "update_server/internal/db" + "update_server/internal/slug" +) + +func (h *handler) basePageData(r *http.Request, title, eyebrow, heading, description string) PageData { + return PageData{ + Title: title, + Eyebrow: eyebrow, + Heading: heading, + Description: description, + BaseURL: strings.TrimRight(h.config.BaseURL, "/"), + CSRFToken: h.csrfToken(r), + CurrentUser: h.currentUser(r), + } +} + +func (h *handler) currentUser(r *http.Request) *db.User { + sessionState, ok := authservice.FromContext(r.Context()) + if !ok { + return nil + } + + user := sessionState.User + return &user +} + +func (h *handler) currentUserID(r *http.Request) *int64 { + user := h.currentUser(r) + if user == nil { + return nil + } + + return &user.ID +} + +func routeID(r *http.Request, key string) (int64, error) { + raw := strings.TrimSpace(chi.URLParam(r, key)) + if raw == "" { + return 0, fmt.Errorf("%s is required", key) + } + + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value <= 0 { + return 0, fmt.Errorf("invalid %s", key) + } + + return value, nil +} + +func projectFormInput(name, slugValue, description string) (db.CreateProjectParams, string) { + name = strings.TrimSpace(name) + slugValue = slug.Make(firstNonEmpty(slugValue, name)) + description = strings.TrimSpace(description) + + switch { + case name == "": + return db.CreateProjectParams{}, "Project name is required." + case slugValue == "": + return db.CreateProjectParams{}, "Project slug is required." + default: + return db.CreateProjectParams{ + Name: name, + Slug: slugValue, + Description: description, + }, "" + } +} + +func tagFormInput(name, slugValue, description string) (db.CreateTagParams, string) { + name = strings.TrimSpace(name) + slugValue = slug.Make(firstNonEmpty(slugValue, name)) + description = strings.TrimSpace(description) + + switch { + case name == "": + return db.CreateTagParams{}, "Tag name is required." + case slugValue == "": + return db.CreateTagParams{}, "Tag slug is required." + default: + return db.CreateTagParams{ + Name: name, + Slug: slugValue, + Description: description, + }, "" + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + + return "" +} + +func maxUploadMegabytes(bytes int64) int64 { + if bytes <= 0 { + return 0 + } + + const mebibyte = 1024 * 1024 + value := bytes / mebibyte + if bytes%mebibyte != 0 { + value++ + } + if value == 0 { + value = 1 + } + + return value +} + +func maxUploadRequestLimit(fileLimit int64) int64 { + const multipartOverhead = 1 << 20 + return fileLimit + multipartOverhead +} + +func isMaxBytesError(err error) bool { + var maxErr *http.MaxBytesError + return errors.As(err, &maxErr) +} diff --git a/internal/http/admin_projects.go b/internal/http/admin_projects.go new file mode 100644 index 0000000..bcef9fc --- /dev/null +++ b/internal/http/admin_projects.go @@ -0,0 +1,485 @@ +package httpserver + +import ( + "errors" + "fmt" + "mime/multipart" + "net/http" + "strconv" + + "update_server/internal/db" + "update_server/internal/releases" +) + +func (h *handler) adminProjects(w http.ResponseWriter, r *http.Request) { + projects, err := h.store.Projects.List(r.Context()) + if err != nil { + http.Error(w, "project listing failed", http.StatusInternalServerError) + return + } + + data := ProjectsPageData{ + PageData: h.basePageData( + r, + "Projects", + "Protected Admin", + "Projects", + "Create projects, update their metadata, archive them when needed, and open each project to manage tags and release uploads.", + ), + Projects: projects, + } + data.Flash = projectListFlash(r.URL.Query().Get("status")) + + renderPage(w, h.renderer, "projects", data) +} + +func (h *handler) adminProjectNew(w http.ResponseWriter, r *http.Request) { + data := ProjectFormPageData{ + PageData: h.basePageData( + r, + "New Project", + "Protected Admin", + "Create Project", + "Add a new update stream with a stable slug so future releases and API access rules can target it reliably.", + ), + Form: ProjectFormData{ + Action: "/admin/projects", + SubmitLabel: "Create project", + }, + } + + renderPage(w, h.renderer, "project_form", data) +} + +func (h *handler) adminProjectCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid project form", http.StatusBadRequest) + return + } + + form := ProjectFormData{ + Action: "/admin/projects", + SubmitLabel: "Create project", + Name: r.FormValue("name"), + Slug: r.FormValue("slug"), + Description: r.FormValue("description"), + } + + params, validationError := projectFormInput(form.Name, form.Slug, form.Description) + form.Name = params.Name + form.Slug = params.Slug + form.Description = params.Description + if validationError != "" { + form.Error = validationError + h.renderProjectFormPage(w, r, http.StatusBadRequest, form) + return + } + + project, err := h.store.Projects.Create(r.Context(), params) + if err != nil { + if errors.Is(err, db.ErrConflict) { + form.Error = "A project with that slug already exists." + h.renderProjectFormPage(w, r, http.StatusConflict, form) + return + } + + http.Error(w, "project creation failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-created", project.ID), http.StatusSeeOther) +} + +func (h *handler) adminProjectDetail(w http.ResponseWriter, r *http.Request) { + projectID, err := routeID(r, "projectID") + if err != nil { + http.NotFound(w, r) + return + } + + data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{}) + if err != nil { + h.renderProjectDetailError(w, r, err) + return + } + + data.Flash = projectDetailFlash(r.URL.Query().Get("status")) + renderPage(w, h.renderer, "project_detail", *data) +} + +func (h *handler) adminProjectUpdate(w http.ResponseWriter, r *http.Request) { + projectID, err := routeID(r, "projectID") + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid project form", http.StatusBadRequest) + return + } + + form := ProjectFormData{ + Action: fmt.Sprintf("/admin/projects/%d", projectID), + SubmitLabel: "Save project", + Name: r.FormValue("name"), + Slug: r.FormValue("slug"), + Description: r.FormValue("description"), + } + + params, validationError := projectFormInput(form.Name, form.Slug, form.Description) + form.Name = params.Name + form.Slug = params.Slug + form.Description = params.Description + if validationError != "" { + form.Error = validationError + h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, form, ReleaseUploadData{}) + return + } + + _, err = h.store.Projects.Update(r.Context(), projectID, db.UpdateProjectParams(params)) + if err != nil { + switch { + case errors.Is(err, db.ErrNotFound): + http.NotFound(w, r) + case errors.Is(err, db.ErrConflict): + form.Error = "A project with that slug already exists." + h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, form, ReleaseUploadData{}) + default: + http.Error(w, "project update failed", http.StatusInternalServerError) + } + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-updated", projectID), http.StatusSeeOther) +} + +func (h *handler) adminProjectArchive(w http.ResponseWriter, r *http.Request) { + projectID, err := routeID(r, "projectID") + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid archive form", http.StatusBadRequest) + return + } + + state := r.FormValue("state") + var ( + setActive bool + statusCode string + ) + + switch state { + case "archive": + setActive = false + statusCode = "project-archived" + case "restore": + setActive = true + statusCode = "project-restored" + default: + http.Error(w, "invalid archive action", http.StatusBadRequest) + return + } + + if err := h.store.Projects.SetActive(r.Context(), projectID, setActive); err != nil { + if errors.Is(err, db.ErrNotFound) { + http.NotFound(w, r) + return + } + + http.Error(w, "project archive failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=%s", projectID, statusCode), http.StatusSeeOther) +} + +func (h *handler) adminProjectAttachTag(w http.ResponseWriter, r *http.Request) { + projectID, err := routeID(r, "projectID") + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid tag assignment form", http.StatusBadRequest) + return + } + + tagID, err := strconv.ParseInt(r.FormValue("tag_id"), 10, 64) + if err != nil || tagID <= 0 { + h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Choose a valid tag to attach.") + return + } + + if _, err := h.store.Tags.GetByID(r.Context(), tagID); err != nil { + if errors.Is(err, db.ErrNotFound) { + h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Selected tag no longer exists.") + return + } + + http.Error(w, "tag lookup failed", http.StatusInternalServerError) + return + } + + if err := h.store.Projects.AttachTag(r.Context(), projectID, tagID); err != nil { + http.Error(w, "tag assignment failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-attached", projectID), http.StatusSeeOther) +} + +func (h *handler) adminProjectDetachTag(w http.ResponseWriter, r *http.Request) { + projectID, err := routeID(r, "projectID") + if err != nil { + http.NotFound(w, r) + return + } + + tagID, err := routeID(r, "tagID") + if err != nil { + http.NotFound(w, r) + return + } + + if err := h.store.Projects.DetachTag(r.Context(), projectID, tagID); err != nil { + http.Error(w, "tag detach failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-detached", projectID), http.StatusSeeOther) +} + +func (h *handler) adminProjectUploadRelease(w http.ResponseWriter, r *http.Request) { + projectID, err := routeID(r, "projectID") + if err != nil { + http.NotFound(w, r) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes)) + if err := r.ParseMultipartForm(16 << 20); err != nil { + upload := ReleaseUploadData{ + Version: r.FormValue("version"), + Build: r.FormValue("build"), + ReleaseNotes: r.FormValue("release_notes"), + } + + if isMaxBytesError(err) { + upload.Error = fmt.Sprintf("Upload exceeds the configured %d MB limit.", maxUploadMegabytes(h.config.MaxUploadBytes)) + } else { + upload.Error = "Upload form could not be read." + } + + h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload) + return + } + defer func() { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } + }() + + upload := ReleaseUploadData{ + Version: r.FormValue("version"), + Build: r.FormValue("build"), + ReleaseNotes: r.FormValue("release_notes"), + } + + file, header, err := r.FormFile("artifact") + if err != nil { + upload.Error = "Choose an artifact file to upload." + h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload) + return + } + defer file.Close() + + if err := h.handleReleaseUpload(r, projectID, file, header, upload); err != nil { + switch { + case errors.Is(err, db.ErrNotFound): + http.NotFound(w, r) + case errors.Is(err, db.ErrConflict): + upload.Error = "A release with that version and build already exists for this project." + h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, ProjectFormData{}, upload) + default: + upload.Error = err.Error() + h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload) + } + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=release-uploaded", projectID), http.StatusSeeOther) +} + +func (h *handler) handleReleaseUpload(r *http.Request, projectID int64, file multipart.File, header *multipart.FileHeader, upload ReleaseUploadData) error { + _, err := h.releases.Upload(r.Context(), releases.UploadParams{ + ProjectID: projectID, + Version: upload.Version, + Build: upload.Build, + ReleaseNotes: upload.ReleaseNotes, + OriginalFilename: header.Filename, + DeclaredType: header.Header.Get("Content-Type"), + Reader: file, + UploadedByUserID: h.currentUserID(r), + }) + if err != nil { + return err + } + + return nil +} + +func (h *handler) renderProjectFormPage(w http.ResponseWriter, r *http.Request, status int, form ProjectFormData) { + data := ProjectFormPageData{ + PageData: h.basePageData( + r, + "New Project", + "Protected Admin", + "Create Project", + "Add a new update stream with a stable slug so future releases and API access rules can target it reliably.", + ), + Form: form, + } + + renderPageStatus(w, h.renderer, "project_form", status, data) +} + +func (h *handler) renderProjectDetailPage(w http.ResponseWriter, r *http.Request, projectID int64, status int, form ProjectFormData, upload ReleaseUploadData) { + data, err := h.projectDetailPageData(r, projectID, form, upload) + if err != nil { + h.renderProjectDetailError(w, r, err) + return + } + + renderPageStatus(w, h.renderer, "project_detail", status, *data) +} + +func (h *handler) renderProjectDetailPageWithFlash(w http.ResponseWriter, r *http.Request, projectID int64, status int, kind, message string) { + data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{}) + if err != nil { + h.renderProjectDetailError(w, r, err) + return + } + + data.Flash = &FlashMessage{Kind: kind, Message: message} + renderPageStatus(w, h.renderer, "project_detail", status, *data) +} + +func (h *handler) renderProjectDetailError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, db.ErrNotFound) { + http.NotFound(w, r) + return + } + + http.Error(w, "project page failed", http.StatusInternalServerError) +} + +func (h *handler) projectDetailPageData(r *http.Request, projectID int64, form ProjectFormData, upload ReleaseUploadData) (*ProjectDetailPageData, error) { + project, err := h.store.Projects.GetByID(r.Context(), projectID) + if err != nil { + return nil, err + } + + tags, err := h.store.Projects.ListTags(r.Context(), projectID) + if err != nil { + return nil, err + } + + availableTags, err := h.store.Tags.ListAvailableForProject(r.Context(), projectID) + if err != nil { + return nil, err + } + + releasesList, err := h.store.Releases.ListByProjectID(r.Context(), projectID) + if err != nil { + return nil, err + } + + var latestRelease *db.Release + if len(releasesList) > 0 { + release := releasesList[0].Release + latestRelease = &release + } + + if form.Action == "" { + form = ProjectFormData{ + Action: fmt.Sprintf("/admin/projects/%d", projectID), + SubmitLabel: "Save project", + Name: project.Name, + Slug: project.Slug, + Description: project.Description, + Error: form.Error, + } + } + + if upload.Action == "" { + upload.Action = fmt.Sprintf("/admin/projects/%d/releases", projectID) + } + if upload.MaxUploadMB == 0 { + upload.MaxUploadMB = maxUploadMegabytes(h.config.MaxUploadBytes) + } + + archiveState := "archive" + archiveLabel := "Archive project" + if !project.IsActive { + archiveState = "restore" + archiveLabel = "Restore project" + } + + data := &ProjectDetailPageData{ + PageData: h.basePageData( + r, + project.Name, + "Protected Admin", + project.Name, + "Edit project metadata, manage tag assignments, and upload releases with checksums stored in SQLite.", + ), + Project: *project, + Form: form, + Tags: tags, + AvailableTags: availableTags, + Releases: releasesList, + LatestRelease: latestRelease, + AttachTagAction: fmt.Sprintf("/admin/projects/%d/tags", projectID), + ArchiveAction: fmt.Sprintf("/admin/projects/%d/archive", projectID), + ArchiveState: archiveState, + ArchiveLabel: archiveLabel, + Upload: upload, + } + + return data, nil +} + +func projectListFlash(code string) *FlashMessage { + switch code { + case "": + return nil + default: + return nil + } +} + +func projectDetailFlash(code string) *FlashMessage { + switch code { + case "project-created": + return &FlashMessage{Kind: "success", Message: "Project created. You can now attach tags and upload releases."} + case "project-updated": + return &FlashMessage{Kind: "success", Message: "Project details updated."} + case "project-archived": + return &FlashMessage{Kind: "success", Message: "Project archived."} + case "project-restored": + return &FlashMessage{Kind: "success", Message: "Project restored and active again."} + case "tag-attached": + return &FlashMessage{Kind: "success", Message: "Tag attached to the project."} + case "tag-detached": + return &FlashMessage{Kind: "success", Message: "Tag detached from the project."} + case "release-uploaded": + return &FlashMessage{Kind: "success", Message: "Release uploaded. Metadata, checksum, and storage path were saved."} + default: + return nil + } +} diff --git a/internal/http/admin_tags.go b/internal/http/admin_tags.go new file mode 100644 index 0000000..60c6f45 --- /dev/null +++ b/internal/http/admin_tags.go @@ -0,0 +1,274 @@ +package httpserver + +import ( + "errors" + "fmt" + "net/http" + + "update_server/internal/db" +) + +func (h *handler) adminTags(w http.ResponseWriter, r *http.Request) { + tags, err := h.store.Tags.List(r.Context()) + if err != nil { + http.Error(w, "tag listing failed", http.StatusInternalServerError) + return + } + + data := TagsPageData{ + PageData: h.basePageData( + r, + "Tags", + "Protected Admin", + "Tags", + "Create reusable tags now so projects can be grouped for future access rules and release discovery endpoints.", + ), + Tags: tags, + } + data.Flash = tagListFlash(r.URL.Query().Get("status")) + + renderPage(w, h.renderer, "tags", data) +} + +func (h *handler) adminTagNew(w http.ResponseWriter, r *http.Request) { + data := TagFormPageData{ + PageData: h.basePageData( + r, + "New Tag", + "Protected Admin", + "Create Tag", + "Add a reusable label that can be attached to projects from day one and reused later in API key access rules.", + ), + Form: TagFormData{ + Action: "/admin/tags", + SubmitLabel: "Create tag", + }, + } + + renderPage(w, h.renderer, "tag_form", data) +} + +func (h *handler) adminTagCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid tag form", http.StatusBadRequest) + return + } + + form := TagFormData{ + Action: "/admin/tags", + SubmitLabel: "Create tag", + Name: r.FormValue("name"), + Slug: r.FormValue("slug"), + Description: r.FormValue("description"), + } + + params, validationError := tagFormInput(form.Name, form.Slug, form.Description) + form.Name = params.Name + form.Slug = params.Slug + form.Description = params.Description + if validationError != "" { + form.Error = validationError + h.renderTagFormPage(w, r, http.StatusBadRequest, form) + return + } + + tag, err := h.store.Tags.Create(r.Context(), params) + if err != nil { + if errors.Is(err, db.ErrConflict) { + form.Error = "A tag with that slug already exists." + h.renderTagFormPage(w, r, http.StatusConflict, form) + return + } + + http.Error(w, "tag creation failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?status=tag-created", tag.ID), http.StatusSeeOther) +} + +func (h *handler) adminTagDetail(w http.ResponseWriter, r *http.Request) { + tagID, err := routeID(r, "tagID") + if err != nil { + http.NotFound(w, r) + return + } + + data, err := h.tagDetailPageData(r, tagID, TagFormData{}) + if err != nil { + h.renderTagDetailError(w, r, err) + return + } + + data.Flash = tagDetailFlash(r.URL.Query().Get("status"), r.URL.Query().Get("error")) + renderPage(w, h.renderer, "tag_form", *data) +} + +func (h *handler) adminTagUpdate(w http.ResponseWriter, r *http.Request) { + tagID, err := routeID(r, "tagID") + if err != nil { + http.NotFound(w, r) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid tag form", http.StatusBadRequest) + return + } + + form := TagFormData{ + Action: fmt.Sprintf("/admin/tags/%d", tagID), + SubmitLabel: "Save tag", + Name: r.FormValue("name"), + Slug: r.FormValue("slug"), + Description: r.FormValue("description"), + } + + params, validationError := tagFormInput(form.Name, form.Slug, form.Description) + form.Name = params.Name + form.Slug = params.Slug + form.Description = params.Description + if validationError != "" { + form.Error = validationError + h.renderTagDetailPage(w, r, tagID, http.StatusBadRequest, form) + return + } + + _, err = h.store.Tags.Update(r.Context(), tagID, db.UpdateTagParams(params)) + if err != nil { + switch { + case errors.Is(err, db.ErrNotFound): + http.NotFound(w, r) + case errors.Is(err, db.ErrConflict): + form.Error = "A tag with that slug already exists." + h.renderTagDetailPage(w, r, tagID, http.StatusConflict, form) + default: + http.Error(w, "tag update failed", http.StatusInternalServerError) + } + return + } + + http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?status=tag-updated", tagID), http.StatusSeeOther) +} + +func (h *handler) adminTagDelete(w http.ResponseWriter, r *http.Request) { + tagID, err := routeID(r, "tagID") + if err != nil { + http.NotFound(w, r) + return + } + + err = h.store.Tags.Delete(r.Context(), tagID) + if err != nil { + switch { + case errors.Is(err, db.ErrNotFound): + http.NotFound(w, r) + case errors.Is(err, db.ErrConflict): + http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?error=tag-in-use", tagID), http.StatusSeeOther) + default: + http.Error(w, "tag delete failed", http.StatusInternalServerError) + } + return + } + + http.Redirect(w, r, "/admin/tags?status=tag-deleted", http.StatusSeeOther) +} + +func (h *handler) renderTagFormPage(w http.ResponseWriter, r *http.Request, status int, form TagFormData) { + data := TagFormPageData{ + PageData: h.basePageData( + r, + "New Tag", + "Protected Admin", + "Create Tag", + "Add a reusable label that can be attached to projects from day one and reused later in API key access rules.", + ), + Form: form, + } + + renderPageStatus(w, h.renderer, "tag_form", status, data) +} + +func (h *handler) renderTagDetailPage(w http.ResponseWriter, r *http.Request, tagID int64, status int, form TagFormData) { + data, err := h.tagDetailPageData(r, tagID, form) + if err != nil { + h.renderTagDetailError(w, r, err) + return + } + + renderPageStatus(w, h.renderer, "tag_form", status, *data) +} + +func (h *handler) renderTagDetailError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, db.ErrNotFound) { + http.NotFound(w, r) + return + } + + http.Error(w, "tag page failed", http.StatusInternalServerError) +} + +func (h *handler) tagDetailPageData(r *http.Request, tagID int64, form TagFormData) (*TagFormPageData, error) { + tag, err := h.store.Tags.GetByID(r.Context(), tagID) + if err != nil { + return nil, err + } + + projects, err := h.store.Tags.ListProjects(r.Context(), tagID) + if err != nil { + return nil, err + } + + if form.Action == "" { + form = TagFormData{ + Action: fmt.Sprintf("/admin/tags/%d", tagID), + SubmitLabel: "Save tag", + Name: tag.Name, + Slug: tag.Slug, + Description: tag.Description, + Error: form.Error, + } + } + form.DeleteAction = fmt.Sprintf("/admin/tags/%d/delete", tagID) + form.CanDelete = len(projects) == 0 + + data := &TagFormPageData{ + PageData: h.basePageData( + r, + tag.Name, + "Protected Admin", + tag.Name, + "Edit this tag and review which projects already use it before preparing future access rules.", + ), + Form: form, + Tag: tag, + Projects: projects, + } + + return data, nil +} + +func tagListFlash(code string) *FlashMessage { + switch code { + case "tag-deleted": + return &FlashMessage{Kind: "success", Message: "Tag deleted."} + default: + return nil + } +} + +func tagDetailFlash(statusCode, errorCode string) *FlashMessage { + switch statusCode { + case "tag-created": + return &FlashMessage{Kind: "success", Message: "Tag created. You can now attach it to projects."} + case "tag-updated": + return &FlashMessage{Kind: "success", Message: "Tag details updated."} + } + + switch errorCode { + case "tag-in-use": + return &FlashMessage{Kind: "error", Message: "This tag is still attached to one or more projects, so it cannot be deleted yet."} + default: + return nil + } +} diff --git a/internal/http/api_key_middleware.go b/internal/http/api_key_middleware.go new file mode 100644 index 0000000..430f46a --- /dev/null +++ b/internal/http/api_key_middleware.go @@ -0,0 +1,111 @@ +package httpserver + +import ( + "errors" + "net/http" + "strings" + + "update_server/internal/apikeys" +) + +func (h *handler) requireAPIKey(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.apiKeys == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "api key auth is unavailable"}) + return + } + + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "missing bearer api key") + return + } + + state, err := h.apiKeys.Authenticate(r.Context(), token) + if err != nil { + if errors.Is(err, apikeys.ErrUnauthenticated) { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "invalid api key") + return + } + + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "api key lookup failed"}) + return + } + + next.ServeHTTP(w, r.WithContext(apikeys.NewContext(r.Context(), state))) + }) +} + +func (h *handler) requireAPIKeyPermission(permission apikeys.Permission) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + state, ok := apikeys.FromContext(r.Context()) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required") + return + } + + if !apikeys.HasPermission(state.APIKey, permission) { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "api key permission denied"}) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func (h *handler) requireAPIKeyProjectAccess(routeParam string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + state, ok := apikeys.FromContext(r.Context()) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required") + return + } + + projectID, err := routeID(r, routeParam) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid project id"}) + return + } + + allowed, err := h.apiKeys.CanAccessProject(r.Context(), state.APIKey, projectID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "project access lookup failed"}) + return + } + + if !allowed { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "api key cannot access this project"}) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func bearerToken(header string) (string, bool) { + header = strings.TrimSpace(header) + if header == "" { + return "", false + } + + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", false + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return "", false + } + + return token, true +} + +func writeAPIKeyAuthError(w http.ResponseWriter, status int, message string) { + w.Header().Set("WWW-Authenticate", `Bearer realm="update-server"`) + writeJSON(w, status, map[string]any{"error": message}) +} diff --git a/internal/http/api_key_middleware_test.go b/internal/http/api_key_middleware_test.go new file mode 100644 index 0000000..e9e9ece --- /dev/null +++ b/internal/http/api_key_middleware_test.go @@ -0,0 +1,162 @@ +package httpserver + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "update_server/internal/apikeys" + "update_server/internal/db" +) + +func TestAPIKeyMiddlewareEnforcesAuthPermissionAndProjectScope(t *testing.T) { + t.Parallel() + + h, service, store := newAPIKeyMiddlewareTestHandler(t) + ctx := context.Background() + + allowedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Desktop App", Slug: "desktop-app"}) + if err != nil { + t.Fatalf("create allowed project: %v", err) + } + + blockedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Mobile App", Slug: "mobile-app"}) + if err != nil { + t.Fatalf("create blocked project: %v", err) + } + + validKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Download Clients", + ScopeMode: db.ScopeModeProjectAllowList, + CanDownload: true, + ProjectIDs: []int64{allowedProject.ID}, + }) + if err != nil { + t.Fatalf("create valid api key: %v", err) + } + + noPermissionKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "No Permission", + ScopeMode: db.ScopeModeAllProjects, + CanUpload: true, + ProjectIDs: nil, + }) + if err != nil { + t.Fatalf("create no-permission api key: %v", err) + } + + expiredAt := time.Now().UTC().Add(-time.Hour) + expiredKey, err := service.Create(ctx, apikeys.CreateParams{ + Name: "Expired Key", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + ExpiresAt: &expiredAt, + }) + if err != nil { + t.Fatalf("create expired api key: %v", err) + } + + router := chi.NewRouter() + router.Route("/api", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(h.requireAPIKey) + r.Use(h.requireAPIKeyPermission(apikeys.PermissionDownload)) + r.With(h.requireAPIKeyProjectAccess("projectID")).Get("/projects/{projectID}", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + }) + }) + }) + + recorder := performMiddlewareRequest(router, "/api/projects/1", "") + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected missing auth to return 401, got %d", recorder.Code) + } + + if header := recorder.Header().Get("WWW-Authenticate"); header == "" { + t.Fatal("expected missing auth to include WWW-Authenticate header") + } + + recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+noPermissionKey.RawKey) + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected missing permission to return 403, got %d", recorder.Code) + } + + recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, blockedProject.ID), "Bearer "+validKey.RawKey) + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected blocked project to return 403, got %d", recorder.Code) + } + + recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+expiredKey.RawKey) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected expired key to return 401, got %d", recorder.Code) + } + + recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+validKey.RawKey) + if recorder.Code != http.StatusOK { + t.Fatalf("expected allowed project to return 200, got %d with body %s", recorder.Code, recorder.Body.String()) + } +} + +func newAPIKeyMiddlewareTestHandler(t *testing.T) (*handler, *apikeys.Service, *db.Store) { + t.Helper() + + ctx := context.Background() + sqlitePath := filepath.Join(t.TempDir(), "http-api-keys.sqlite") + database, err := db.Open(ctx, sqlitePath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.Migrate(ctx, database, middlewareProjectPath(t, "migrations")); err != nil { + _ = database.Close() + t.Fatalf("migrate sqlite: %v", err) + } + + store := db.NewStore(database) + t.Cleanup(func() { + _ = store.Close() + }) + + service := apikeys.NewService(store) + return &handler{ + store: store, + apiKeys: service, + }, service, store +} + +func middlewareProjectPath(t *testing.T, parts ...string) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve caller path") + } + + root := filepath.Join(filepath.Dir(filename), "..", "..") + items := append([]string{root}, parts...) + return filepath.Join(items...) +} + +func performMiddlewareRequest(router http.Handler, target, authorization string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, target, nil) + req.RemoteAddr = "127.0.0.1:12345" + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + return recorder +} + +func itoa(t *testing.T, value int64) string { + t.Helper() + return strconv.FormatInt(value, 10) +} diff --git a/internal/http/api_keys_integration_test.go b/internal/http/api_keys_integration_test.go new file mode 100644 index 0000000..ca9fbd3 --- /dev/null +++ b/internal/http/api_keys_integration_test.go @@ -0,0 +1,213 @@ +package httpserver_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strconv" + "strings" + "testing" +) + +func TestAdminAPIKeyFlowRevealsRawKeyOnceAndSupportsRevocation(t *testing.T) { + t.Parallel() + + router, _, store := newTestRouterWithStore(t) + sessionCookie := loginAsAdmin(t, router) + + projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + }, http.StatusSeeOther, sessionCookie) + projectID := extractResourceID(t, projectLocation, "/admin/projects/") + + submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Mobile App"}, + "slug": {"mobile-app"}, + }, http.StatusSeeOther, sessionCookie) + + tagLocation := submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{ + "name": {"Windows"}, + "slug": {"windows"}, + }, http.StatusSeeOther, sessionCookie) + tagID := extractResourceID(t, tagLocation, "/admin/tags/") + + submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/tags", url.Values{ + "tag_id": {tagID}, + }, http.StatusSeeOther, sessionCookie) + + csrfCookie := ensureCSRFCookie(t, router, sessionCookie) + recorder := performRequest(t, router, http.MethodPost, "/admin/api-keys", url.Values{ + "name": {"Windows Clients"}, + "description": {"Download access for Windows builds."}, + "scope_mode": {"tag_allow_list"}, + "can_download": {"1"}, + "can_manage_projects": {"1"}, + "tag_id": {tagID}, + "csrf_token": {csrfCookie.Value}, + }, sessionCookie, csrfCookie) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected create api key page, got %d with body %s", recorder.Code, recorder.Body.String()) + } + + assertHeaderContains(t, recorder, "Cache-Control", "no-store") + assertHeaderEquals(t, recorder, "Pragma", "no-cache") + assertHeaderEquals(t, recorder, "Expires", "0") + + body := recorder.Body.String() + rawKey := findRawAPIKey(t, body) + if !strings.Contains(body, rawKey) { + t.Fatal("expected raw api key to be shown on creation response") + } + + assertBodyContains(t, body, `value="Windows Clients"`) + assertBodyContains(t, body, "Download access for Windows builds.") + assertBodyContains(t, body, `option value="tag_allow_list" selected`) + assertBodyContains(t, body, `name="can_download" value="1" checked`) + assertBodyContains(t, body, `name="can_manage_projects" value="1" checked`) + assertBodyContains(t, body, `name="tag_id" value="`+tagID+`" checked`) + assertBodyContains(t, body, "/api/v1/projects") + assertBodyContains(t, body, "/api/v1/projects/desktop-app/releases/latest") + assertBodyContains(t, body, "Authorization: Bearer") + + keys, err := store.APIKeys.List(context.Background()) + if err != nil { + t.Fatalf("list api keys: %v", err) + } + + if len(keys) != 1 { + t.Fatalf("expected one api key, got %d", len(keys)) + } + + keyID := keys[0].APIKey.ID + storedKey, err := store.APIKeys.GetByID(context.Background(), keyID) + if err != nil { + t.Fatalf("load stored api key: %v", err) + } + + if storedKey.KeyHash == rawKey { + t.Fatal("expected stored api key hash to differ from the raw key") + } + + accessibleProjects, err := store.APIKeys.ListAccessibleProjects(context.Background(), keyID, storedKey.ScopeMode) + if err != nil { + t.Fatalf("list accessible projects: %v", err) + } + + if len(accessibleProjects) != 1 || accessibleProjects[0].Name != "Desktop App" { + t.Fatalf("expected only Desktop App to be accessible, got %+v", accessibleProjects) + } + + recorder = performRequest(t, router, http.MethodGet, "/admin/api-keys/"+itoa64(keyID), nil, sessionCookie) + if recorder.Code != http.StatusOK { + t.Fatalf("expected api key detail page to load, got %d", recorder.Code) + } + + if strings.Contains(recorder.Body.String(), rawKey) { + t.Fatal("expected raw api key to disappear after the creation response") + } + + submitForm(t, router, http.MethodPost, "/admin/api-keys/"+itoa64(keyID)+"/activate", url.Values{ + "state": {"revoke"}, + }, http.StatusSeeOther, sessionCookie) + + storedKey, err = store.APIKeys.GetByID(context.Background(), keyID) + if err != nil { + t.Fatalf("reload revoked api key: %v", err) + } + + if storedKey.IsActive { + t.Fatal("expected api key to be revoked") + } +} + +func TestAdminAPIKeyCreateResponseShowsPersistedProjectRules(t *testing.T) { + t.Parallel() + + router, _, _ := newTestRouterWithStore(t) + sessionCookie := loginAsAdmin(t, router) + + projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + }, http.StatusSeeOther, sessionCookie) + projectID := extractResourceID(t, projectLocation, "/admin/projects/") + + submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Mobile App"}, + "slug": {"mobile-app"}, + }, http.StatusSeeOther, sessionCookie) + + csrfCookie := ensureCSRFCookie(t, router, sessionCookie) + recorder := performRequest(t, router, http.MethodPost, "/admin/api-keys", url.Values{ + "name": {"Desktop Only"}, + "description": {"Project allow-list clients."}, + "scope_mode": {"project_allow_list"}, + "can_download": {"1"}, + "project_id": {projectID}, + "csrf_token": {csrfCookie.Value}, + }, sessionCookie, csrfCookie) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected create api key page, got %d with body %s", recorder.Code, recorder.Body.String()) + } + + body := recorder.Body.String() + assertBodyContains(t, body, `value="Desktop Only"`) + assertBodyContains(t, body, "Project allow-list clients.") + assertBodyContains(t, body, `option value="project_allow_list" selected`) + assertBodyContains(t, body, `name="project_id" value="`+projectID+`" checked`) + assertBodyContains(t, body, "/api/v1/projects") + assertBodyContains(t, body, "/api/v1/projects/desktop-app/releases/latest") + assertHeaderContains(t, recorder, "Cache-Control", "no-store") +} + +func findRawAPIKey(t *testing.T, body string) string { + t.Helper() + + re := regexp.MustCompile(`upsk_[A-Za-z0-9_-]+`) + matches := re.FindAllString(body, -1) + if len(matches) == 0 { + t.Fatal("expected raw api key in response body") + } + + longest := matches[0] + for _, match := range matches[1:] { + if len(match) > len(longest) { + longest = match + } + } + + return longest +} + +func itoa64(value int64) string { + return strconv.FormatInt(value, 10) +} + +func assertBodyContains(t *testing.T, body, fragment string) { + t.Helper() + + if !strings.Contains(body, fragment) { + t.Fatalf("expected response body to contain %q", fragment) + } +} + +func assertHeaderContains(t *testing.T, recorder *httptest.ResponseRecorder, key, fragment string) { + t.Helper() + + if value := recorder.Header().Get(key); !strings.Contains(value, fragment) { + t.Fatalf("expected %s header to contain %q, got %q", key, fragment, value) + } +} + +func assertHeaderEquals(t *testing.T, recorder *httptest.ResponseRecorder, key, want string) { + t.Helper() + + if value := recorder.Header().Get(key); value != want { + t.Fatalf("expected %s header %q, got %q", key, want, value) + } +} diff --git a/internal/http/auth_handlers.go b/internal/http/auth_handlers.go new file mode 100644 index 0000000..80c3124 --- /dev/null +++ b/internal/http/auth_handlers.go @@ -0,0 +1,154 @@ +package httpserver + +import ( + "errors" + "net" + "net/http" + "net/url" + "strings" + + authservice "update_server/internal/auth" +) + +const defaultAdminRedirect = "/admin" + +func (h *handler) adminLoginForm(w http.ResponseWriter, r *http.Request) { + if redirected, err := h.redirectAuthenticatedAdmin(w, r); err != nil { + http.Error(w, "session lookup failed", http.StatusInternalServerError) + return + } else if redirected { + return + } + + h.renderLoginPage(w, r, http.StatusOK, "", safeNextPath(r.URL.Query().Get("next")), "") +} + +func (h *handler) adminLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid login form", http.StatusBadRequest) + return + } + + nextPath := safeNextPath(r.FormValue("next")) + email := strings.TrimSpace(r.FormValue("email")) + password := r.FormValue("password") + + token, sessionState, err := h.auth.Authenticate(r.Context(), email, password, clientIP(r), r.UserAgent()) + if err != nil { + if errors.Is(err, authservice.ErrInvalidCredentials) { + h.renderLoginPage(w, r, http.StatusUnauthorized, email, nextPath, "Invalid email or password.") + return + } + + http.Error(w, "login failed", http.StatusInternalServerError) + return + } + + http.SetCookie(w, h.auth.SessionCookie(token, sessionState.Session.ExpiresAt)) + if _, err := h.issueCSRFCookie(w); err != nil { + http.Error(w, "login failed", http.StatusInternalServerError) + return + } + http.Redirect(w, r, nextPath, http.StatusSeeOther) +} + +func (h *handler) adminLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(h.auth.SessionCookieName()); err == nil { + if err := h.auth.InvalidateSession(r.Context(), cookie.Value); err != nil { + http.Error(w, "logout failed", http.StatusInternalServerError) + return + } + } + + http.SetCookie(w, h.auth.ClearSessionCookie()) + http.SetCookie(w, h.clearCSRFCookie()) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +func (h *handler) renderLoginPage(w http.ResponseWriter, r *http.Request, status int, email, nextPath, errorMessage string) { + hasActiveAdmin, err := h.store.Users.HasActiveAdmin(r.Context()) + if err != nil { + http.Error(w, "admin status lookup failed", http.StatusInternalServerError) + return + } + + setupHint := "" + if !hasActiveAdmin { + setupHint = "No active admin user exists yet. Set ADMIN_EMAIL and ADMIN_PASSWORD, then restart the server once to bootstrap the first admin account." + } + + loginData := LoginPageData{ + PageData: h.basePageData( + r, + "Admin Login", + "Secure Sign-In", + "Admin Login", + "Use your admin credentials to open the protected server-rendered dashboard.", + ), + Login: LoginFormData{ + Action: "/admin/login", + Email: email, + Next: nextPath, + Error: errorMessage, + SetupHint: setupHint, + }, + } + + renderPageStatus(w, h.renderer, "login", status, loginData) +} + +func (h *handler) redirectAuthenticatedAdmin(w http.ResponseWriter, r *http.Request) (bool, error) { + cookie, err := r.Cookie(h.auth.SessionCookieName()) + if err != nil { + return false, nil + } + + sessionState, err := h.auth.LoadSession(r.Context(), cookie.Value) + if err != nil { + if errors.Is(err, authservice.ErrUnauthenticated) { + http.SetCookie(w, h.auth.ClearSessionCookie()) + return false, nil + } + + return false, err + } + + http.Redirect(w, r, safeNextPath(r.URL.Query().Get("next")), http.StatusSeeOther) + _ = sessionState + return true, nil +} + +func safeNextPath(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return defaultAdminRedirect + } + + if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") { + return defaultAdminRedirect + } + + return raw +} + +func loginRedirectPath(nextPath string) string { + values := url.Values{} + if nextPath = safeNextPath(nextPath); nextPath != defaultAdminRedirect { + values.Set("next", nextPath) + } + + if encoded := values.Encode(); encoded != "" { + return "/admin/login?" + encoded + } + + return "/admin/login" +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + + return r.RemoteAddr +} diff --git a/internal/http/auth_integration_test.go b/internal/http/auth_integration_test.go new file mode 100644 index 0000000..69ee138 --- /dev/null +++ b/internal/http/auth_integration_test.go @@ -0,0 +1,269 @@ +package httpserver_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "update_server/internal/apikeys" + "update_server/internal/auth" + "update_server/internal/config" + "update_server/internal/db" + httpserver "update_server/internal/http" + "update_server/internal/releases" + "update_server/internal/storage" +) + +const testCSRFCookieName = "update_server_csrf" + +func TestAdminLoginLogoutFlowProtectsRoutes(t *testing.T) { + t.Parallel() + + router, cfg := newTestRouter(t) + recorder := performRequest(t, router, http.MethodGet, "/admin", nil) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("expected redirect for unauthenticated admin route, got %d", recorder.Code) + } + + if location := recorder.Header().Get("Location"); location != "/admin/login" { + t.Fatalf("expected login redirect, got %q", location) + } + + loginForm := url.Values{ + "email": {"admin@example.com"}, + "password": {"correct horse battery staple"}, + "next": {"/admin"}, + } + + csrfCookie := ensureCSRFCookie(t, router) + loginForm.Set("csrf_token", csrfCookie.Value) + recorder = performRequest(t, router, http.MethodPost, "/admin/login", loginForm, csrfCookie) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("expected login redirect, got %d", recorder.Code) + } + + if location := recorder.Header().Get("Location"); location != "/admin" { + t.Fatalf("expected admin redirect after login, got %q", location) + } + + adminCookies := recorder.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range adminCookies { + if cookie.Name == cfg.SessionCookieName { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil || sessionCookie.Value == "" { + t.Fatal("expected session cookie after successful login") + } + + recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie) + if recorder.Code != http.StatusOK { + t.Fatalf("expected authenticated admin dashboard, got %d", recorder.Code) + } + + if !strings.Contains(recorder.Body.String(), "Admin Dashboard") { + t.Fatal("expected admin dashboard content in response body") + } + + if !strings.Contains(recorder.Body.String(), "admin@example.com") { + t.Fatal("expected authenticated admin email in dashboard response") + } + + logoutCSRFCookie := ensureCSRFCookie(t, router, sessionCookie) + recorder = performRequest(t, router, http.MethodPost, "/admin/logout", url.Values{ + "csrf_token": {logoutCSRFCookie.Value}, + }, sessionCookie, logoutCSRFCookie) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("expected logout redirect, got %d", recorder.Code) + } + + if location := recorder.Header().Get("Location"); location != "/admin/login" { + t.Fatalf("expected login redirect after logout, got %q", location) + } + + recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("expected invalidated session cookie to be rejected, got %d", recorder.Code) + } + + if location := recorder.Header().Get("Location"); location != "/admin/login" { + t.Fatalf("expected invalidated session redirect, got %q", location) + } +} + +func TestAdminLoginRejectsInvalidCredentials(t *testing.T) { + t.Parallel() + + router, _ := newTestRouter(t) + csrfCookie := ensureCSRFCookie(t, router) + recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{ + "email": {"admin@example.com"}, + "password": {"definitely-wrong"}, + "next": {"/admin"}, + "csrf_token": {csrfCookie.Value}, + }, csrfCookie) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized login response, got %d", recorder.Code) + } + + if !strings.Contains(recorder.Body.String(), "Invalid email or password.") { + t.Fatal("expected invalid login message in response body") + } +} + +func newTestRouter(t *testing.T) (http.Handler, config.Config) { + t.Helper() + + router, cfg, _ := newTestRouterWithStore(t) + return router, cfg +} + +func newTestRouterWithStore(t *testing.T) (http.Handler, config.Config, *db.Store) { + return newTestRouterWithConfig(t, nil) +} + +func newTestRouterWithConfig(t *testing.T, mutate func(*config.Config)) (http.Handler, config.Config, *db.Store) { + t.Helper() + + store := newHTTPTestStore(t) + t.Cleanup(func() { + _ = store.Close() + }) + + artifactsDir := filepath.Join(t.TempDir(), "artifacts") + cfg := config.Config{ + AppName: "Update Server", + BaseURL: "http://127.0.0.1:8080", + ArtifactsDir: artifactsDir, + TemplatesDir: httpProjectPath(t, "web", "templates"), + StaticDir: httpProjectPath(t, "web", "static"), + AdminEmail: "admin@example.com", + AdminPassword: "correct horse battery staple", + MaxUploadBytes: 8 << 20, + SessionCookieName: "update_server_session", + CSRFCookieName: testCSRFCookieName, + SessionTTL: 24 * time.Hour, + ReadTimeout: 30 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + ShutdownTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + LoginRateLimitPerMinute: 10, + LoginRateLimitBurst: 5, + ClientRateLimitPerMinute: 120, + ClientRateLimitBurst: 60, + } + if mutate != nil { + mutate(&cfg) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + renderer, err := httpserver.NewRenderer(cfg.TemplatesDir) + if err != nil { + t.Fatalf("create renderer: %v", err) + } + + authService := auth.NewService(cfg, logger, store) + apiKeyService := apikeys.NewService(store) + if err := authService.EnsureBootstrapAdmin(context.Background()); err != nil { + t.Fatalf("bootstrap admin: %v", err) + } + + artifactStore, err := storage.NewLocal(cfg.ArtifactsDir) + if err != nil { + t.Fatalf("create artifact storage: %v", err) + } + + releaseService := releases.NewService(store, artifactStore) + return httpserver.NewRouter(cfg, logger, renderer, store, authService, apiKeyService, releaseService), cfg, store +} + +func newHTTPTestStore(t *testing.T) *db.Store { + t.Helper() + + sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite") + database, err := db.Open(context.Background(), sqlitePath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.Migrate(context.Background(), database, httpProjectPath(t, "migrations")); err != nil { + _ = database.Close() + t.Fatalf("migrate sqlite: %v", err) + } + + return db.NewStore(database) +} + +func httpProjectPath(t *testing.T, parts ...string) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve caller path") + } + + root := filepath.Join(filepath.Dir(filename), "..", "..") + items := append([]string{root}, parts...) + return filepath.Join(items...) +} + +func performRequest(t *testing.T, handler http.Handler, method, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { + t.Helper() + + var body io.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } + + req := httptest.NewRequest(method, target, body) + req.RemoteAddr = "127.0.0.1:12345" + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + for _, cookie := range cookies { + req.AddCookie(cookie) + } + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder +} + +func ensureCSRFCookie(t *testing.T, handler http.Handler, cookies ...*http.Cookie) *http.Cookie { + t.Helper() + + for _, cookie := range cookies { + if cookie != nil && cookie.Name == testCSRFCookieName && cookie.Value != "" { + return cookie + } + } + + recorder := performRequest(t, handler, http.MethodGet, "/admin/login", nil, cookies...) + if recorder.Code != http.StatusOK && recorder.Code != http.StatusSeeOther { + t.Fatalf("expected csrf bootstrap request to succeed, got %d with body %s", recorder.Code, recorder.Body.String()) + } + + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == testCSRFCookieName && cookie.Value != "" { + return cookie + } + } + + t.Fatal("expected csrf cookie from admin bootstrap request") + return nil +} diff --git a/internal/http/auth_middleware.go b/internal/http/auth_middleware.go new file mode 100644 index 0000000..aa47c35 --- /dev/null +++ b/internal/http/auth_middleware.go @@ -0,0 +1,52 @@ +package httpserver + +import ( + "errors" + "net/http" + + authservice "update_server/internal/auth" + "update_server/internal/db" +) + +func (h *handler) requireAuthenticatedSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(h.auth.SessionCookieName()) + if err != nil { + http.Redirect(w, r, loginRedirectPath(r.URL.RequestURI()), http.StatusSeeOther) + return + } + + sessionState, err := h.auth.LoadSession(r.Context(), cookie.Value) + if err != nil { + if errors.Is(err, authservice.ErrUnauthenticated) { + http.SetCookie(w, h.auth.ClearSessionCookie()) + http.Redirect(w, r, loginRedirectPath(r.URL.RequestURI()), http.StatusSeeOther) + return + } + + http.Error(w, "session lookup failed", http.StatusInternalServerError) + return + } + + next.ServeHTTP(w, r.WithContext(authservice.NewContext(r.Context(), sessionState))) + }) +} + +func (h *handler) requireRole(requiredRole db.UserRole) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionState, ok := authservice.FromContext(r.Context()) + if !ok { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + + if !authservice.RoleAllowed(sessionState.User.Role, requiredRole) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/client_api.go b/internal/http/client_api.go new file mode 100644 index 0000000..c30e279 --- /dev/null +++ b/internal/http/client_api.go @@ -0,0 +1,247 @@ +package httpserver + +import ( + "errors" + "fmt" + "mime" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "update_server/internal/apikeys" + "update_server/internal/db" +) + +var errInvalidReleaseID = errors.New("invalid release id") + +type apiProjectResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + LatestReleaseURL string `json:"latest_release_url"` +} + +type apiReleaseResponse struct { + ID int64 `json:"id"` + Version string `json:"version"` + Build string `json:"build"` + Filename string `json:"filename"` + ChecksumSHA256 string `json:"checksum_sha256"` + SizeBytes int64 `json:"size_bytes"` + ContentType string `json:"content_type"` + ReleaseNotes string `json:"release_notes"` + CreatedAt time.Time `json:"created_at"` + MetadataURL string `json:"metadata_url"` + DownloadURL string `json:"download_url"` +} + +func (h *handler) apiAccessibleProjects(w http.ResponseWriter, r *http.Request) { + state, ok := apikeys.FromContext(r.Context()) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required") + return + } + + projects, err := h.apiKeys.ListAccessibleProjects(r.Context(), state.APIKey) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "project listing failed"}) + return + } + + items := make([]apiProjectResponse, 0, len(projects)) + for _, project := range projects { + items = append(items, apiProjectPayload(project)) + } + + writeJSON(w, http.StatusOK, map[string]any{"projects": items}) +} + +func (h *handler) apiLatestRelease(w http.ResponseWriter, r *http.Request) { + state, ok := apikeys.FromContext(r.Context()) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required") + return + } + + project, err := h.accessibleProjectBySlug(r, state.APIKey) + if err != nil { + h.writeClientResourceError(w, err, "project lookup failed") + return + } + + release, err := h.store.Releases.GetLatestByProjectID(r.Context(), project.ID) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"}) + return + } + + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "release lookup failed"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "project": apiProjectPayload(*project), + "release": apiReleasePayload(*release), + }) +} + +func (h *handler) apiReleaseMetadata(w http.ResponseWriter, r *http.Request) { + state, ok := apikeys.FromContext(r.Context()) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required") + return + } + + project, release, err := h.accessibleReleaseByID(r, state.APIKey) + if err != nil { + h.writeClientResourceError(w, err, "release lookup failed") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "project": apiProjectPayload(*project), + "release": apiReleasePayload(*release), + }) +} + +func (h *handler) apiReleaseDownload(w http.ResponseWriter, r *http.Request) { + state, ok := apikeys.FromContext(r.Context()) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required") + return + } + + _, release, err := h.accessibleReleaseByID(r, state.APIKey) + if err != nil { + h.writeClientResourceError(w, err, "release lookup failed") + return + } + + file, err := h.releases.Artifact(release.StoragePath) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"}) + return + } + + h.logger.Error("artifact open failed", "release_id", release.ID, "storage_path", release.StoragePath, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "artifact download failed"}) + return + } + defer file.Close() + + disposition := mime.FormatMediaType("attachment", map[string]string{"filename": release.Filename}) + if disposition != "" { + w.Header().Set("Content-Disposition", disposition) + } + w.Header().Set("Content-Type", release.ContentType) + w.Header().Set("X-Content-Type-Options", "nosniff") + + http.ServeContent(w, r, release.Filename, release.UpdatedAt, file) +} + +func (h *handler) accessibleProjectBySlug(r *http.Request, apiKey db.APIKey) (*db.Project, error) { + projectSlug := strings.TrimSpace(chi.URLParam(r, "projectSlug")) + if projectSlug == "" { + return nil, db.ErrNotFound + } + + project, err := h.store.Projects.GetBySlug(r.Context(), projectSlug) + if err != nil { + return nil, err + } + + if !project.IsActive { + return nil, db.ErrNotFound + } + + allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID) + if err != nil { + return nil, fmt.Errorf("check project access: %w", err) + } + + if !allowed { + return nil, db.ErrNotFound + } + + return project, nil +} + +func (h *handler) accessibleReleaseByID(r *http.Request, apiKey db.APIKey) (*db.Project, *db.Release, error) { + releaseID, err := routeID(r, "releaseID") + if err != nil { + return nil, nil, errInvalidReleaseID + } + + release, err := h.store.Releases.GetByID(r.Context(), releaseID) + if err != nil { + return nil, nil, err + } + + if !release.IsActive { + return nil, nil, db.ErrNotFound + } + + project, err := h.store.Projects.GetByID(r.Context(), release.ProjectID) + if err != nil { + return nil, nil, err + } + + if !project.IsActive { + return nil, nil, db.ErrNotFound + } + + allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID) + if err != nil { + return nil, nil, fmt.Errorf("check release project access: %w", err) + } + + if !allowed { + return nil, nil, db.ErrNotFound + } + + return project, release, nil +} + +func (h *handler) writeClientResourceError(w http.ResponseWriter, err error, message string) { + switch { + case err == nil: + return + case errors.Is(err, db.ErrNotFound): + writeJSON(w, http.StatusNotFound, map[string]any{"error": "resource not found"}) + case errors.Is(err, errInvalidReleaseID): + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid release id"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": message}) + } +} + +func apiProjectPayload(project db.Project) apiProjectResponse { + return apiProjectResponse{ + ID: project.ID, + Name: project.Name, + Slug: project.Slug, + Description: project.Description, + LatestReleaseURL: fmt.Sprintf("/api/v1/projects/%s/releases/latest", project.Slug), + } +} + +func apiReleasePayload(release db.Release) apiReleaseResponse { + return apiReleaseResponse{ + ID: release.ID, + Version: release.Version, + Build: release.Build, + Filename: release.Filename, + ChecksumSHA256: release.ChecksumSHA256, + SizeBytes: release.SizeBytes, + ContentType: release.ContentType, + ReleaseNotes: release.ReleaseNotes, + CreatedAt: release.CreatedAt, + MetadataURL: fmt.Sprintf("/api/v1/releases/%d", release.ID), + DownloadURL: fmt.Sprintf("/api/v1/releases/%d/download", release.ID), + } +} diff --git a/internal/http/client_api_integration_test.go b/internal/http/client_api_integration_test.go new file mode 100644 index 0000000..f05f172 --- /dev/null +++ b/internal/http/client_api_integration_test.go @@ -0,0 +1,290 @@ +package httpserver_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "update_server/internal/apikeys" + "update_server/internal/db" +) + +func TestClientAPIListsAuthorizedProjectsReturnsLatestMetadataAndStreamsDownloads(t *testing.T) { + t.Parallel() + + router, _, store := newTestRouterWithStore(t) + sessionCookie := loginAsAdmin(t, router) + + desktopProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + "description": {"Primary desktop updater stream."}, + }, http.StatusSeeOther, sessionCookie), "/admin/projects/") + + mobileProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Mobile App"}, + "slug": {"mobile-app"}, + }, http.StatusSeeOther, sessionCookie), "/admin/projects/") + + legacyProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Legacy App"}, + "slug": {"legacy-app"}, + }, http.StatusSeeOther, sessionCookie), "/admin/projects/") + + tagID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{ + "name": {"Windows"}, + "slug": {"windows"}, + }, http.StatusSeeOther, sessionCookie), "/admin/tags/") + + submitForm(t, router, http.MethodPost, "/admin/projects/"+desktopProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie) + submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie) + + submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{ + "version": "1.0.0", + "build": "build-1", + "release_notes": "Initial desktop rollout.", + }, "artifact", "Desktop-App-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie) + + submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{ + "version": "1.1.0", + "build": "build-2", + "release_notes": "Improved desktop rollout.", + }, "artifact", "Desktop-App-1.1.0.zip", []byte("desktop-release-1.1.0"), sessionCookie) + + submitMultipartForm(t, router, "/admin/projects/"+mobileProjectID+"/releases", map[string]string{ + "version": "2.0.0", + "build": "mobile-1", + "release_notes": "Mobile rollout.", + }, "artifact", "mobile-app-2.0.0.apk", []byte("mobile-release-2.0.0"), sessionCookie) + + submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/archive", url.Values{ + "state": {"archive"}, + }, http.StatusSeeOther, sessionCookie) + + keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{ + Name: "Windows Clients", + ScopeMode: db.ScopeModeTagAllowList, + CanDownload: true, + TagIDs: []int64{mustParseInt64(t, tagID)}, + }) + if err != nil { + t.Fatalf("create client api key: %v", err) + } + + projectsRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) + if projectsRecorder.Code != http.StatusOK { + t.Fatalf("expected project list to return 200, got %d with body %s", projectsRecorder.Code, projectsRecorder.Body.String()) + } + + var projectsPayload struct { + Projects []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + LatestReleaseURL string `json:"latest_release_url"` + } `json:"projects"` + } + decodeJSONBody(t, projectsRecorder, &projectsPayload) + + if len(projectsPayload.Projects) != 1 { + t.Fatalf("expected exactly one accessible active project, got %+v", projectsPayload.Projects) + } + + if projectsPayload.Projects[0].Slug != "desktop-app" { + t.Fatalf("expected desktop-app in accessible projects, got %+v", projectsPayload.Projects[0]) + } + + if projectsPayload.Projects[0].LatestReleaseURL != "/api/v1/projects/desktop-app/releases/latest" { + t.Fatalf("unexpected latest release url %q", projectsPayload.Projects[0].LatestReleaseURL) + } + + latestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/desktop-app/releases/latest", "Bearer "+keyResult.RawKey) + if latestRecorder.Code != http.StatusOK { + t.Fatalf("expected latest release metadata to return 200, got %d with body %s", latestRecorder.Code, latestRecorder.Body.String()) + } + + var latestPayload struct { + Project struct { + Slug string `json:"slug"` + } `json:"project"` + Release struct { + ID int64 `json:"id"` + Version string `json:"version"` + Build string `json:"build"` + MetadataURL string `json:"metadata_url"` + DownloadURL string `json:"download_url"` + CreatedAt time.Time `json:"created_at"` + } `json:"release"` + } + decodeJSONBody(t, latestRecorder, &latestPayload) + + if latestPayload.Project.Slug != "desktop-app" { + t.Fatalf("expected desktop-app project payload, got %+v", latestPayload.Project) + } + + if latestPayload.Release.Version != "1.1.0" || latestPayload.Release.Build != "build-2" { + t.Fatalf("expected latest release 1.1.0/build-2, got %+v", latestPayload.Release) + } + + if latestPayload.Release.MetadataURL != "/api/v1/releases/2" { + t.Fatalf("unexpected metadata url %q", latestPayload.Release.MetadataURL) + } + + if latestPayload.Release.DownloadURL != "/api/v1/releases/2/download" { + t.Fatalf("unexpected download url %q", latestPayload.Release.DownloadURL) + } + + metadataRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.MetadataURL, "Bearer "+keyResult.RawKey) + if metadataRecorder.Code != http.StatusOK { + t.Fatalf("expected release metadata to return 200, got %d with body %s", metadataRecorder.Code, metadataRecorder.Body.String()) + } + + var metadataPayload struct { + Project struct { + Slug string `json:"slug"` + } `json:"project"` + Release struct { + ID int64 `json:"id"` + Version string `json:"version"` + Filename string `json:"filename"` + } `json:"release"` + } + decodeJSONBody(t, metadataRecorder, &metadataPayload) + + if metadataPayload.Release.ID != latestPayload.Release.ID || metadataPayload.Release.Version != "1.1.0" { + t.Fatalf("expected matching release metadata payload, got %+v", metadataPayload.Release) + } + + blockedLatestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/mobile-app/releases/latest", "Bearer "+keyResult.RawKey) + if blockedLatestRecorder.Code != http.StatusNotFound { + t.Fatalf("expected blocked project latest lookup to return 404, got %d with body %s", blockedLatestRecorder.Code, blockedLatestRecorder.Body.String()) + } + + blockedMetadataRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3", "Bearer "+keyResult.RawKey) + if blockedMetadataRecorder.Code != http.StatusNotFound { + t.Fatalf("expected blocked release metadata to return 404, got %d with body %s", blockedMetadataRecorder.Code, blockedMetadataRecorder.Body.String()) + } + + downloadRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.DownloadURL, "Bearer "+keyResult.RawKey) + if downloadRecorder.Code != http.StatusOK { + t.Fatalf("expected release download to return 200, got %d with body %s", downloadRecorder.Code, downloadRecorder.Body.String()) + } + + if body := downloadRecorder.Body.String(); body != "desktop-release-1.1.0" { + t.Fatalf("unexpected download body %q", body) + } + + if value := downloadRecorder.Header().Get("Content-Disposition"); !strings.Contains(value, "attachment") || !strings.Contains(value, "desktop-app-1.1.0.zip") { + t.Fatalf("expected attachment content disposition, got %q", value) + } + + if value := downloadRecorder.Header().Get("Content-Type"); !strings.Contains(value, "text/plain") { + t.Fatalf("expected sniffed text content type, got %q", value) + } + + blockedDownloadRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3/download", "Bearer "+keyResult.RawKey) + if blockedDownloadRecorder.Code != http.StatusNotFound { + t.Fatalf("expected blocked release download to return 404, got %d with body %s", blockedDownloadRecorder.Code, blockedDownloadRecorder.Body.String()) + } +} + +func TestClientAPIRejectsMissingPermissionDisabledAndExpiredKeys(t *testing.T) { + t.Parallel() + + router, _, store := newTestRouterWithStore(t) + sessionCookie := loginAsAdmin(t, router) + + projectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + }, http.StatusSeeOther, sessionCookie), "/admin/projects/") + + submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{ + "version": "1.0.0", + "build": "build-1", + "release_notes": "Initial rollout.", + }, "artifact", "desktop-app-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie) + + service := apikeys.NewService(store) + + noPermissionKey, err := service.Create(t.Context(), apikeys.CreateParams{ + Name: "No Download", + ScopeMode: db.ScopeModeAllProjects, + CanUpload: true, + }) + if err != nil { + t.Fatalf("create no-permission key: %v", err) + } + + disabledKey, err := service.Create(t.Context(), apikeys.CreateParams{ + Name: "Disabled", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + }) + if err != nil { + t.Fatalf("create disabled key: %v", err) + } + if err := service.SetActive(t.Context(), disabledKey.APIKey.ID, false); err != nil { + t.Fatalf("disable api key: %v", err) + } + + expiredAt := time.Now().UTC().Add(-time.Hour) + expiredKey, err := service.Create(t.Context(), apikeys.CreateParams{ + Name: "Expired", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + ExpiresAt: &expiredAt, + }) + if err != nil { + t.Fatalf("create expired key: %v", err) + } + + missingAuthRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "") + if missingAuthRecorder.Code != http.StatusUnauthorized { + t.Fatalf("expected missing auth to return 401, got %d", missingAuthRecorder.Code) + } + assertHeaderContains(t, missingAuthRecorder, "WWW-Authenticate", "Bearer") + + noPermissionRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+noPermissionKey.RawKey) + if noPermissionRecorder.Code != http.StatusForbidden { + t.Fatalf("expected missing permission to return 403, got %d with body %s", noPermissionRecorder.Code, noPermissionRecorder.Body.String()) + } + + disabledRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+disabledKey.RawKey) + if disabledRecorder.Code != http.StatusUnauthorized { + t.Fatalf("expected disabled key to return 401, got %d with body %s", disabledRecorder.Code, disabledRecorder.Body.String()) + } + assertHeaderContains(t, disabledRecorder, "WWW-Authenticate", "Bearer") + + expiredRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+expiredKey.RawKey) + if expiredRecorder.Code != http.StatusUnauthorized { + t.Fatalf("expected expired key to return 401, got %d with body %s", expiredRecorder.Code, expiredRecorder.Body.String()) + } + assertHeaderContains(t, expiredRecorder, "WWW-Authenticate", "Bearer") +} + +func performAPIRequest(t *testing.T, handler http.Handler, method, target, authorization string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(method, target, nil) + req.RemoteAddr = "127.0.0.1:12345" + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder +} + +func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder, target any) { + t.Helper() + + if err := json.Unmarshal(recorder.Body.Bytes(), target); err != nil { + t.Fatalf("decode json response: %v", err) + } +} diff --git a/internal/http/csrf.go b/internal/http/csrf.go new file mode 100644 index 0000000..7e6b2cc --- /dev/null +++ b/internal/http/csrf.go @@ -0,0 +1,156 @@ +package httpserver + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + adminCookiePath = "/admin" + csrfFormField = "csrf_token" + csrfHeaderName = "X-CSRF-Token" +) + +type csrfTokenContextKey struct{} + +func (h *handler) adminCSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, cookie, err := h.ensureCSRFCookie(r) + if err != nil { + http.Error(w, "csrf setup failed", http.StatusInternalServerError) + return + } + + if cookie != nil { + http.SetCookie(w, cookie) + } + + r = r.WithContext(context.WithValue(r.Context(), csrfTokenContextKey{}, token)) + + if requiresCSRFProtection(r.Method) { + submittedToken, err := h.submittedCSRFToken(w, r) + if err != nil || subtle.ConstantTimeCompare([]byte(token), []byte(submittedToken)) != 1 { + http.Error(w, "csrf validation failed", http.StatusForbidden) + return + } + } + + next.ServeHTTP(w, r) + }) +} + +func (h *handler) csrfToken(r *http.Request) string { + if token, ok := r.Context().Value(csrfTokenContextKey{}).(string); ok { + return token + } + + cookie, err := r.Cookie(h.config.CSRFCookieName) + if err != nil { + return "" + } + + if !validCSRFCookieToken(cookie.Value) { + return "" + } + + return cookie.Value +} + +func (h *handler) issueCSRFCookie(w http.ResponseWriter) (string, error) { + token, err := generateCSRFToken() + if err != nil { + return "", err + } + + http.SetCookie(w, h.csrfCookie(token)) + return token, nil +} + +func (h *handler) clearCSRFCookie() *http.Cookie { + return &http.Cookie{ + Name: h.config.CSRFCookieName, + Value: "", + Path: adminCookiePath, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: h.config.SecureCookies, + Expires: time.Unix(0, 0).UTC(), + MaxAge: -1, + } +} + +func (h *handler) ensureCSRFCookie(r *http.Request) (string, *http.Cookie, error) { + cookie, err := r.Cookie(h.config.CSRFCookieName) + if err == nil && validCSRFCookieToken(cookie.Value) { + return cookie.Value, nil, nil + } + + token, err := generateCSRFToken() + if err != nil { + return "", nil, fmt.Errorf("generate csrf token: %w", err) + } + + return token, h.csrfCookie(token), nil +} + +func (h *handler) csrfCookie(token string) *http.Cookie { + return &http.Cookie{ + Name: h.config.CSRFCookieName, + Value: token, + Path: adminCookiePath, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: h.config.SecureCookies, + } +} + +func (h *handler) submittedCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) { + if token := strings.TrimSpace(r.Header.Get(csrfHeaderName)); token != "" { + return token, nil + } + + contentType := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) + if strings.HasPrefix(contentType, "multipart/form-data") { + r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes)) + if err := r.ParseMultipartForm(16 << 20); err != nil { + return "", err + } + + return strings.TrimSpace(r.FormValue(csrfFormField)), nil + } + + if err := r.ParseForm(); err != nil { + return "", err + } + + return strings.TrimSpace(r.FormValue(csrfFormField)), nil +} + +func generateCSRFToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func requiresCSRFProtection(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + return false + default: + return true + } +} + +func validCSRFCookieToken(token string) bool { + token = strings.TrimSpace(token) + return len(token) >= 32 +} diff --git a/internal/http/handlers.go b/internal/http/handlers.go new file mode 100644 index 0000000..8fa7b1b --- /dev/null +++ b/internal/http/handlers.go @@ -0,0 +1,149 @@ +package httpserver + +import ( + "net/http" + "strconv" + "time" +) + +func (h *handler) health(w http.ResponseWriter, r *http.Request) { + payload := map[string]any{ + "status": "ok", + "database": "ok", + "service": h.config.AppName, + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + + if h.store == nil { + payload["status"] = "degraded" + payload["database"] = "unavailable" + payload["error"] = "database store is not initialized" + writeJSON(w, http.StatusServiceUnavailable, payload) + return + } + + if err := h.store.HealthCheck(r.Context()); err != nil { + payload["status"] = "degraded" + payload["database"] = "unavailable" + payload["error"] = "database ping failed" + writeJSON(w, http.StatusServiceUnavailable, payload) + return + } + + writeJSON(w, http.StatusOK, payload) +} + +func (h *handler) home(w http.ResponseWriter, r *http.Request) { + data := HomePageData{ + PageData: PageData{ + Title: "Client Update API Ready", + Eyebrow: "Usable Product Flow", + Heading: h.config.AppName, + BaseURL: h.config.BaseURL, + Description: "Protected admin flows now connect all the way through to bearer-authenticated client endpoints for project discovery, latest-release metadata, release metadata, and private artifact downloads.", + }, + Links: []PageLink{ + {Label: "Health check", Href: "/healthz", Description: "JSON readiness endpoint covering both the HTTP server and SQLite connectivity."}, + {Label: "Admin login", Href: "/admin/login", Description: "Server-rendered login form for the protected admin workspace."}, + {Label: "Projects", Href: "/admin/projects", Description: "Create projects, attach tags, upload releases, and inspect the client-facing metadata paths from the protected admin UI."}, + {Label: "Tags", Href: "/admin/tags", Description: "Manage reusable tags that projects can use from day one."}, + {Label: "API keys", Href: "/admin/api-keys", Description: "Generate hashed API keys, reveal the raw key once, preview accessible projects, and copy the client API quick-start flow."}, + {Label: "Client API", Href: "/api/v1", Description: "Versioned JSON API entrypoint documenting the bearer-authenticated project, metadata, and download endpoints."}, + }, + } + + renderPage(w, h.renderer, "home", data) +} + +func (h *handler) adminHome(w http.ResponseWriter, r *http.Request) { + projects, err := h.store.Projects.List(r.Context()) + if err != nil { + http.Error(w, "project summary lookup failed", http.StatusInternalServerError) + return + } + + tags, err := h.store.Tags.List(r.Context()) + if err != nil { + http.Error(w, "tag summary lookup failed", http.StatusInternalServerError) + return + } + + apiKeys, err := h.store.APIKeys.List(r.Context()) + if err != nil { + http.Error(w, "api key summary lookup failed", http.StatusInternalServerError) + return + } + + totalReleases := 0 + for _, item := range projects { + totalReleases += item.ReleaseCount + } + + data := DashboardPageData{ + PageData: h.basePageData( + r, + "Admin Dashboard", + "Protected Admin", + "Admin Dashboard", + "Projects, tags, releases, and API keys now feed the client update API so admins can complete the end-to-end product flow in one workspace.", + ), + Metrics: []DashboardMetric{ + {Label: "Projects", Value: strconv.Itoa(len(projects)), Description: "Active and archived projects available for management."}, + {Label: "Tags", Value: strconv.Itoa(len(tags)), Description: "Reusable labels ready for project assignment and future access scopes."}, + {Label: "Releases", Value: strconv.Itoa(totalReleases), Description: "Stored release records with checksum and disk path metadata."}, + {Label: "API Keys", Value: strconv.Itoa(len(apiKeys)), Description: "Hashed client credentials with permission flags and scope rules."}, + }, + Links: []PageLink{ + {Label: "Manage projects", Href: "/admin/projects", Description: "Open project detail pages to edit metadata, attach tags, upload releases, and inspect client metadata or download paths."}, + {Label: "Manage tags", Href: "/admin/tags", Description: "Create tags once and reuse them across projects from the same protected UI."}, + {Label: "Manage API keys", Href: "/admin/api-keys", Description: "Generate, revoke, and scope client credentials, then copy the bearer-authenticated client API flow."}, + {Label: "Client API", Href: "/api/v1", Description: "Public JSON index for accessible projects, latest release metadata, release metadata, and authenticated downloads."}, + }, + } + + renderPage(w, h.renderer, "admin", data) +} + +func (h *handler) apiIndex(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "service": h.config.AppName, + "version": "v1", + "status": "client-api-ready", + "auth": map[string]any{ + "type": "bearer", + "header": "Authorization: Bearer ", + }, + "routes": []map[string]string{ + { + "method": http.MethodGet, + "path": "/api/v1/projects", + "description": "List active projects accessible to the API key.", + }, + { + "method": http.MethodGet, + "path": "/api/v1/projects/{projectSlug}/releases/latest", + "description": "Get the latest active release metadata for an accessible project.", + }, + { + "method": http.MethodGet, + "path": "/api/v1/releases/{releaseID}", + "description": "Get release metadata for an accessible release.", + }, + { + "method": http.MethodGet, + "path": "/api/v1/releases/{releaseID}/download", + "description": "Download the private artifact for an accessible release.", + }, + }, + }) +} + +func renderPage(w http.ResponseWriter, renderer *Renderer, name string, data any) { + renderPageStatus(w, renderer, name, http.StatusOK, data) +} + +func renderPageStatus(w http.ResponseWriter, renderer *Renderer, name string, status int, data any) { + if err := renderer.Render(w, name, status, data); err != nil { + http.Error(w, "template rendering failed", http.StatusInternalServerError) + } +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..4240880 --- /dev/null +++ b/internal/http/middleware.go @@ -0,0 +1,29 @@ +package httpserver + +import ( + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startedAt := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + next.ServeHTTP(ww, r) + + logger.Info("request completed", + "request_id", middleware.GetReqID(r.Context()), + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "bytes", ww.BytesWritten(), + "duration", time.Since(startedAt).String(), + ) + }) + } +} diff --git a/internal/http/projects_integration_test.go b/internal/http/projects_integration_test.go new file mode 100644 index 0000000..9ea2b53 --- /dev/null +++ b/internal/http/projects_integration_test.go @@ -0,0 +1,323 @@ +package httpserver_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAdminProjectTagAndReleaseFlow(t *testing.T) { + t.Parallel() + + router, cfg, store := newTestRouterWithStore(t) + + sessionCookie := loginAsAdmin(t, router) + + projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {""}, + "description": {"Primary desktop updater stream."}, + }, http.StatusSeeOther, sessionCookie) + projectID := extractResourceID(t, projectLocation, "/admin/projects/") + + submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID, url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + "description": {"Primary desktop updater stream with release uploads."}, + }, http.StatusSeeOther, sessionCookie) + + tagLocation := submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{ + "name": {"Windows Stable"}, + "slug": {""}, + "description": {"Windows production releases."}, + }, http.StatusSeeOther, sessionCookie) + tagID := extractResourceID(t, tagLocation, "/admin/tags/") + + submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/tags", url.Values{ + "tag_id": {tagID}, + }, http.StatusSeeOther, sessionCookie) + + artifactBody := []byte("release-payload-1.0.0") + uploadLocation := submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{ + "version": "1.0.0", + "build": "build-42", + "release_notes": "Initial Windows stable rollout.", + }, "artifact", "..\\Desktop App 1.0.0.zip", artifactBody, sessionCookie) + + if !strings.Contains(uploadLocation, "status=release-uploaded") { + t.Fatalf("expected release upload redirect status, got %q", uploadLocation) + } + + projectIDInt := mustParseInt64(t, projectID) + project, err := store.Projects.GetByID(context.Background(), projectIDInt) + if err != nil { + t.Fatalf("load project: %v", err) + } + + if project.Slug != "desktop-app" { + t.Fatalf("expected auto-generated project slug to persist, got %q", project.Slug) + } + + if !project.IsActive { + t.Fatal("expected project to remain active until the archive action runs") + } + + projectTags, err := store.Projects.ListTags(context.Background(), project.ID) + if err != nil { + t.Fatalf("load project tags: %v", err) + } + + if len(projectTags) != 1 || projectTags[0].Slug != "windows-stable" { + t.Fatalf("expected one attached tag, got %+v", projectTags) + } + + releases, err := store.Releases.ListByProjectID(context.Background(), project.ID) + if err != nil { + t.Fatalf("load releases: %v", err) + } + + if len(releases) != 1 { + t.Fatalf("expected one release, got %d", len(releases)) + } + + release := releases[0].Release + if release.Filename != "desktop-app-1.0.0.zip" { + t.Fatalf("expected sanitized filename, got %q", release.Filename) + } + + expectedChecksum := sha256.Sum256(artifactBody) + if release.ChecksumSHA256 != hex.EncodeToString(expectedChecksum[:]) { + t.Fatalf("expected checksum %s, got %s", hex.EncodeToString(expectedChecksum[:]), release.ChecksumSHA256) + } + + if release.StoragePath != "desktop-app/1.0.0/build-42/desktop-app-1.0.0.zip" { + t.Fatalf("unexpected storage path %q", release.StoragePath) + } + + artifactPath := filepath.Join(cfg.ArtifactsDir, filepath.FromSlash(release.StoragePath)) + if strings.HasPrefix(artifactPath, cfg.StaticDir) { + t.Fatalf("artifact path %q should not be inside static dir %q", artifactPath, cfg.StaticDir) + } + + storedArtifact, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatalf("read artifact from disk: %v", err) + } + + if !bytes.Equal(storedArtifact, artifactBody) { + t.Fatal("stored artifact body did not match uploaded payload") + } + + recorder := performRequest(t, router, http.MethodGet, "/admin/projects/"+projectID, nil, sessionCookie) + if recorder.Code != http.StatusOK { + t.Fatalf("expected project detail page to load, got %d", recorder.Code) + } + + projectBody := recorder.Body.String() + if !strings.Contains(projectBody, "/api/v1/projects/desktop-app/releases/latest") { + t.Fatal("expected project detail page to show latest release metadata endpoint") + } + + if !strings.Contains(projectBody, "/api/v1/releases/1/download") { + t.Fatal("expected project detail page to show release download endpoint") + } + + submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/archive", url.Values{ + "state": {"archive"}, + }, http.StatusSeeOther, sessionCookie) + + project, err = store.Projects.GetByID(context.Background(), projectIDInt) + if err != nil { + t.Fatalf("reload project after archive: %v", err) + } + + if project.IsActive { + t.Fatal("expected archived project to be inactive") + } + + recorder = performRequest(t, router, http.MethodGet, "/admin/projects", nil, sessionCookie) + if recorder.Code != http.StatusOK { + t.Fatalf("expected projects page to load, got %d", recorder.Code) + } + + if !strings.Contains(recorder.Body.String(), "Desktop App") { + t.Fatal("expected projects page to list the created project") + } + + recorder = performRequest(t, router, http.MethodGet, "/admin/tags", nil, sessionCookie) + if recorder.Code != http.StatusOK { + t.Fatalf("expected tags page to load, got %d", recorder.Code) + } + + if !strings.Contains(recorder.Body.String(), "Windows Stable") { + t.Fatal("expected tags page to list the created tag") + } +} + +func loginAsAdmin(t *testing.T, router http.Handler) *http.Cookie { + t.Helper() + + csrfCookie := ensureCSRFCookie(t, router) + recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{ + "email": {"admin@example.com"}, + "password": {"correct horse battery staple"}, + "next": {"/admin"}, + "csrf_token": {csrfCookie.Value}, + }, csrfCookie) + + if recorder.Code != http.StatusSeeOther { + t.Fatalf("expected login redirect, got %d", recorder.Code) + } + + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == "update_server_session" { + return cookie + } + } + + t.Fatal("expected session cookie after login") + return nil +} + +func submitForm(t *testing.T, router http.Handler, method, target string, form url.Values, expectedStatus int, cookies ...*http.Cookie) string { + t.Helper() + + if strings.HasPrefix(target, "/admin/") { + csrfCookie := ensureCSRFCookie(t, router, cookies...) + cookies = upsertCookie(cookies, csrfCookie) + if form == nil { + form = url.Values{} + } + if form.Get("csrf_token") == "" { + form.Set("csrf_token", csrfCookie.Value) + } + } + + recorder := performRequest(t, router, method, target, form, cookies...) + if recorder.Code != expectedStatus { + t.Fatalf("expected %d for %s %s, got %d with body %s", expectedStatus, method, target, recorder.Code, recorder.Body.String()) + } + + return recorder.Header().Get("Location") +} + +func submitMultipartForm(t *testing.T, router http.Handler, target string, fields map[string]string, fileField, filename string, body []byte, cookies ...*http.Cookie) string { + t.Helper() + + if strings.HasPrefix(target, "/admin/") { + csrfCookie := ensureCSRFCookie(t, router, cookies...) + cookies = upsertCookie(cookies, csrfCookie) + if fields == nil { + fields = map[string]string{} + } + if fields["csrf_token"] == "" { + fields["csrf_token"] = csrfCookie.Value + } + } + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + t.Fatalf("write multipart field %s: %v", key, err) + } + } + + part, err := writer.CreateFormFile(fileField, filename) + if err != nil { + t.Fatalf("create multipart file part: %v", err) + } + + if _, err := part.Write(body); err != nil { + t.Fatalf("write multipart file body: %v", err) + } + + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, target, &requestBody) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("Content-Type", writer.FormDataContentType()) + for _, cookie := range cookies { + req.AddCookie(cookie) + } + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("expected multipart upload redirect, got %d with body %s", recorder.Code, recorder.Body.String()) + } + + return recorder.Header().Get("Location") +} + +func upsertCookie(cookies []*http.Cookie, cookie *http.Cookie) []*http.Cookie { + if cookie == nil { + return cookies + } + + updated := make([]*http.Cookie, 0, len(cookies)+1) + replaced := false + for _, existing := range cookies { + if existing == nil { + continue + } + + if existing.Name == cookie.Name { + updated = append(updated, cookie) + replaced = true + continue + } + + updated = append(updated, existing) + } + + if !replaced { + updated = append(updated, cookie) + } + + return updated +} + +func extractResourceID(t *testing.T, location, prefix string) string { + t.Helper() + + if !strings.HasPrefix(location, prefix) { + t.Fatalf("expected redirect location with prefix %q, got %q", prefix, location) + } + + trimmed := strings.TrimPrefix(location, prefix) + parts := strings.SplitN(trimmed, "?", 2) + if parts[0] == "" { + t.Fatalf("could not extract resource id from %q", location) + } + + return parts[0] +} + +func mustParseInt64(t *testing.T, raw string) int64 { + t.Helper() + + value, err := url.PathUnescape(raw) + if err != nil { + t.Fatalf("unescape id %q: %v", raw, err) + } + + var parsed int64 + if _, err := fmt.Sscan(value, &parsed); err != nil { + t.Fatalf("parse id %q: %v", raw, err) + } + + return parsed +} diff --git a/internal/http/rate_limit.go b/internal/http/rate_limit.go new file mode 100644 index 0000000..23522a4 --- /dev/null +++ b/internal/http/rate_limit.go @@ -0,0 +1,139 @@ +package httpserver + +import ( + "math" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +type rateLimitStore struct { + mu sync.Mutex + ratePerSec float64 + burst float64 + lastCleanup time.Time + entries map[string]*rateLimitEntry +} + +type rateLimitEntry struct { + tokens float64 + lastSeen time.Time +} + +func newRateLimitStore(perMinute, burst int) *rateLimitStore { + return &rateLimitStore{ + ratePerSec: float64(perMinute) / 60, + burst: float64(burst), + entries: make(map[string]*rateLimitEntry), + } +} + +func (s *rateLimitStore) Allow(key string, now time.Time) (bool, time.Duration) { + key = strings.TrimSpace(key) + if key == "" { + key = "global" + } + + s.mu.Lock() + defer s.mu.Unlock() + + if entry, ok := s.entries[key]; ok { + elapsed := now.Sub(entry.lastSeen).Seconds() + entry.tokens = math.Min(s.burst, entry.tokens+(elapsed*s.ratePerSec)) + entry.lastSeen = now + if entry.tokens >= 1 { + entry.tokens-- + s.cleanup(now) + return true, 0 + } + + s.cleanup(now) + return false, retryAfter(entry.tokens, s.ratePerSec) + } + + s.entries[key] = &rateLimitEntry{ + tokens: s.burst - 1, + lastSeen: now, + } + s.cleanup(now) + return true, 0 +} + +func (s *rateLimitStore) cleanup(now time.Time) { + if !s.lastCleanup.IsZero() && now.Sub(s.lastCleanup) < 5*time.Minute { + return + } + + for key, entry := range s.entries { + if now.Sub(entry.lastSeen) > 15*time.Minute { + delete(s.entries, key) + } + } + + s.lastCleanup = now +} + +func retryAfter(tokens, ratePerSec float64) time.Duration { + if ratePerSec <= 0 { + return time.Minute + } + + if tokens < 0 { + tokens = 0 + } + + seconds := (1 - tokens) / ratePerSec + if seconds < 1 { + seconds = 1 + } + + return time.Duration(math.Ceil(seconds * float64(time.Second))) +} + +func retryAfterHeader(delay time.Duration) string { + seconds := int(math.Ceil(delay.Seconds())) + if seconds < 1 { + seconds = 1 + } + + return strconv.Itoa(seconds) +} + +func (h *handler) loginRateLimit(next http.Handler) http.Handler { + return h.rateLimit(h.loginRateLimiter, func(r *http.Request) string { + return clientIP(r) + }, func(w http.ResponseWriter, r *http.Request, delay time.Duration) { + w.Header().Set("Retry-After", retryAfterHeader(delay)) + http.Error(w, "too many login attempts", http.StatusTooManyRequests) + })(next) +} + +func (h *handler) clientAPIRateLimit(next http.Handler) http.Handler { + return h.rateLimit(h.clientRateLimiter, func(r *http.Request) string { + return clientIP(r) + }, func(w http.ResponseWriter, r *http.Request, delay time.Duration) { + w.Header().Set("Retry-After", retryAfterHeader(delay)) + writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate limit exceeded"}) + })(next) +} + +func (h *handler) rateLimit(store *rateLimitStore, keyFn func(*http.Request) string, reject func(http.ResponseWriter, *http.Request, time.Duration)) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := clientIP(r) + if keyFn != nil { + key = keyFn(r) + } + + allowed, delay := store.Allow(key, time.Now().UTC()) + if !allowed { + reject(w, r, delay) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/render.go b/internal/http/render.go new file mode 100644 index 0000000..c1dbcac --- /dev/null +++ b/internal/http/render.go @@ -0,0 +1,78 @@ +package httpserver + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "path/filepath" + "strings" +) + +type Renderer struct { + templates map[string]*template.Template +} + +func NewRenderer(templatesDir string) (*Renderer, error) { + layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts", "*.gohtml")) + if err != nil { + return nil, fmt.Errorf("find layouts: %w", err) + } + + if len(layouts) == 0 { + return nil, fmt.Errorf("no layout templates found in %s", filepath.Join(templatesDir, "layouts")) + } + + partials, err := filepath.Glob(filepath.Join(templatesDir, "partials", "*.gohtml")) + if err != nil { + return nil, fmt.Errorf("find partials: %w", err) + } + + pages, err := filepath.Glob(filepath.Join(templatesDir, "pages", "*.gohtml")) + if err != nil { + return nil, fmt.Errorf("find pages: %w", err) + } + + if len(pages) == 0 { + return nil, fmt.Errorf("no page templates found in %s", filepath.Join(templatesDir, "pages")) + } + + renderer := &Renderer{templates: make(map[string]*template.Template, len(pages))} + + for _, page := range pages { + files := append([]string{}, layouts...) + files = append(files, partials...) + files = append(files, page) + + tmpl, err := template.ParseFiles(files...) + if err != nil { + return nil, fmt.Errorf("parse template set for %s: %w", page, err) + } + + name := strings.TrimSuffix(filepath.Base(page), filepath.Ext(page)) + renderer.templates[name] = tmpl + } + + return renderer, nil +} + +func (r *Renderer) Render(w http.ResponseWriter, name string, status int, data any) error { + tmpl, ok := r.templates[name] + if !ok { + return fmt.Errorf("unknown template %q", name) + } + + var output bytes.Buffer + if err := tmpl.ExecuteTemplate(&output, "base", data); err != nil { + return err + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if status <= 0 { + status = http.StatusOK + } + w.WriteHeader(status) + + _, err := output.WriteTo(w) + return err +} diff --git a/internal/http/response.go b/internal/http/response.go new file mode 100644 index 0000000..2f81611 --- /dev/null +++ b/internal/http/response.go @@ -0,0 +1,15 @@ +package httpserver + +import ( + "encoding/json" + "net/http" +) + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + + if err := json.NewEncoder(w).Encode(payload); err != nil { + http.Error(w, "json encoding failed", http.StatusInternalServerError) + } +} diff --git a/internal/http/router.go b/internal/http/router.go new file mode 100644 index 0000000..cf68f02 --- /dev/null +++ b/internal/http/router.go @@ -0,0 +1,113 @@ +package httpserver + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "update_server/internal/apikeys" + authservice "update_server/internal/auth" + "update_server/internal/config" + "update_server/internal/db" + "update_server/internal/releases" +) + +type handler struct { + config config.Config + store *db.Store + logger *slog.Logger + renderer *Renderer + auth *authservice.Service + apiKeys *apikeys.Service + releases *releases.Service + loginRateLimiter *rateLimitStore + clientRateLimiter *rateLimitStore +} + +func NewRouter(cfg config.Config, logger *slog.Logger, renderer *Renderer, store *db.Store, auth *authservice.Service, apiKeyService *apikeys.Service, releaseService *releases.Service) http.Handler { + h := &handler{ + config: cfg, + store: store, + logger: logger, + renderer: renderer, + auth: auth, + apiKeys: apiKeyService, + releases: releaseService, + loginRateLimiter: newRateLimitStore(cfg.LoginRateLimitPerMinute, cfg.LoginRateLimitBurst), + clientRateLimiter: newRateLimitStore(cfg.ClientRateLimitPerMinute, cfg.ClientRateLimitBurst), + } + + router := chi.NewRouter() + router.Use(middleware.RequestID) + if cfg.TrustProxyHeaders { + router.Use(middleware.RealIP) + } + router.Use(requestLogger(logger)) + router.Use(middleware.Recoverer) + router.Use(middleware.StripSlashes) + router.Use(securityHeaders(cfg)) + + router.Get("/healthz", h.health) + router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(cfg.StaticDir)))) + router.Get("/", h.home) + + router.Route("/admin", func(r chi.Router) { + r.Use(adminResponseHeaders) + r.Use(h.adminCSRF) + + r.Get("/login", h.adminLoginForm) + r.With(h.loginRateLimit).Post("/login", h.adminLogin) + + r.Group(func(r chi.Router) { + r.Use(h.requireAuthenticatedSession) + r.Use(h.requireRole(db.UserRoleAdmin)) + + r.Get("/", h.adminHome) + r.Post("/logout", h.adminLogout) + r.Get("/projects", h.adminProjects) + r.Get("/projects/new", h.adminProjectNew) + r.Post("/projects", h.adminProjectCreate) + r.Get("/projects/{projectID}", h.adminProjectDetail) + r.Post("/projects/{projectID}", h.adminProjectUpdate) + r.Post("/projects/{projectID}/archive", h.adminProjectArchive) + r.Post("/projects/{projectID}/tags", h.adminProjectAttachTag) + r.Post("/projects/{projectID}/tags/{tagID}/detach", h.adminProjectDetachTag) + r.Post("/projects/{projectID}/releases", h.adminProjectUploadRelease) + r.Get("/tags", h.adminTags) + r.Get("/tags/new", h.adminTagNew) + r.Post("/tags", h.adminTagCreate) + r.Get("/tags/{tagID}", h.adminTagDetail) + r.Post("/tags/{tagID}", h.adminTagUpdate) + r.Post("/tags/{tagID}/delete", h.adminTagDelete) + r.Get("/api-keys", h.adminAPIKeys) + r.Get("/api-keys/new", h.adminAPIKeyNew) + r.Post("/api-keys", h.adminAPIKeyCreate) + r.Get("/api-keys/{apiKeyID}", h.adminAPIKeyDetail) + r.Post("/api-keys/{apiKeyID}", h.adminAPIKeyUpdate) + r.Post("/api-keys/{apiKeyID}/activate", h.adminAPIKeyToggleActive) + }) + }) + + router.Route("/api", func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { + r.Use(apiResponseHeaders) + r.Get("/", h.apiIndex) + + r.Group(func(r chi.Router) { + r.Use(protectedAPIResponseHeaders) + r.Use(h.clientAPIRateLimit) + r.Use(h.requireAPIKey) + r.Use(h.requireAPIKeyPermission(apikeys.PermissionDownload)) + + r.Get("/projects", h.apiAccessibleProjects) + r.Get("/projects/{projectSlug}/releases/latest", h.apiLatestRelease) + r.Get("/releases/{releaseID}", h.apiReleaseMetadata) + r.Get("/releases/{releaseID}/download", h.apiReleaseDownload) + }) + }) + }) + + return router +} diff --git a/internal/http/security.go b/internal/http/security.go new file mode 100644 index 0000000..2036d3a --- /dev/null +++ b/internal/http/security.go @@ -0,0 +1,80 @@ +package httpserver + +import ( + "net/http" + "strings" + + "update_server/internal/config" +) + +const contentSecurityPolicy = "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'" + +func securityHeaders(cfg config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers := w.Header() + headers.Set("Content-Security-Policy", contentSecurityPolicy) + headers.Set("Cross-Origin-Opener-Policy", "same-origin") + headers.Set("Cross-Origin-Resource-Policy", "same-origin") + headers.Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()") + headers.Set("Referrer-Policy", "no-referrer") + headers.Set("X-Content-Type-Options", "nosniff") + headers.Set("X-Frame-Options", "DENY") + + if cfg.SecureCookies { + headers.Set("Strict-Transport-Security", "max-age=31536000") + } + + next.ServeHTTP(w, r) + }) + } +} + +func adminResponseHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + applyNoStoreHeaders(w) + addVaryHeader(w, "Cookie") + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + next.ServeHTTP(w, r) + }) +} + +func apiResponseHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + next.ServeHTTP(w, r) + }) +} + +func protectedAPIResponseHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + applyNoStoreHeaders(w) + addVaryHeader(w, "Authorization") + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + next.ServeHTTP(w, r) + }) +} + +func applyNoStoreHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store, private, max-age=0") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + +func addVaryHeader(w http.ResponseWriter, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + + current := w.Header().Values("Vary") + for _, entry := range current { + for _, existing := range strings.Split(entry, ",") { + if strings.EqualFold(strings.TrimSpace(existing), value) { + return + } + } + } + + w.Header().Add("Vary", value) +} diff --git a/internal/http/security_integration_test.go b/internal/http/security_integration_test.go new file mode 100644 index 0000000..ed1d40a --- /dev/null +++ b/internal/http/security_integration_test.go @@ -0,0 +1,194 @@ +package httpserver_test + +import ( + "net/http" + "net/url" + "testing" + + "update_server/internal/apikeys" + "update_server/internal/config" + "update_server/internal/db" +) + +func TestAdminPOSTRejectsMissingOrInvalidCSRFToken(t *testing.T) { + t.Parallel() + + router, _, _ := newTestRouterWithStore(t) + sessionCookie := loginAsAdmin(t, router) + + missingRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + }, sessionCookie) + if missingRecorder.Code != http.StatusForbidden { + t.Fatalf("expected missing csrf token to return 403, got %d with body %s", missingRecorder.Code, missingRecorder.Body.String()) + } + + csrfCookie := ensureCSRFCookie(t, router, sessionCookie) + invalidRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + "csrf_token": {"definitely-wrong"}, + }, sessionCookie, csrfCookie) + if invalidRecorder.Code != http.StatusForbidden { + t.Fatalf("expected invalid csrf token to return 403, got %d with body %s", invalidRecorder.Code, invalidRecorder.Body.String()) + } + + location := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + }, http.StatusSeeOther, sessionCookie) + if location == "" { + t.Fatal("expected valid csrf-protected form to redirect") + } +} + +func TestSecurityHeadersAndCookieDefaults(t *testing.T) { + t.Parallel() + + router, cfg, store := newTestRouterWithConfig(t, func(cfg *config.Config) { + cfg.BaseURL = "https://updates.example.com" + cfg.SecureCookies = true + }) + + loginPageRecorder := performRequest(t, router, http.MethodGet, "/admin/login", nil) + if loginPageRecorder.Code != http.StatusOK { + t.Fatalf("expected login page to load, got %d", loginPageRecorder.Code) + } + + assertHeaderContains(t, loginPageRecorder, "Cache-Control", "no-store") + assertHeaderEquals(t, loginPageRecorder, "X-Frame-Options", "DENY") + assertHeaderEquals(t, loginPageRecorder, "X-Content-Type-Options", "nosniff") + assertHeaderEquals(t, loginPageRecorder, "Referrer-Policy", "no-referrer") + assertHeaderContains(t, loginPageRecorder, "Content-Security-Policy", "frame-ancestors 'none'") + assertHeaderContains(t, loginPageRecorder, "Strict-Transport-Security", "max-age=31536000") + + csrfCookie := ensureCSRFCookie(t, router) + if csrfCookie.Path != "/admin" { + t.Fatalf("expected csrf cookie path /admin, got %q", csrfCookie.Path) + } + if !csrfCookie.HttpOnly { + t.Fatal("expected csrf cookie to be HttpOnly") + } + if !csrfCookie.Secure { + t.Fatal("expected csrf cookie to be Secure when APP_BASE_URL is https") + } + if csrfCookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("expected csrf cookie SameSite=Strict, got %v", csrfCookie.SameSite) + } + + loginRecorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{ + "email": {"admin@example.com"}, + "password": {"correct horse battery staple"}, + "next": {"/admin"}, + "csrf_token": {csrfCookie.Value}, + }, csrfCookie) + if loginRecorder.Code != http.StatusSeeOther { + t.Fatalf("expected login to redirect, got %d with body %s", loginRecorder.Code, loginRecorder.Body.String()) + } + + var sessionCookie *http.Cookie + for _, cookie := range loginRecorder.Result().Cookies() { + if cookie.Name == cfg.SessionCookieName { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil { + t.Fatal("expected session cookie after login") + } + if sessionCookie.Path != "/admin" { + t.Fatalf("expected session cookie path /admin, got %q", sessionCookie.Path) + } + if !sessionCookie.HttpOnly { + t.Fatal("expected session cookie to be HttpOnly") + } + if !sessionCookie.Secure { + t.Fatal("expected session cookie to be Secure") + } + if sessionCookie.SameSite != http.SameSiteLaxMode { + t.Fatalf("expected session cookie SameSite=Lax, got %v", sessionCookie.SameSite) + } + + keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{ + Name: "Clients", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + }) + if err != nil { + t.Fatalf("create api key: %v", err) + } + + apiRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) + if apiRecorder.Code != http.StatusOK { + t.Fatalf("expected api project listing to succeed, got %d with body %s", apiRecorder.Code, apiRecorder.Body.String()) + } + + assertHeaderContains(t, apiRecorder, "Cache-Control", "no-store") + assertHeaderContains(t, apiRecorder, "Vary", "Authorization") + assertHeaderEquals(t, apiRecorder, "X-Content-Type-Options", "nosniff") + assertHeaderEquals(t, apiRecorder, "X-Frame-Options", "DENY") + assertHeaderContains(t, apiRecorder, "X-Robots-Tag", "noindex") +} + +func TestLoginRateLimitReturnsTooManyRequests(t *testing.T) { + t.Parallel() + + router, _, _ := newTestRouterWithConfig(t, func(cfg *config.Config) { + cfg.LoginRateLimitPerMinute = 60 + cfg.LoginRateLimitBurst = 2 + }) + + csrfCookie := ensureCSRFCookie(t, router) + form := url.Values{ + "email": {"admin@example.com"}, + "password": {"wrong-password"}, + "next": {"/admin"}, + "csrf_token": {csrfCookie.Value}, + } + + for attempt := 0; attempt < 2; attempt++ { + recorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected attempt %d to return 401, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String()) + } + } + + limitedRecorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie) + if limitedRecorder.Code != http.StatusTooManyRequests { + t.Fatalf("expected limited login to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String()) + } + assertHeaderContains(t, limitedRecorder, "Retry-After", "1") +} + +func TestClientAPIRateLimitReturnsTooManyRequests(t *testing.T) { + t.Parallel() + + router, _, store := newTestRouterWithConfig(t, func(cfg *config.Config) { + cfg.ClientRateLimitPerMinute = 60 + cfg.ClientRateLimitBurst = 2 + }) + + keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{ + Name: "Clients", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + }) + if err != nil { + t.Fatalf("create api key: %v", err) + } + + for attempt := 0; attempt < 2; attempt++ { + recorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) + if recorder.Code != http.StatusOK { + t.Fatalf("expected api attempt %d to return 200, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String()) + } + } + + limitedRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) + if limitedRecorder.Code != http.StatusTooManyRequests { + t.Fatalf("expected api rate limit to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String()) + } + assertHeaderContains(t, limitedRecorder, "Retry-After", "1") +} diff --git a/internal/http/view_data.go b/internal/http/view_data.go new file mode 100644 index 0000000..392cf1a --- /dev/null +++ b/internal/http/view_data.go @@ -0,0 +1,165 @@ +package httpserver + +import "update_server/internal/db" + +type PageData struct { + Title string + Eyebrow string + Heading string + Description string + BaseURL string + CSRFToken string + CurrentUser *db.User + Flash *FlashMessage +} + +type FlashMessage struct { + Kind string + Message string +} + +type PageLink struct { + Label string + Href string + Description string +} + +type HomePageData struct { + PageData + Links []PageLink +} + +type DashboardMetric struct { + Label string + Value string + Description string +} + +type DashboardPageData struct { + PageData + Links []PageLink + Metrics []DashboardMetric +} + +type LoginFormData struct { + Action string + Email string + Next string + Error string + SetupHint string +} + +type LoginPageData struct { + PageData + Login LoginFormData +} + +type ProjectsPageData struct { + PageData + Projects []db.ProjectListItem +} + +type ProjectFormData struct { + Action string + SubmitLabel string + Name string + Slug string + Description string + Error string +} + +type ReleaseUploadData struct { + Action string + Version string + Build string + ReleaseNotes string + Error string + MaxUploadMB int64 +} + +type ProjectFormPageData struct { + PageData + Form ProjectFormData +} + +type ProjectDetailPageData struct { + PageData + Project db.Project + Form ProjectFormData + Tags []db.Tag + AvailableTags []db.Tag + Releases []db.ReleaseListItem + LatestRelease *db.Release + AttachTagAction string + ArchiveAction string + ArchiveState string + ArchiveLabel string + Upload ReleaseUploadData +} + +type TagsPageData struct { + PageData + Tags []db.TagListItem +} + +type TagFormData struct { + Action string + SubmitLabel string + Name string + Slug string + Description string + Error string + DeleteAction string + CanDelete bool +} + +type TagFormPageData struct { + PageData + Form TagFormData + Tag *db.Tag + Projects []db.Project +} + +type APIKeyProjectChoice struct { + Project db.Project + Selected bool +} + +type APIKeyTagChoice struct { + Tag db.Tag + Selected bool +} + +type APIKeysPageData struct { + PageData + APIKeys []db.APIKeyListItem +} + +type APIKeyFormData struct { + Action string + SubmitLabel string + Name string + Description string + ScopeMode db.ScopeMode + ExpiresAt string + CanDownload bool + CanUpload bool + CanDelete bool + CanManageProjects bool + SelectedProjectIDs []int64 + SelectedTagIDs []int64 + Error string + RevealKey string +} + +type APIKeyPageData struct { + PageData + APIKey *db.APIKey + Form APIKeyFormData + ProjectChoices []APIKeyProjectChoice + TagChoices []APIKeyTagChoice + AccessibleProjects []db.Project + ToggleAction string + ToggleState string + ToggleLabel string +} diff --git a/internal/releases/service.go b/internal/releases/service.go new file mode 100644 index 0000000..6e97d40 --- /dev/null +++ b/internal/releases/service.go @@ -0,0 +1,270 @@ +package releases + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "unicode" + + "update_server/internal/db" + "update_server/internal/storage" +) + +type Service struct { + store *db.Store + artifacts *storage.LocalStore +} + +type UploadParams struct { + ProjectID int64 + Version string + Build string + ReleaseNotes string + OriginalFilename string + DeclaredType string + Reader io.Reader + UploadedByUserID *int64 +} + +type UploadResult struct { + Project *db.Project + Release *db.Release +} + +func NewService(store *db.Store, artifacts *storage.LocalStore) *Service { + return &Service{ + store: store, + artifacts: artifacts, + } +} + +func (s *Service) Artifact(storagePath string) (*os.File, error) { + file, err := s.artifacts.Open(storagePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, db.ErrNotFound + } + + return nil, fmt.Errorf("open artifact: %w", err) + } + + return file, nil +} + +func (s *Service) Upload(ctx context.Context, params UploadParams) (*UploadResult, error) { + project, err := s.store.Projects.GetByID(ctx, params.ProjectID) + if err != nil { + return nil, fmt.Errorf("load upload project: %w", err) + } + + version := strings.TrimSpace(params.Version) + if version == "" { + return nil, fmt.Errorf("release version is required") + } + + sanitizedFilename := sanitizeFilename(params.OriginalFilename) + if sanitizedFilename == "" { + return nil, fmt.Errorf("uploaded file name is invalid") + } + + tempFile, tempPath, err := s.artifacts.CreateTemp("upload-*") + if err != nil { + return nil, fmt.Errorf("create temp artifact: %w", err) + } + + removeTemp := func() { + if tempFile != nil { + _ = tempFile.Close() + } + _ = s.artifacts.RemoveTemp(tempPath) + } + + hash := sha256.New() + multiWriter := io.MultiWriter(tempFile, hash) + + sniffBuffer := make([]byte, 0, 512) + copyBuffer := make([]byte, 32*1024) + var sizeBytes int64 + + for { + n, readErr := params.Reader.Read(copyBuffer) + if n > 0 { + chunk := copyBuffer[:n] + if len(sniffBuffer) < 512 { + remaining := 512 - len(sniffBuffer) + if remaining > n { + remaining = n + } + sniffBuffer = append(sniffBuffer, chunk[:remaining]...) + } + + written, writeErr := multiWriter.Write(chunk) + sizeBytes += int64(written) + if writeErr != nil { + removeTemp() + return nil, fmt.Errorf("write temp artifact: %w", writeErr) + } + } + + if errors.Is(readErr, io.EOF) { + break + } + + if readErr != nil { + removeTemp() + return nil, fmt.Errorf("read upload stream: %w", readErr) + } + } + + if sizeBytes == 0 { + removeTemp() + return nil, fmt.Errorf("uploaded artifact is empty") + } + + if err := tempFile.Close(); err != nil { + removeTemp() + return nil, fmt.Errorf("close temp artifact: %w", err) + } + tempFile = nil + + contentType := detectContentType(sniffBuffer, params.DeclaredType) + storagePath := buildStoragePath(project.Slug, version, params.Build, sanitizedFilename) + + if err := s.artifacts.CommitTemp(tempPath, storagePath); err != nil { + removeTemp() + return nil, fmt.Errorf("store artifact: %w", err) + } + + cleanupFinal := func() { + _ = s.artifacts.Remove(storagePath) + } + + release, err := s.store.Releases.Create(ctx, db.CreateReleaseParams{ + ProjectID: project.ID, + Version: version, + Build: strings.TrimSpace(params.Build), + Filename: sanitizedFilename, + StoragePath: storagePath, + ChecksumSHA256: hex.EncodeToString(hash.Sum(nil)), + SizeBytes: sizeBytes, + ContentType: contentType, + ReleaseNotes: strings.TrimSpace(params.ReleaseNotes), + UploadedByUserID: params.UploadedByUserID, + IsActive: true, + }) + if err != nil { + cleanupFinal() + return nil, fmt.Errorf("create release metadata: %w", err) + } + + return &UploadResult{ + Project: project, + Release: release, + }, nil +} + +func sanitizeFilename(raw string) string { + filename := filepath.Base(strings.ReplaceAll(strings.TrimSpace(raw), "\\", "/")) + if filename == "." || filename == "" { + return "" + } + + ext := filepath.Ext(filename) + name := strings.TrimSuffix(filename, ext) + name = sanitizePathSegment(name) + if name == "" { + name = "artifact" + } + + ext = sanitizeExtension(ext) + return strings.Trim(name+ext, ".") +} + +func sanitizeExtension(ext string) string { + if ext == "" { + return "" + } + + ext = strings.ToLower(ext) + var builder strings.Builder + for _, r := range ext { + switch { + case r == '.': + builder.WriteRune(r) + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(r) + } + } + + if builder.Len() <= 1 { + return "" + } + + return builder.String() +} + +func sanitizePathSegment(raw string) string { + raw = strings.TrimSpace(strings.ToLower(raw)) + var builder strings.Builder + lastDash := false + + for _, r := range raw { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(r) + lastDash = false + case r == '.', r == '_', r == '-': + builder.WriteRune(r) + lastDash = false + default: + if !lastDash && builder.Len() > 0 { + builder.WriteRune('-') + lastDash = true + } + } + } + + value := strings.Trim(builder.String(), "-._") + if len(value) > 160 { + value = strings.Trim(value[:160], "-._") + } + + return value +} + +func buildStoragePath(projectSlug, version, build, filename string) string { + versionSegment := sanitizePathSegment(version) + if versionSegment == "" { + versionSegment = "release" + } + + parts := []string{sanitizePathSegment(projectSlug), versionSegment} + if build = sanitizePathSegment(build); build != "" { + parts = append(parts, build) + } + parts = append(parts, filename) + + return filepath.ToSlash(filepath.Join(parts...)) +} + +func detectContentType(sniff []byte, declared string) string { + if detected := http.DetectContentType(sniff); detected != "" && detected != "application/octet-stream" { + return detected + } + + if declared = strings.TrimSpace(declared); declared != "" { + if mediaType, _, err := mime.ParseMediaType(declared); err == nil && mediaType != "" { + return mediaType + } + } + + return "application/octet-stream" +} diff --git a/internal/slug/slug.go b/internal/slug/slug.go new file mode 100644 index 0000000..b3fa7d5 --- /dev/null +++ b/internal/slug/slug.go @@ -0,0 +1,27 @@ +package slug + +import ( + "strings" + "unicode" +) + +func Make(raw string) string { + raw = strings.TrimSpace(strings.ToLower(raw)) + var builder strings.Builder + lastDash := false + + for _, r := range raw { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(r) + lastDash = false + default: + if !lastDash && builder.Len() > 0 { + builder.WriteRune('-') + lastDash = true + } + } + } + + return strings.Trim(builder.String(), "-") +} diff --git a/internal/storage/local.go b/internal/storage/local.go new file mode 100644 index 0000000..8ba798c --- /dev/null +++ b/internal/storage/local.go @@ -0,0 +1,127 @@ +package storage + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type LocalStore struct { + rootDir string + tempDir string +} + +func NewLocal(rootDir string) (*LocalStore, error) { + rootDir = filepath.Clean(rootDir) + tempDir := filepath.Join(rootDir, ".tmp") + + for _, dir := range []string{rootDir, tempDir} { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("create artifact directory %s: %w", dir, err) + } + } + + return &LocalStore{ + rootDir: rootDir, + tempDir: tempDir, + }, nil +} + +func (s *LocalStore) RootDir() string { + return s.rootDir +} + +func (s *LocalStore) CreateTemp(pattern string) (*os.File, string, error) { + file, err := os.CreateTemp(s.tempDir, pattern) + if err != nil { + return nil, "", fmt.Errorf("create temp file: %w", err) + } + + return file, file.Name(), nil +} + +func (s *LocalStore) CommitTemp(tempPath, relativePath string) error { + destination, err := s.absolutePath(relativePath) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil { + return fmt.Errorf("create artifact parent directory: %w", err) + } + + if _, err := os.Stat(destination); err == nil { + return fmt.Errorf("artifact already exists at %s", relativePath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("check artifact destination: %w", err) + } + + if err := os.Rename(tempPath, destination); err != nil { + return fmt.Errorf("move artifact into place: %w", err) + } + + return nil +} + +func (s *LocalStore) Remove(relativePath string) error { + destination, err := s.absolutePath(relativePath) + if err != nil { + return err + } + + if err := os.Remove(destination); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove artifact: %w", err) + } + + return nil +} + +func (s *LocalStore) RemoveTemp(tempPath string) error { + if strings.TrimSpace(tempPath) == "" { + return nil + } + + if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove temp artifact: %w", err) + } + + return nil +} + +func (s *LocalStore) Open(relativePath string) (*os.File, error) { + destination, err := s.absolutePath(relativePath) + if err != nil { + return nil, err + } + + file, err := os.Open(destination) + if err != nil { + return nil, fmt.Errorf("open artifact: %w", err) + } + + return file, nil +} + +func (s *LocalStore) absolutePath(relativePath string) (string, error) { + relativePath = filepath.Clean(strings.TrimSpace(relativePath)) + if relativePath == "." || relativePath == "" { + return "", fmt.Errorf("artifact path is required") + } + + if filepath.IsAbs(relativePath) { + return "", fmt.Errorf("artifact path must be relative") + } + + joined := filepath.Join(s.rootDir, relativePath) + rel, err := filepath.Rel(s.rootDir, joined) + if err != nil { + return "", fmt.Errorf("resolve artifact path: %w", err) + } + + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("artifact path escapes storage root") + } + + return joined, nil +} diff --git a/migrate b/migrate new file mode 100755 index 0000000..06d0d78 Binary files /dev/null and b/migrate differ diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql new file mode 100644 index 0000000..3498b91 --- /dev/null +++ b/migrations/0001_initial_schema.sql @@ -0,0 +1,112 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK(length(trim(email)) > 3), + password_hash TEXT NOT NULL CHECK(length(password_hash) > 0), + role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + last_login_at TEXT +); + +CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL CHECK(length(trim(name)) > 0), + slug TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK(length(trim(slug)) > 0), + description TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL CHECK(length(trim(name)) > 0), + slug TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK(length(trim(slug)) > 0), + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE TABLE project_tags ( + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (project_id, tag_id) +); + +CREATE TABLE releases ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + version TEXT NOT NULL CHECK(length(trim(version)) > 0), + build TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL CHECK(length(trim(filename)) > 0), + storage_path TEXT NOT NULL UNIQUE CHECK(length(trim(storage_path)) > 0), + checksum_sha256 TEXT NOT NULL CHECK(length(checksum_sha256) = 64), + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0), + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + release_notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + uploaded_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)), + UNIQUE (project_id, version, build) +); + +CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL CHECK(length(trim(name)) > 0), + key_prefix TEXT NOT NULL UNIQUE CHECK(length(trim(key_prefix)) > 0), + key_hash TEXT NOT NULL UNIQUE CHECK(length(trim(key_hash)) > 0), + description TEXT NOT NULL DEFAULT '', + scope_mode TEXT NOT NULL CHECK(scope_mode IN ('all_projects', 'project_allow_list', 'project_deny_list', 'tag_allow_list', 'tag_deny_list')), + can_download INTEGER NOT NULL DEFAULT 0 CHECK(can_download IN (0, 1)), + can_upload INTEGER NOT NULL DEFAULT 0 CHECK(can_upload IN (0, 1)), + can_delete INTEGER NOT NULL DEFAULT 0 CHECK(can_delete IN (0, 1)), + can_manage_projects INTEGER NOT NULL DEFAULT 0 CHECK(can_manage_projects IN (0, 1)), + is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)), + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + last_used_at TEXT, + created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE api_key_project_access ( + api_key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (api_key_id, project_id) +); + +CREATE TABLE api_key_tag_access ( + api_key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (api_key_id, tag_id) +); + +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE CHECK(length(trim(token_hash)) > 0), + expires_at TEXT NOT NULL, + last_seen_at TEXT, + invalidated_at TEXT, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + api_key_id INTEGER REFERENCES api_keys(id) ON DELETE SET NULL, + action TEXT NOT NULL CHECK(length(trim(action)) > 0), + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER, + target_identifier TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + ip_address TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); diff --git a/migrations/0002_indexes_and_triggers.sql b/migrations/0002_indexes_and_triggers.sql new file mode 100644 index 0000000..957fb76 --- /dev/null +++ b/migrations/0002_indexes_and_triggers.sql @@ -0,0 +1,65 @@ +CREATE INDEX idx_project_tags_tag_id ON project_tags(tag_id); + +CREATE INDEX idx_releases_project_active_created_at ON releases(project_id, is_active, created_at DESC); +CREATE INDEX idx_releases_uploaded_by_user_id ON releases(uploaded_by_user_id); + +CREATE INDEX idx_api_keys_scope_mode_active ON api_keys(scope_mode, is_active); +CREATE INDEX idx_api_key_project_access_project_id ON api_key_project_access(project_id); +CREATE INDEX idx_api_key_tag_access_tag_id ON api_key_tag_access(tag_id); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX idx_audit_logs_actor_user_id ON audit_logs(actor_user_id); +CREATE INDEX idx_audit_logs_api_key_id ON audit_logs(api_key_id); + +CREATE TRIGGER users_touch_updated_at +AFTER UPDATE ON users +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE users + SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER projects_touch_updated_at +AFTER UPDATE ON projects +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE projects + SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER tags_touch_updated_at +AFTER UPDATE ON tags +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE tags + SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER releases_touch_updated_at +AFTER UPDATE ON releases +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE releases + SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER api_keys_touch_updated_at +AFTER UPDATE ON api_keys +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE api_keys + SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = NEW.id; +END; diff --git a/migrations/0003_api_key_scope_guards.sql b/migrations/0003_api_key_scope_guards.sql new file mode 100644 index 0000000..a1326b4 --- /dev/null +++ b/migrations/0003_api_key_scope_guards.sql @@ -0,0 +1,52 @@ +CREATE TRIGGER api_key_project_access_scope_guard +BEFORE INSERT ON api_key_project_access +FOR EACH ROW +WHEN (SELECT scope_mode FROM api_keys WHERE id = NEW.api_key_id) NOT IN ('project_allow_list', 'project_deny_list') +BEGIN + SELECT RAISE(ABORT, 'api key scope_mode does not permit project access rows'); +END; + +CREATE TRIGGER api_key_tag_access_scope_guard +BEFORE INSERT ON api_key_tag_access +FOR EACH ROW +WHEN (SELECT scope_mode FROM api_keys WHERE id = NEW.api_key_id) NOT IN ('tag_allow_list', 'tag_deny_list') +BEGIN + SELECT RAISE(ABORT, 'api key scope_mode does not permit tag access rows'); +END; + +CREATE TRIGGER api_keys_scope_mode_transition_guard +BEFORE UPDATE OF scope_mode ON api_keys +FOR EACH ROW +BEGIN + SELECT CASE + WHEN NEW.scope_mode = 'all_projects' AND EXISTS ( + SELECT 1 + FROM api_key_project_access + WHERE api_key_id = NEW.id + ) THEN RAISE(ABORT, 'all_projects keys cannot keep project access rows') + END; + + SELECT CASE + WHEN NEW.scope_mode = 'all_projects' AND EXISTS ( + SELECT 1 + FROM api_key_tag_access + WHERE api_key_id = NEW.id + ) THEN RAISE(ABORT, 'all_projects keys cannot keep tag access rows') + END; + + SELECT CASE + WHEN NEW.scope_mode IN ('project_allow_list', 'project_deny_list') AND EXISTS ( + SELECT 1 + FROM api_key_tag_access + WHERE api_key_id = NEW.id + ) THEN RAISE(ABORT, 'project-scoped keys cannot keep tag access rows') + END; + + SELECT CASE + WHEN NEW.scope_mode IN ('tag_allow_list', 'tag_deny_list') AND EXISTS ( + SELECT 1 + FROM api_key_project_access + WHERE api_key_id = NEW.id + ) THEN RAISE(ABORT, 'tag-scoped keys cannot keep project access rows') + END; +END; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..ca59af1 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,5 @@ +# Migrations + +This directory stores the ordered SQLite migration files applied by the app and by `go run ./cmd/migrate`. + +Files are applied lexicographically and tracked in the `schema_migrations` table with a SHA-256 checksum so edited applied migrations fail fast. diff --git a/server b/server new file mode 100755 index 0000000..3ba9389 Binary files /dev/null and b/server differ diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..292fa2d --- /dev/null +++ b/web/static/app.css @@ -0,0 +1,584 @@ +:root { + color-scheme: light; + --bg: #f5f1e8; + --card: rgba(255, 255, 255, 0.88); + --card-border: rgba(55, 46, 36, 0.16); + --text: #2a241d; + --muted: #64584b; + --accent: #0f766e; + --accent-soft: #d8f3ef; + --danger: #9a3412; + --danger-soft: #ffefe6; + --success-soft: #edf7f2; + --shadow: 0 18px 45px rgba(49, 41, 31, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; + color: var(--text); + background: + radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 25rem), + linear-gradient(180deg, #efe7d5 0%, var(--bg) 100%); +} + +a { + color: inherit; +} + +.page-shell { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 1.25rem 0 3rem; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0 1.5rem; +} + +.brand { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.9rem; +} + +.site-nav a { + text-decoration: none; + color: var(--muted); +} + +.nav-user { + color: var(--text); + font-size: 0.95rem; +} + +.nav-form { + margin: 0; +} + +.nav-button { + border: 0; + background: transparent; + color: var(--accent); + font: inherit; + font-weight: 700; + cursor: pointer; + padding: 0; +} + +.content-card { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 1.5rem; + padding: 2rem; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.hero { + max-width: 46rem; +} + +.hero-split { + max-width: none; + display: flex; + justify-content: space-between; + gap: 1.25rem; + align-items: flex-start; +} + +.hero-meta { + display: grid; + justify-items: end; + gap: 0.75rem; +} + +.eyebrow { + margin: 0 0 0.75rem; + color: var(--accent); + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(2.2rem, 4vw, 4.2rem); + line-height: 0.96; +} + +.lede { + margin: 1rem 0 0; + max-width: 44rem; + font-size: 1.1rem; + line-height: 1.7; + color: var(--muted); +} + +.link-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.link-card, +.callout { + border-radius: 1rem; + border: 1px solid rgba(15, 118, 110, 0.16); + background: linear-gradient(180deg, rgba(216, 243, 239, 0.8), rgba(255, 255, 255, 0.92)); + padding: 1rem 1.1rem; +} + +.link-card h2 { + margin: 0 0 0.6rem; + font-size: 1.15rem; +} + +.link-card p, +.callout p { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.callout h2 { + margin: 0 0 0.7rem; + font-size: 1.05rem; +} + +.admin-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.metric-grid, +.resource-grid, +.section-grid { + display: grid; + gap: 1rem; + margin-top: 2rem; +} + +.metric-grid { + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} + +.resource-grid, +.section-grid { + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); +} + +.compact-grid { + margin-top: 1rem; +} + +.metric-card, +.resource-card, +.section-card { + border-radius: 1.1rem; + border: 1px solid rgba(55, 46, 36, 0.14); + background: rgba(255, 255, 255, 0.96); + padding: 1.15rem; +} + +.section-card-wide { + margin-top: 2rem; +} + +.metric-value { + margin: 0; + color: var(--accent); + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1; + font-weight: 700; +} + +.metric-card h2, +.resource-card h2, +.section-card h2, +.release-card h3 { + margin: 0.35rem 0 0.45rem; +} + +.metric-card p:last-child, +.resource-card p:last-child, +.section-card p:last-child { + margin-bottom: 0; +} + +.toolbar { + margin-top: 1.5rem; +} + +.resource-header, +.release-head, +.section-heading { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.section-heading { + margin-bottom: 1rem; +} + +.section-heading h2, +.resource-header h2, +.release-head h3 { + margin-top: 0; +} + +.resource-slug { + margin: 0; + color: var(--accent); + font-size: 0.92rem; + font-weight: 700; +} + +.resource-description, +.empty-copy, +.helper-copy, +.release-notes { + color: var(--muted); + line-height: 1.6; +} + +.stat-row, +.button-row, +.chip-list, +.release-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.stat-row { + margin: 1rem 0; + color: var(--muted); + font-size: 0.95rem; +} + +.button-row { + margin-top: 0.35rem; +} + +.endpoint-list { + display: grid; + gap: 0.75rem; +} + +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.2rem 0.75rem; + border-radius: 999px; + background: rgba(55, 46, 36, 0.08); + color: var(--text); + font-size: 0.85rem; + font-weight: 700; +} + +.pill-success { + background: var(--success-soft); + color: #0b5a54; +} + +.pill-soft { + background: var(--accent-soft); + color: #0b5a54; +} + +.pill-muted { + background: rgba(55, 46, 36, 0.08); + color: var(--muted); +} + +.meta-line + .meta-line { + margin-top: 0.45rem; +} + +.action-row { + margin-top: 1.5rem; +} + +.form-shell { + max-width: 28rem; + margin-top: 2rem; +} + +.form-shell-wide { + max-width: 40rem; +} + +.stack-form { + display: grid; + gap: 1rem; +} + +.inline-form { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.field { + display: grid; + gap: 0.45rem; + color: var(--text); + font-weight: 700; +} + +.field span { + font-size: 0.95rem; +} + +.field input { + width: 100%; + border: 1px solid rgba(55, 46, 36, 0.18); + border-radius: 0.95rem; + padding: 0.9rem 1rem; + font: inherit; + color: var(--text); + background: rgba(255, 255, 255, 0.96); +} + +.field select, +.field textarea { + width: 100%; + border: 1px solid rgba(55, 46, 36, 0.18); + border-radius: 0.95rem; + padding: 0.9rem 1rem; + font: inherit; + color: var(--text); + background: rgba(255, 255, 255, 0.96); + resize: vertical; +} + +.field input:focus { + outline: 2px solid rgba(15, 118, 110, 0.22); + outline-offset: 2px; + border-color: rgba(15, 118, 110, 0.45); +} + +.field textarea:focus, +.field select:focus { + outline: 2px solid rgba(15, 118, 110, 0.22); + outline-offset: 2px; + border-color: rgba(15, 118, 110, 0.45); +} + +.choice-group { + margin: 0; + padding: 1rem; + border: 1px solid rgba(55, 46, 36, 0.12); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.78); +} + +.choice-group legend { + padding: 0 0.35rem; + font-weight: 700; +} + +.choice-list { + display: grid; + gap: 0.75rem; +} + +.choice-row { + display: flex; + gap: 0.75rem; + align-items: flex-start; + color: var(--muted); + line-height: 1.5; +} + +.choice-row input { + margin-top: 0.2rem; +} + +.secret-code { + display: inline-block; + margin: 0.35rem 0; + padding: 0.55rem 0.7rem; + border-radius: 0.85rem; + background: rgba(255, 255, 255, 0.96); + word-break: break-all; +} + +.code-block { + margin: 1rem 0 0; + padding: 1rem 1.1rem; + border-radius: 1rem; + border: 1px solid rgba(55, 46, 36, 0.12); + background: rgba(42, 36, 29, 0.96); + color: #f7f2ea; + overflow-x: auto; +} + +.code-block code { + color: inherit; + white-space: pre; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3rem; + padding: 0.8rem 1.2rem; + border: 0; + border-radius: 999px; + font: inherit; + font-weight: 700; + color: white; + background: linear-gradient(135deg, #0f766e, #0b5a54); + cursor: pointer; + text-decoration: none; +} + +.button:hover { + filter: brightness(1.03); +} + +.button-secondary { + color: var(--text); + background: rgba(55, 46, 36, 0.08); +} + +.button-danger { + background: linear-gradient(135deg, var(--danger), #7c2d12); +} + +.alert-card { + margin-top: 1.5rem; + border-radius: 1rem; + border: 1px solid rgba(15, 118, 110, 0.18); + background: rgba(255, 255, 255, 0.9); + padding: 1rem 1.1rem; +} + +.alert-card p { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.alert-error { + border-color: rgba(162, 50, 28, 0.28); + background: rgba(255, 243, 239, 0.95); +} + +.alert-success { + background: rgba(237, 247, 242, 0.95); +} + +.compact-alert { + margin-top: 0; +} + +.text-link { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.chip-form { + display: inline-flex; + align-items: center; + gap: 0.6rem; + padding: 0.35rem 0.55rem 0.35rem 0.8rem; + border-radius: 999px; + border: 1px solid rgba(15, 118, 110, 0.16); + background: rgba(216, 243, 239, 0.7); +} + +.chip-label { + font-weight: 700; +} + +.chip-remove { + border: 0; + background: transparent; + color: var(--danger); + font: inherit; + cursor: pointer; +} + +.release-card { + flex: 1 1 20rem; + border-radius: 1rem; + border: 1px solid rgba(55, 46, 36, 0.14); + background: rgba(255, 255, 255, 0.96); + padding: 1rem; +} + +.release-build { + color: var(--muted); + font-size: 0.95rem; +} + +.empty-state { + margin-top: 2rem; + border-radius: 1rem; + border: 1px dashed rgba(55, 46, 36, 0.2); + padding: 1.5rem; + background: rgba(255, 255, 255, 0.72); +} + +code { + font-family: "SFMono-Regular", "SF Mono", "Menlo", monospace; + font-size: 0.92em; +} + +@media (max-width: 640px) { + .page-shell { + width: min(100% - 1rem, 72rem); + } + + .content-card { + padding: 1.35rem; + } + + .site-header { + align-items: flex-start; + flex-direction: column; + } + + .hero-split, + .hero-meta, + .resource-header, + .release-head, + .section-heading { + display: grid; + } + + .hero-meta { + justify-items: start; + } +} diff --git a/web/templates/layouts/base.gohtml b/web/templates/layouts/base.gohtml new file mode 100644 index 0000000..d251218 --- /dev/null +++ b/web/templates/layouts/base.gohtml @@ -0,0 +1,46 @@ +{{define "base"}} + + + + + + {{if .Title}}{{.Title}} | {{end}}Update Server + + + +
+ +
+ {{if .Flash}} +
+

{{.Flash.Message}}

+
+ {{end}} + {{template "content" .}} +
+
+ + +{{end}} diff --git a/web/templates/pages/admin.gohtml b/web/templates/pages/admin.gohtml new file mode 100644 index 0000000..ee6198c --- /dev/null +++ b/web/templates/pages/admin.gohtml @@ -0,0 +1,39 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ +
+ {{range .Metrics}} +
+

{{.Value}}

+

{{.Label}}

+

{{.Description}}

+
+ {{end}} +
+ +
+
+

Authenticated User

+

Email: {{if .CurrentUser}}{{.CurrentUser.Email}}{{else}}Unavailable{{end}}

+

Role: {{if .CurrentUser}}{{.CurrentUser.Role}}{{else}}Unknown{{end}}

+
+ +
+

Session And Storage

+

Protected admin routes continue to use the existing session context. Release uploads now persist files under the private artifact directory rather than the public static web root.

+
+
+ + +{{end}} diff --git a/web/templates/pages/api_key_form.gohtml b/web/templates/pages/api_key_form.gohtml new file mode 100644 index 0000000..13b3501 --- /dev/null +++ b/web/templates/pages/api_key_form.gohtml @@ -0,0 +1,200 @@ +{{define "content"}} +
+
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ {{if .APIKey}} +
+ + {{if .APIKey.IsActive}}Active{{else}}Revoked{{end}} + +

{{.APIKey.KeyPrefix}}

+
+ {{end}} +
+ +{{if .Form.RevealKey}} +
+

Copy this API key now: {{.Form.RevealKey}}

+

This is the only time the full raw key will be shown. Only the hash and short prefix are stored.

+
+{{end}} + +{{if .Form.Error}} +
+

{{.Form.Error}}

+
+{{end}} + +
+
+
+

Key Details

+

Choose a stable label, minimum permissions, and one scope mode for this key.

+
+ +
+ {{template "csrf_field" .}} + + + + + + + + +
+ Permissions + + + + +
+ +
+ Project rules +

Used only for project_allow_list and project_deny_list.

+ {{if .ProjectChoices}} +
+ {{range .ProjectChoices}} + + {{end}} +
+ {{else}} +

No projects exist yet.

+ {{end}} +
+ +
+ Tag rules +

Used only for tag_allow_list and tag_deny_list.

+ {{if .TagChoices}} +
+ {{range .TagChoices}} + + {{end}} +
+ {{else}} +

No tags exist yet.

+ {{end}} +
+ +
+ + Back to API keys +
+
+
+ + {{if .APIKey}} +
+
+

Lifecycle

+

Disabled or expired keys are rejected before any project scope checks happen.

+
+ +

Created: {{.APIKey.CreatedAt.Format "2006-01-02 15:04 UTC"}}

+

Updated: {{.APIKey.UpdatedAt.Format "2006-01-02 15:04 UTC"}}

+

Last used: {{if .APIKey.LastUsedAt}}{{.APIKey.LastUsedAt.Format "2006-01-02 15:04 UTC"}}{{else}}Never{{end}}

+

Expires: {{if .APIKey.ExpiresAt}}{{.APIKey.ExpiresAt.Format "2006-01-02 15:04 UTC"}}{{else}}No expiry{{end}}

+ +
+ {{template "csrf_field" .}} + + +
+
+ {{end}} +
+ +{{if .APIKey}} +
+
+

Effective Access Preview

+

This preview shows the active projects currently reachable after scope mode evaluation.

+
+ + {{if .AccessibleProjects}} +
+ {{range .AccessibleProjects}} +
+
+
+

{{.Name}}

+

{{.Slug}}

+
+ Active +
+

{{if .Description}}{{.Description}}{{else}}No description yet.{{end}}

+
+ {{end}} +
+ {{else}} +

No active projects currently match this key.

+ {{end}} +
+ +
+
+

Client API Quick Start

+

Clients should authenticate with a bearer API key and then discover projects before requesting metadata or downloading an artifact.

+
+ +
+

Bearer header: Authorization: Bearer {{if .Form.RevealKey}}{{.Form.RevealKey}}{{else}}<paste-api-key>{{end}}

+

Accessible projects: {{.BaseURL}}/api/v1/projects

+
+ +
curl -H "Authorization: Bearer {{if .Form.RevealKey}}{{.Form.RevealKey}}{{else}}<paste-api-key>{{end}}" \
+  {{.BaseURL}}/api/v1/projects
+ + {{if .AccessibleProjects}} +
+ {{range .AccessibleProjects}} +

{{.Name}} latest metadata: {{printf "%s/api/v1/projects/%s/releases/latest" $.BaseURL .Slug}}

+ {{end}} +
+ {{else}} +

This key does not currently expose any active projects, so client metadata and download lookups will return no accessible resources.

+ {{end}} +
+{{end}} +{{end}} diff --git a/web/templates/pages/api_keys.gohtml b/web/templates/pages/api_keys.gohtml new file mode 100644 index 0000000..d62108c --- /dev/null +++ b/web/templates/pages/api_keys.gohtml @@ -0,0 +1,49 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ +
+ Create API key +
+ +{{if .APIKeys}} +
+ {{range .APIKeys}} +
+
+
+

{{.APIKey.Name}}

+

{{.APIKey.KeyPrefix}}

+
+ + {{if .APIKey.IsActive}}Active{{else}}Revoked{{end}} + +
+ +

{{if .APIKey.Description}}{{.APIKey.Description}}{{else}}No description yet.{{end}}

+

Scope: {{.APIKey.ScopeMode}}

+

Rule rows: {{.ProjectRuleCount}} project, {{.TagRuleCount}} tag

+

Permissions: + {{if .APIKey.CanDownload}}download{{else}}no-download{{end}}, + {{if .APIKey.CanUpload}}upload{{else}}no-upload{{end}}, + {{if .APIKey.CanDelete}}delete{{else}}no-delete{{end}}, + {{if .APIKey.CanManageProjects}}manage-projects{{else}}no-manage-projects{{end}} +

+

Updated: {{.APIKey.UpdatedAt.Format "2006-01-02 15:04 UTC"}}

+

Last used: {{if .APIKey.LastUsedAt}}{{.APIKey.LastUsedAt.Format "2006-01-02 15:04 UTC"}}{{else}}Never{{end}}

+

Expires: {{if .APIKey.ExpiresAt}}{{.APIKey.ExpiresAt.Format "2006-01-02 15:04 UTC"}}{{else}}No expiry{{end}}

+ + Open API key +
+ {{end}} +
+{{else}} +
+

No API keys yet

+

Create the first key to let client applications authenticate against the project, metadata, and download endpoints.

+
+{{end}} +{{end}} diff --git a/web/templates/pages/home.gohtml b/web/templates/pages/home.gohtml new file mode 100644 index 0000000..e961d14 --- /dev/null +++ b/web/templates/pages/home.gohtml @@ -0,0 +1,16 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ + +{{end}} diff --git a/web/templates/pages/login.gohtml b/web/templates/pages/login.gohtml new file mode 100644 index 0000000..4b6db71 --- /dev/null +++ b/web/templates/pages/login.gohtml @@ -0,0 +1,38 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ +{{if .Login.Error}} +
+

{{.Login.Error}}

+
+{{end}} + +{{if .Login.SetupHint}} +
+

{{.Login.SetupHint}}

+
+{{end}} + +
+
+ {{template "csrf_field" .}} + + + + + + + +
+
+{{end}} diff --git a/web/templates/pages/project_detail.gohtml b/web/templates/pages/project_detail.gohtml new file mode 100644 index 0000000..c85eefb --- /dev/null +++ b/web/templates/pages/project_detail.gohtml @@ -0,0 +1,200 @@ +{{define "content"}} +
+
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+
+ + {{if .Project.IsActive}}Active{{else}}Archived{{end}} + +

{{.Project.Slug}}

+
+
+ +{{if .Form.Error}} +
+

{{.Form.Error}}

+
+{{end}} + +
+
+
+

Project Details

+

Edit the stable metadata used by the admin UI and future client endpoints.

+
+ +
+ {{template "csrf_field" .}} + + + + + + + +
+
+ +
+
+

Lifecycle

+

Archive projects to hide them operationally without deleting their data.

+
+ +

Created: {{.Project.CreatedAt.Format "2006-01-02 15:04 UTC"}}

+

Updated: {{.Project.UpdatedAt.Format "2006-01-02 15:04 UTC"}}

+ +
+ {{template "csrf_field" .}} + + +
+
+
+ +
+
+
+

Project Tags

+

Tags are available from day one so projects can be grouped immediately.

+
+ + {{if .Tags}} +
+ {{range .Tags}} +
+ {{template "csrf_field" $}} + {{.Name}} + +
+ {{end}} +
+ {{else}} +

No tags attached yet.

+ {{end}} + + {{if .AvailableTags}} +
+ {{template "csrf_field" .}} + + +
+ {{else}} +

All existing tags are already attached. Create another tag if you need a new grouping.

+ {{end}} +
+ +
+
+

Release Upload

+

Uploads are stored outside the public web root and each release records its checksum, size, and storage path.

+
+ + {{if .Upload.Error}} +
+

{{.Upload.Error}}

+
+ {{end}} + +
+ {{template "csrf_field" .}} + + + + + + + + +

Maximum upload size: {{.Upload.MaxUploadMB}} MB.

+ +
+
+
+ +
+
+

Release History

+

Each release row keeps the sanitized filename, checksum, file size, content type, and private storage path for later download endpoints.

+
+ + {{if .Releases}} +
+ {{range .Releases}} +
+
+

{{.Release.Version}}{{if .Release.Build}} {{.Release.Build}}{{end}}

+ {{.Release.ContentType}} +
+

File: {{.Release.Filename}}

+

Checksum: {{.Release.ChecksumSHA256}}

+

Storage path: {{.Release.StoragePath}}

+

Size: {{.Release.SizeBytes}} bytes

+

Uploaded by: {{if .UploadedByEmail}}{{.UploadedByEmail}}{{else}}Unknown{{end}}

+

Created: {{.Release.CreatedAt.Format "2006-01-02 15:04 UTC"}}

+ {{if .Release.ReleaseNotes}} +

{{.Release.ReleaseNotes}}

+ {{end}} +
+ {{end}} +
+ {{else}} +

No releases uploaded for this project yet.

+ {{end}} +
+ +
+
+

Client API

+

Use these bearer-authenticated endpoints from client applications after you create an API key with download access.

+
+ + {{if .Project.IsActive}} +
+

Project listing: {{.BaseURL}}/api/v1/projects

+

Latest release metadata: {{printf "%s/api/v1/projects/%s/releases/latest" .BaseURL .Project.Slug}}

+ {{if .LatestRelease}} +

Release metadata: {{printf "%s/api/v1/releases/%d" .BaseURL .LatestRelease.ID}}

+

Artifact download: {{printf "%s/api/v1/releases/%d/download" .BaseURL .LatestRelease.ID}}

+

Current latest: Version {{.LatestRelease.Version}}{{if .LatestRelease.Build}} (build {{.LatestRelease.Build}}){{end}}, release ID {{.LatestRelease.ID}}.

+ {{else}} +

Upload the first release to activate the metadata and download endpoints for this project.

+ {{end}} +
+ {{else}} +

This project is archived, so client API lookups and downloads will hide it until you restore the project.

+ {{end}} +
+{{end}} diff --git a/web/templates/pages/project_form.gohtml b/web/templates/pages/project_form.gohtml new file mode 100644 index 0000000..0ef72c7 --- /dev/null +++ b/web/templates/pages/project_form.gohtml @@ -0,0 +1,38 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ +{{if .Form.Error}} +
+

{{.Form.Error}}

+
+{{end}} + +
+
+ {{template "csrf_field" .}} + + + + + + +
+ + Back to projects +
+
+
+{{end}} diff --git a/web/templates/pages/projects.gohtml b/web/templates/pages/projects.gohtml new file mode 100644 index 0000000..2344a68 --- /dev/null +++ b/web/templates/pages/projects.gohtml @@ -0,0 +1,44 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ +
+ Create project +
+ +{{if .Projects}} +
+ {{range .Projects}} +
+
+
+

{{.Project.Name}}

+

{{.Project.Slug}}

+
+ + {{if .Project.IsActive}}Active{{else}}Archived{{end}} + +
+ +

{{if .Project.Description}}{{.Project.Description}}{{else}}No description yet.{{end}}

+ +
+ {{.TagCount}} tags + {{.ReleaseCount}} releases + Updated {{.Project.UpdatedAt.Format "2006-01-02 15:04 UTC"}} +
+ + Open project +
+ {{end}} +
+{{else}} +
+

No projects yet

+

Create the first project to start grouping releases and tags.

+
+{{end}} +{{end}} diff --git a/web/templates/pages/tag_form.gohtml b/web/templates/pages/tag_form.gohtml new file mode 100644 index 0000000..60cbf80 --- /dev/null +++ b/web/templates/pages/tag_form.gohtml @@ -0,0 +1,93 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ +{{if .Form.Error}} +
+

{{.Form.Error}}

+
+{{end}} + +
+
+
+

Tag Details

+

Use stable tag slugs so projects and future access rules can reference them predictably.

+
+ +
+ {{template "csrf_field" .}} + + + + + + +
+ + Back to tags +
+
+
+ + {{if .Tag}} +
+
+

Delete Tag

+

Tags can only be deleted once they are no longer attached to any project.

+
+ + {{if .Form.CanDelete}} +
+ {{template "csrf_field" .}} + +
+ {{else}} +

Detach this tag from all projects before deleting it.

+ {{end}} +
+ {{end}} +
+ +{{if .Tag}} +
+
+

Assigned Projects

+

These projects currently use this tag.

+
+ + {{if .Projects}} +
+ {{range .Projects}} +
+
+
+

{{.Name}}

+

{{.Slug}}

+
+ + {{if .IsActive}}Active{{else}}Archived{{end}} + +
+

{{if .Description}}{{.Description}}{{else}}No description yet.{{end}}

+
+ {{end}} +
+ {{else}} +

This tag is not attached to any project yet.

+ {{end}} +
+{{end}} +{{end}} diff --git a/web/templates/pages/tags.gohtml b/web/templates/pages/tags.gohtml new file mode 100644 index 0000000..f4f9168 --- /dev/null +++ b/web/templates/pages/tags.gohtml @@ -0,0 +1,36 @@ +{{define "content"}} +
+

{{.Eyebrow}}

+

{{.Heading}}

+

{{.Description}}

+
+ +
+ Create tag +
+ +{{if .Tags}} +
+ {{range .Tags}} +
+
+
+

{{.Tag.Name}}

+

{{.Tag.Slug}}

+
+ {{.ProjectCount}} projects +
+ +

{{if .Tag.Description}}{{.Tag.Description}}{{else}}No description yet.{{end}}

+

Updated: {{.Tag.UpdatedAt.Format "2006-01-02 15:04 UTC"}}

+ Open tag +
+ {{end}} +
+{{else}} +
+

No tags yet

+

Create the first tag so projects can be grouped from day one.

+
+{{end}} +{{end}} diff --git a/web/templates/partials/csrf_field.gohtml b/web/templates/partials/csrf_field.gohtml new file mode 100644 index 0000000..ee52fff --- /dev/null +++ b/web/templates/partials/csrf_field.gohtml @@ -0,0 +1,3 @@ +{{define "csrf_field"}} +{{if .CSRFToken}}{{end}} +{{end}}