init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
.bin/
|
||||||
|
data-dev/
|
||||||
|
docs/agents/
|
||||||
|
*.cookies
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
.bin/
|
||||||
|
data-dev/
|
||||||
74
DEVELOPMENT_WORKFLOW.md
Normal file
74
DEVELOPMENT_WORKFLOW.md
Normal 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
62
Dockerfile
Normal 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
495
IMPLEMENTATION_PLAN.md
Normal 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
25
Justfile
Normal 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
486
PRODUCT_SPEC.md
Normal 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
34
cmd/migrate/main.go
Normal 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
36
cmd/server/main.go
Normal 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
23
deploy/Caddyfile.example
Normal 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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
deploy/nginx.update-server.conf.example
Normal file
42
deploy/nginx.update-server.conf.example
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17
deploy/update-server.env.example
Normal file
17
deploy/update-server.env.example
Normal 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
45
docker-compose.yml
Normal 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
160
docs/DEPLOYMENT.md
Normal 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.
|
||||||
41
docs/agents/HANDOFF_TEMPLATE.md
Normal file
41
docs/agents/HANDOFF_TEMPLATE.md
Normal 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.
|
||||||
174
docs/agents/OPERATOR_QUICKSTART_RU.md
Normal file
174
docs/agents/OPERATOR_QUICKSTART_RU.md
Normal 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` переходишь к следующему агенту.
|
||||||
29
docs/agents/VALIDATION_TEMPLATE.md
Normal file
29
docs/agents/VALIDATION_TEMPLATE.md
Normal 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
543
docs/agents/WORKFLOW.md
Normal 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.
|
||||||
|
|
||||||
|
Это защитит тебя от хаоса, повторной работы и скрытых поломок между этапами.
|
||||||
79
docs/agents/handoffs/01-foundation.md
Normal file
79
docs/agents/handoffs/01-foundation.md
Normal 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.
|
||||||
105
docs/agents/handoffs/02-database.md
Normal file
105
docs/agents/handoffs/02-database.md
Normal 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 key’s selected `scope_mode`;
|
||||||
|
- wired database open + migrate into app startup without changing the existing route-group layout;
|
||||||
|
- added a standalone `cmd/migrate` entrypoint and `just migrate`;
|
||||||
|
- added migration tests and refreshed the health endpoint to report SQLite readiness.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `/Users/delete/projects/update_server/go.mod`
|
||||||
|
- `/Users/delete/projects/update_server/go.sum`
|
||||||
|
- `/Users/delete/projects/update_server/Justfile`
|
||||||
|
- `/Users/delete/projects/update_server/cmd/migrate/main.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/app/app.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/config/config.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/db/models.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/db/open.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/db/migrate.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/db/store.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/db/migrate_test.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/http/router.go`
|
||||||
|
- `/Users/delete/projects/update_server/internal/http/handlers.go`
|
||||||
|
- `/Users/delete/projects/update_server/migrations/README.md`
|
||||||
|
- `/Users/delete/projects/update_server/migrations/0001_initial_schema.sql`
|
||||||
|
- `/Users/delete/projects/update_server/migrations/0002_indexes_and_triggers.sql`
|
||||||
|
- `/Users/delete/projects/update_server/migrations/0003_api_key_scope_guards.sql`
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
Added migrations:
|
||||||
|
|
||||||
|
- `0001_initial_schema.sql`
|
||||||
|
- `0002_indexes_and_triggers.sql`
|
||||||
|
- `0003_api_key_scope_guards.sql`
|
||||||
|
|
||||||
|
Schema notes:
|
||||||
|
|
||||||
|
- timestamps are stored as UTC RFC3339-like text values;
|
||||||
|
- `api_keys.scope_mode` is constrained to the agreed modes:
|
||||||
|
- `all_projects`
|
||||||
|
- `project_allow_list`
|
||||||
|
- `project_deny_list`
|
||||||
|
- `tag_allow_list`
|
||||||
|
- `tag_deny_list`
|
||||||
|
- `api_key_project_access` only accepts keys in project-based modes;
|
||||||
|
- `api_key_tag_access` only accepts keys in tag-based modes;
|
||||||
|
- changing an API key to an incompatible scope mode is blocked if incompatible access rows already exist;
|
||||||
|
- `sessions` stores hashed session tokens, not raw tokens;
|
||||||
|
- `audit_logs` storage is included, but no runtime writes are wired yet.
|
||||||
|
|
||||||
|
## API Or Route Changes
|
||||||
|
|
||||||
|
- no new route groups or business endpoints were added;
|
||||||
|
- `GET /healthz` now includes SQLite readiness and returns `503` if the store is unavailable;
|
||||||
|
- updated the placeholder `/` and `/api/v1` responses to reflect database readiness.
|
||||||
|
|
||||||
|
## Commands And Tests Run
|
||||||
|
|
||||||
|
- `gofmt -w ./cmd ./internal` - completed successfully;
|
||||||
|
- `go mod tidy` - initially failed in the sandbox due Go cache/network restrictions; completed successfully after rerunning with normal access and temporary Go caches;
|
||||||
|
- `GOCACHE=/tmp/go-build-sqlite3 GOMODCACHE=/tmp/go-mod-sqlite3 go test ./...` - passed;
|
||||||
|
- `GOCACHE=/tmp/go-build-sqlite3 GOMODCACHE=/tmp/go-mod-sqlite3 go build -o /tmp/update-server ./cmd/server` - passed;
|
||||||
|
- `GOCACHE=/tmp/go-build-sqlite3 GOMODCACHE=/tmp/go-mod-sqlite3 go build -o /tmp/update-migrate ./cmd/migrate` - passed;
|
||||||
|
- `APP_BASE_URL=http://127.0.0.1:18080 DATA_DIR=/tmp/update-server-db-dev-2 /tmp/update-migrate` - applied migrations successfully to a fresh temp SQLite database;
|
||||||
|
- `sqlite3 /tmp/update-server-db-dev-2/db.sqlite 'SELECT name FROM schema_migrations ORDER BY name;'` - confirmed all three migrations were recorded;
|
||||||
|
- started `/tmp/update-server` on `127.0.0.1:18080` and verified:
|
||||||
|
- `GET /` -> `200 OK`
|
||||||
|
- `GET /api/v1` -> `200 OK`
|
||||||
|
- `GET /healthz` -> `200 OK` with `"database":"ok"`
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- repository structs are intentionally scaffolding-only in this stage; feature-specific query methods are still for later agents to add;
|
||||||
|
- no auth/session business logic is implemented yet beyond the schema and store foundation;
|
||||||
|
- `audit_logs` exists in schema only; event production is deferred;
|
||||||
|
- scope-mode enforcement now covers schema consistency for access-link rows, but full project/tag access evaluation logic is still for the API key stage;
|
||||||
|
- existing local databases created before the final scope-guard addition need the new `0003_api_key_scope_guards.sql` migration applied, which the updated startup path now does automatically.
|
||||||
|
|
||||||
|
## Recommended Next Step
|
||||||
|
|
||||||
|
Agent 03 should implement admin authentication next using the new `users` and `sessions` tables: bootstrap the first admin user from environment, hash passwords, create/invalidate session records, and protect the `/admin` route group with session middleware.
|
||||||
|
|
||||||
|
## Notes For Validator
|
||||||
|
|
||||||
|
- pay extra attention to migration immutability: `schema_migrations` stores a checksum and should reject edited applied files;
|
||||||
|
- validate that the default server startup path now opens SQLite and auto-applies `0001` -> `0003`;
|
||||||
|
- validate that project/tag access link rows are rejected when the parent API key uses the wrong `scope_mode`;
|
||||||
|
- the workspace is not a git repository, so diff-based review may need to rely on direct file inspection.
|
||||||
92
docs/agents/handoffs/03-auth.md
Normal file
92
docs/agents/handoffs/03-auth.md
Normal 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.
|
||||||
105
docs/agents/handoffs/04-projects-releases.md
Normal file
105
docs/agents/handoffs/04-projects-releases.md
Normal 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.
|
||||||
63
docs/agents/handoffs/05-api-keys-fix.md
Normal file
63
docs/agents/handoffs/05-api-keys-fix.md
Normal 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.
|
||||||
90
docs/agents/handoffs/05-api-keys.md
Normal file
90
docs/agents/handoffs/05-api-keys.md
Normal 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.
|
||||||
100
docs/agents/handoffs/06-client-api-ui.md
Normal file
100
docs/agents/handoffs/06-client-api-ui.md
Normal 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 key’s scope evaluation;
|
||||||
|
- verify that the latest-release endpoint selects the newest active persisted release row for the project;
|
||||||
|
- verify that the download endpoint streams bytes from private artifact storage and does not expose static URLs or raw filesystem paths;
|
||||||
|
- verify that the project detail and API key detail admin pages visibly expose the new client API quick-start information.
|
||||||
134
docs/agents/handoffs/07-security-deploy.md
Normal file
134
docs/agents/handoffs/07-security-deploy.md
Normal 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.
|
||||||
13
docs/agents/handoffs/README.md
Normal file
13
docs/agents/handoffs/README.md
Normal 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`
|
||||||
45
docs/agents/validation/01-foundation-validation.md
Normal file
45
docs/agents/validation/01-foundation-validation.md
Normal 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`
|
||||||
54
docs/agents/validation/02-database-validation.md
Normal file
54
docs/agents/validation/02-database-validation.md
Normal 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`
|
||||||
57
docs/agents/validation/03-auth-validation.md
Normal file
57
docs/agents/validation/03-auth-validation.md
Normal 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`
|
||||||
62
docs/agents/validation/04-projects-releases-validation.md
Normal file
62
docs/agents/validation/04-projects-releases-validation.md
Normal 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`
|
||||||
64
docs/agents/validation/05-api-keys-validation.md
Normal file
64
docs/agents/validation/05-api-keys-validation.md
Normal 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`
|
||||||
77
docs/agents/validation/06-client-api-ui-validation.md
Normal file
77
docs/agents/validation/06-client-api-ui-validation.md
Normal 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`
|
||||||
88
docs/agents/validation/07-security-deploy-validation.md
Normal file
88
docs/agents/validation/07-security-deploy-validation.md
Normal 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`
|
||||||
13
docs/agents/validation/README.md
Normal file
13
docs/agents/validation/README.md
Normal 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`
|
||||||
BIN
example/__pycache__/autoupdate.cpython-314.pyc
Normal file
BIN
example/__pycache__/autoupdate.cpython-314.pyc
Normal file
Binary file not shown.
BIN
example/__pycache__/update_client.cpython-314.pyc
Normal file
BIN
example/__pycache__/update_client.cpython-314.pyc
Normal file
Binary file not shown.
192
example/autoupdate.py
Normal file
192
example/autoupdate.py
Normal 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
12
example/update_client.py
Normal 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
9
go.mod
Normal 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
6
go.sum
Normal 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=
|
||||||
32
internal/apikeys/context.go
Normal file
32
internal/apikeys/context.go
Normal 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
407
internal/apikeys/service.go
Normal 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 }
|
||||||
316
internal/apikeys/service_test.go
Normal file
316
internal/apikeys/service_test.go
Normal 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
142
internal/app/app.go
Normal 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
16
internal/auth/context.go
Normal 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
35
internal/auth/password.go
Normal 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
259
internal/auth/service.go
Normal 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[:])
|
||||||
|
}
|
||||||
101
internal/auth/service_test.go
Normal file
101
internal/auth/service_test.go
Normal 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
347
internal/config/config.go
Normal 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
761
internal/db/apikeys.go
Normal 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
19
internal/db/errors.go
Normal 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
146
internal/db/migrate.go
Normal 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
294
internal/db/migrate_test.go
Normal 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
177
internal/db/models.go
Normal 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
60
internal/db/open.go
Normal 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
326
internal/db/projects.go
Normal 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
285
internal/db/releases.go
Normal 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
284
internal/db/sessions.go
Normal 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
131
internal/db/store.go
Normal 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
314
internal/db/tags.go
Normal 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
38
internal/db/time.go
Normal 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
165
internal/db/users.go
Normal 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
|
||||||
|
}
|
||||||
513
internal/http/admin_api_keys.go
Normal file
513
internal/http/admin_api_keys.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
135
internal/http/admin_common.go
Normal file
135
internal/http/admin_common.go
Normal 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)
|
||||||
|
}
|
||||||
485
internal/http/admin_projects.go
Normal file
485
internal/http/admin_projects.go
Normal 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
274
internal/http/admin_tags.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/http/api_key_middleware.go
Normal file
111
internal/http/api_key_middleware.go
Normal 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})
|
||||||
|
}
|
||||||
162
internal/http/api_key_middleware_test.go
Normal file
162
internal/http/api_key_middleware_test.go
Normal 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)
|
||||||
|
}
|
||||||
213
internal/http/api_keys_integration_test.go
Normal file
213
internal/http/api_keys_integration_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
154
internal/http/auth_handlers.go
Normal file
154
internal/http/auth_handlers.go
Normal 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
|
||||||
|
}
|
||||||
269
internal/http/auth_integration_test.go
Normal file
269
internal/http/auth_integration_test.go
Normal 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
|
||||||
|
}
|
||||||
52
internal/http/auth_middleware.go
Normal file
52
internal/http/auth_middleware.go
Normal 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
247
internal/http/client_api.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
290
internal/http/client_api_integration_test.go
Normal file
290
internal/http/client_api_integration_test.go
Normal 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
156
internal/http/csrf.go
Normal 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
149
internal/http/handlers.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/http/middleware.go
Normal file
29
internal/http/middleware.go
Normal 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(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
323
internal/http/projects_integration_test.go
Normal file
323
internal/http/projects_integration_test.go
Normal 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
139
internal/http/rate_limit.go
Normal 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
78
internal/http/render.go
Normal 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
15
internal/http/response.go
Normal 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
113
internal/http/router.go
Normal 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
80
internal/http/security.go
Normal 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)
|
||||||
|
}
|
||||||
194
internal/http/security_integration_test.go
Normal file
194
internal/http/security_integration_test.go
Normal 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
165
internal/http/view_data.go
Normal 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
|
||||||
|
}
|
||||||
270
internal/releases/service.go
Normal file
270
internal/releases/service.go
Normal 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
27
internal/slug/slug.go
Normal 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
127
internal/storage/local.go
Normal 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
BIN
migrate
Executable file
Binary file not shown.
112
migrations/0001_initial_schema.sql
Normal file
112
migrations/0001_initial_schema.sql
Normal 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'))
|
||||||
|
);
|
||||||
65
migrations/0002_indexes_and_triggers.sql
Normal file
65
migrations/0002_indexes_and_triggers.sql
Normal 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;
|
||||||
52
migrations/0003_api_key_scope_guards.sql
Normal file
52
migrations/0003_api_key_scope_guards.sql
Normal 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
5
migrations/README.md
Normal 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
BIN
server
Executable file
Binary file not shown.
584
web/static/app.css
Normal file
584
web/static/app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
web/templates/layouts/base.gohtml
Normal file
46
web/templates/layouts/base.gohtml
Normal 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}}
|
||||||
39
web/templates/pages/admin.gohtml
Normal file
39
web/templates/pages/admin.gohtml
Normal 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}}
|
||||||
200
web/templates/pages/api_key_form.gohtml
Normal file
200
web/templates/pages/api_key_form.gohtml
Normal 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}}<paste-api-key>{{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}}<paste-api-key>{{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}}
|
||||||
49
web/templates/pages/api_keys.gohtml
Normal file
49
web/templates/pages/api_keys.gohtml
Normal 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
Loading…
Add table
Add a link
Reference in a new issue