This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
.bin/
data-dev/
docs/agents/
*.cookies
*.sqlite
*.sqlite-shm
*.sqlite-wal

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.bin/
data-dev/

74
DEVELOPMENT_WORKFLOW.md Normal file
View file

@ -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.

62
Dockerfile Normal file
View file

@ -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"]

495
IMPLEMENTATION_PLAN.md Normal file
View file

@ -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.

25
Justfile Normal file
View file

@ -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

486
PRODUCT_SPEC.md Normal file
View file

@ -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 <api_key>`
- or `X-API-Key: <api_key>`
Recommendation:
- use `Authorization: Bearer <api_key>` 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.

34
cmd/migrate/main.go Normal file
View file

@ -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)
}

36
cmd/server/main.go Normal file
View file

@ -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)
}

23
deploy/Caddyfile.example Normal file
View file

@ -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}
}
}

View file

@ -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;
}
}

View file

@ -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

45
docker-compose.yml Normal file
View file

@ -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:

160
docs/DEPLOYMENT.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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 <AGENT NAME>.
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 "<AGENT NAME>" 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 "<AGENT NAME>" 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 <AGENT NAME> 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` переходишь к следующему агенту.

View file

@ -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`

543
docs/agents/WORKFLOW.md Normal file
View file

@ -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>`
- 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.
Это защитит тебя от хаоса, повторной работы и скрытых поломок между этапами.

View file

@ -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.

View file

@ -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 keys 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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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 <api_key>` 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 keys 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.

View file

@ -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.

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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`

Binary file not shown.

Binary file not shown.

192
example/autoupdate.py Normal file
View file

@ -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:]])

12
example/update_client.py Normal file
View file

@ -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")

9
go.mod Normal file
View file

@ -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
)

6
go.sum Normal file
View file

@ -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=

View file

@ -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
}

407
internal/apikeys/service.go Normal file
View file

@ -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 }

View file

@ -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)
}
}

142
internal/app/app.go Normal file
View file

@ -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
}

16
internal/auth/context.go Normal file
View file

@ -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
}

35
internal/auth/password.go Normal file
View file

@ -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))
}

259
internal/auth/service.go Normal file
View file

@ -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[:])
}

View file

@ -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...)
}

347
internal/config/config.go Normal file
View file

@ -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) != ".."
}

761
internal/db/apikeys.go Normal file
View file

@ -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
}

19
internal/db/errors.go Normal file
View file

@ -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")
}

146
internal/db/migrate.go Normal file
View file

@ -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[:])
}

294
internal/db/migrate_test.go Normal file
View file

@ -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)
}
}

177
internal/db/models.go Normal file
View file

@ -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
}

60
internal/db/open.go Normal file
View file

@ -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
}

326
internal/db/projects.go Normal file
View file

@ -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
}

285
internal/db/releases.go Normal file
View file

@ -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
}

284
internal/db/sessions.go Normal file
View file

@ -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
}

131
internal/db/store.go Normal file
View file

@ -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},
}
}

314
internal/db/tags.go Normal file
View file

@ -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
}

38
internal/db/time.go Normal file
View file

@ -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
}

165
internal/db/users.go Normal file
View file

@ -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
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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
}
}

274
internal/http/admin_tags.go Normal file
View file

@ -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
}
}

View file

@ -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})
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
})
}
}

247
internal/http/client_api.go Normal file
View file

@ -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),
}
}

View file

@ -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)
}
}

156
internal/http/csrf.go Normal file
View file

@ -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
}

149
internal/http/handlers.go Normal file
View file

@ -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 <api_key>",
},
"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)
}
}

View file

@ -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(),
)
})
}
}

View file

@ -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
}

139
internal/http/rate_limit.go Normal file
View file

@ -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)
})
}
}

78
internal/http/render.go Normal file
View file

@ -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
}

15
internal/http/response.go Normal file
View file

@ -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)
}
}

113
internal/http/router.go Normal file
View file

@ -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
}

80
internal/http/security.go Normal file
View file

@ -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)
}

View file

@ -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")
}

165
internal/http/view_data.go Normal file
View file

@ -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
}

View file

@ -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"
}

27
internal/slug/slug.go Normal file
View file

@ -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(), "-")
}

127
internal/storage/local.go Normal file
View file

@ -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
}

BIN
migrate Executable file

Binary file not shown.

View file

@ -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'))
);

View file

@ -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;

View file

@ -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;

5
migrations/README.md Normal file
View file

@ -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.

BIN
server Executable file

Binary file not shown.

584
web/static/app.css Normal file
View file

@ -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;
}
}

View file

@ -0,0 +1,46 @@
{{define "base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} | {{end}}Update Server</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="page-shell">
<header class="site-header">
<a class="brand" href="/">Update Server</a>
<nav class="site-nav">
<a href="/">Home</a>
<a href="/admin">Dashboard</a>
{{if .CurrentUser}}
<a href="/admin/projects">Projects</a>
<a href="/admin/tags">Tags</a>
<a href="/admin/api-keys">API Keys</a>
{{else}}
<a href="/admin/login">Login</a>
{{end}}
<a href="/api/v1">API</a>
<a href="/healthz">Health</a>
{{if .CurrentUser}}
<span class="nav-user">{{.CurrentUser.Email}}</span>
<form class="nav-form" method="post" action="/admin/logout">
{{template "csrf_field" .}}
<button class="nav-button" type="submit">Log out</button>
</form>
{{end}}
</nav>
</header>
<main class="content-card">
{{if .Flash}}
<section class="alert-card {{if eq .Flash.Kind "error"}}alert-error{{else}}alert-success{{end}}">
<p>{{.Flash.Message}}</p>
</section>
{{end}}
{{template "content" .}}
</main>
</div>
</body>
</html>
{{end}}

View file

@ -0,0 +1,39 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
<section class="metric-grid">
{{range .Metrics}}
<article class="metric-card">
<p class="metric-value">{{.Value}}</p>
<h2>{{.Label}}</h2>
<p>{{.Description}}</p>
</article>
{{end}}
</section>
<section class="admin-grid">
<article class="callout">
<h2>Authenticated User</h2>
<p class="meta-line"><strong>Email:</strong> {{if .CurrentUser}}{{.CurrentUser.Email}}{{else}}Unavailable{{end}}</p>
<p class="meta-line"><strong>Role:</strong> {{if .CurrentUser}}{{.CurrentUser.Role}}{{else}}Unknown{{end}}</p>
</article>
<article class="callout">
<h2>Session And Storage</h2>
<p>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.</p>
</article>
</section>
<section class="link-grid">
{{range .Links}}
<article class="link-card">
<h2><a href="{{.Href}}">{{.Label}}</a></h2>
<p>{{.Description}}</p>
</article>
{{end}}
</section>
{{end}}

View file

@ -0,0 +1,200 @@
{{define "content"}}
<section class="hero hero-split">
<div>
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</div>
{{if .APIKey}}
<div class="hero-meta">
<span class="pill {{if .APIKey.IsActive}}pill-success{{else}}pill-muted{{end}}">
{{if .APIKey.IsActive}}Active{{else}}Revoked{{end}}
</span>
<p class="resource-slug">{{.APIKey.KeyPrefix}}</p>
</div>
{{end}}
</section>
{{if .Form.RevealKey}}
<section class="alert-card alert-success">
<p><strong>Copy this API key now:</strong> <code class="secret-code">{{.Form.RevealKey}}</code></p>
<p>This is the only time the full raw key will be shown. Only the hash and short prefix are stored.</p>
</section>
{{end}}
{{if .Form.Error}}
<section class="alert-card alert-error">
<p>{{.Form.Error}}</p>
</section>
{{end}}
<section class="section-grid">
<article class="section-card">
<div class="section-heading">
<h2>Key Details</h2>
<p>Choose a stable label, minimum permissions, and one scope mode for this key.</p>
</div>
<form class="stack-form" method="post" action="{{.Form.Action}}">
{{template "csrf_field" .}}
<label class="field">
<span>Name</span>
<input type="text" name="name" value="{{.Form.Name}}" maxlength="120" required>
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="4">{{.Form.Description}}</textarea>
</label>
<label class="field">
<span>Scope mode</span>
<select name="scope_mode" required>
<option value="all_projects" {{if eq .Form.ScopeMode "all_projects"}}selected{{end}}>all_projects</option>
<option value="project_allow_list" {{if eq .Form.ScopeMode "project_allow_list"}}selected{{end}}>project_allow_list</option>
<option value="project_deny_list" {{if eq .Form.ScopeMode "project_deny_list"}}selected{{end}}>project_deny_list</option>
<option value="tag_allow_list" {{if eq .Form.ScopeMode "tag_allow_list"}}selected{{end}}>tag_allow_list</option>
<option value="tag_deny_list" {{if eq .Form.ScopeMode "tag_deny_list"}}selected{{end}}>tag_deny_list</option>
</select>
</label>
<label class="field">
<span>Expires at</span>
<input type="text" name="expires_at" value="{{.Form.ExpiresAt}}" placeholder="2026-12-31T23:59:59Z or 2026-12-31">
</label>
<fieldset class="choice-group">
<legend>Permissions</legend>
<label class="choice-row">
<input type="checkbox" name="can_download" value="1" {{if .Form.CanDownload}}checked{{end}}>
<span><strong>updates.read</strong> style access for listing or downloading releases.</span>
</label>
<label class="choice-row">
<input type="checkbox" name="can_upload" value="1" {{if .Form.CanUpload}}checked{{end}}>
<span>Upload release artifacts.</span>
</label>
<label class="choice-row">
<input type="checkbox" name="can_delete" value="1" {{if .Form.CanDelete}}checked{{end}}>
<span>Delete or disable release records later.</span>
</label>
<label class="choice-row">
<input type="checkbox" name="can_manage_projects" value="1" {{if .Form.CanManageProjects}}checked{{end}}>
<span>Create or edit projects.</span>
</label>
</fieldset>
<fieldset class="choice-group">
<legend>Project rules</legend>
<p class="helper-copy">Used only for <code>project_allow_list</code> and <code>project_deny_list</code>.</p>
{{if .ProjectChoices}}
<div class="choice-list">
{{range .ProjectChoices}}
<label class="choice-row">
<input type="checkbox" name="project_id" value="{{.Project.ID}}" {{if .Selected}}checked{{end}}>
<span>{{.Project.Name}} <code>{{.Project.Slug}}</code> {{if not .Project.IsActive}}(archived){{end}}</span>
</label>
{{end}}
</div>
{{else}}
<p class="empty-copy">No projects exist yet.</p>
{{end}}
</fieldset>
<fieldset class="choice-group">
<legend>Tag rules</legend>
<p class="helper-copy">Used only for <code>tag_allow_list</code> and <code>tag_deny_list</code>.</p>
{{if .TagChoices}}
<div class="choice-list">
{{range .TagChoices}}
<label class="choice-row">
<input type="checkbox" name="tag_id" value="{{.Tag.ID}}" {{if .Selected}}checked{{end}}>
<span>{{.Tag.Name}} <code>{{.Tag.Slug}}</code></span>
</label>
{{end}}
</div>
{{else}}
<p class="empty-copy">No tags exist yet.</p>
{{end}}
</fieldset>
<div class="button-row">
<button class="button" type="submit">{{.Form.SubmitLabel}}</button>
<a class="button button-secondary" href="/admin/api-keys">Back to API keys</a>
</div>
</form>
</article>
{{if .APIKey}}
<article class="section-card">
<div class="section-heading">
<h2>Lifecycle</h2>
<p>Disabled or expired keys are rejected before any project scope checks happen.</p>
</div>
<p class="meta-line"><strong>Created:</strong> {{.APIKey.CreatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<p class="meta-line"><strong>Updated:</strong> {{.APIKey.UpdatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<p class="meta-line"><strong>Last used:</strong> {{if .APIKey.LastUsedAt}}{{.APIKey.LastUsedAt.Format "2006-01-02 15:04 UTC"}}{{else}}Never{{end}}</p>
<p class="meta-line"><strong>Expires:</strong> {{if .APIKey.ExpiresAt}}{{.APIKey.ExpiresAt.Format "2006-01-02 15:04 UTC"}}{{else}}No expiry{{end}}</p>
<form class="stack-form" method="post" action="{{.ToggleAction}}">
{{template "csrf_field" .}}
<input type="hidden" name="state" value="{{.ToggleState}}">
<button class="button button-secondary" type="submit">{{.ToggleLabel}}</button>
</form>
</article>
{{end}}
</section>
{{if .APIKey}}
<section class="section-card section-card-wide">
<div class="section-heading">
<h2>Effective Access Preview</h2>
<p>This preview shows the active projects currently reachable after scope mode evaluation.</p>
</div>
{{if .AccessibleProjects}}
<div class="resource-grid compact-grid">
{{range .AccessibleProjects}}
<article class="resource-card">
<div class="resource-header">
<div>
<h3>{{.Name}}</h3>
<p class="resource-slug">{{.Slug}}</p>
</div>
<span class="pill pill-success">Active</span>
</div>
<p class="resource-description">{{if .Description}}{{.Description}}{{else}}No description yet.{{end}}</p>
</article>
{{end}}
</div>
{{else}}
<p class="empty-copy">No active projects currently match this key.</p>
{{end}}
</section>
<section class="section-card section-card-wide">
<div class="section-heading">
<h2>Client API Quick Start</h2>
<p>Clients should authenticate with a bearer API key and then discover projects before requesting metadata or downloading an artifact.</p>
</div>
<div class="endpoint-list">
<p class="meta-line"><strong>Bearer header:</strong> <code>Authorization: Bearer {{if .Form.RevealKey}}{{.Form.RevealKey}}{{else}}&lt;paste-api-key&gt;{{end}}</code></p>
<p class="meta-line"><strong>Accessible projects:</strong> <code>{{.BaseURL}}/api/v1/projects</code></p>
</div>
<pre class="code-block"><code>curl -H "Authorization: Bearer {{if .Form.RevealKey}}{{.Form.RevealKey}}{{else}}&lt;paste-api-key&gt;{{end}}" \
{{.BaseURL}}/api/v1/projects</code></pre>
{{if .AccessibleProjects}}
<div class="endpoint-list">
{{range .AccessibleProjects}}
<p class="meta-line"><strong>{{.Name}} latest metadata:</strong> <code>{{printf "%s/api/v1/projects/%s/releases/latest" $.BaseURL .Slug}}</code></p>
{{end}}
</div>
{{else}}
<p class="empty-copy">This key does not currently expose any active projects, so client metadata and download lookups will return no accessible resources.</p>
{{end}}
</section>
{{end}}
{{end}}

View file

@ -0,0 +1,49 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
<section class="toolbar">
<a class="button" href="/admin/api-keys/new">Create API key</a>
</section>
{{if .APIKeys}}
<section class="resource-grid">
{{range .APIKeys}}
<article class="resource-card">
<div class="resource-header">
<div>
<h2><a href="/admin/api-keys/{{.APIKey.ID}}">{{.APIKey.Name}}</a></h2>
<p class="resource-slug">{{.APIKey.KeyPrefix}}</p>
</div>
<span class="pill {{if .APIKey.IsActive}}pill-success{{else}}pill-muted{{end}}">
{{if .APIKey.IsActive}}Active{{else}}Revoked{{end}}
</span>
</div>
<p class="resource-description">{{if .APIKey.Description}}{{.APIKey.Description}}{{else}}No description yet.{{end}}</p>
<p class="meta-line"><strong>Scope:</strong> <code>{{.APIKey.ScopeMode}}</code></p>
<p class="meta-line"><strong>Rule rows:</strong> {{.ProjectRuleCount}} project, {{.TagRuleCount}} tag</p>
<p class="meta-line"><strong>Permissions:</strong>
{{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}}
</p>
<p class="meta-line"><strong>Updated:</strong> {{.APIKey.UpdatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<p class="meta-line"><strong>Last used:</strong> {{if .APIKey.LastUsedAt}}{{.APIKey.LastUsedAt.Format "2006-01-02 15:04 UTC"}}{{else}}Never{{end}}</p>
<p class="meta-line"><strong>Expires:</strong> {{if .APIKey.ExpiresAt}}{{.APIKey.ExpiresAt.Format "2006-01-02 15:04 UTC"}}{{else}}No expiry{{end}}</p>
<a class="text-link" href="/admin/api-keys/{{.APIKey.ID}}">Open API key</a>
</article>
{{end}}
</section>
{{else}}
<section class="empty-state">
<h2>No API keys yet</h2>
<p>Create the first key to let client applications authenticate against the project, metadata, and download endpoints.</p>
</section>
{{end}}
{{end}}

Some files were not shown because too many files have changed in this diff Show more