Harden CI and switch release builds to tagged immutable images
All checks were successful
CI / Backend (push) Successful in 29s
CI / Frontend (push) Successful in 9m27s
CI / Contracts and repository policy (push) Successful in 7s
CI / Container (push) Successful in 18s

This commit is contained in:
Elijah 2026-07-15 19:21:07 -07:00
parent bed2e6cfb6
commit f24e96efa7
60 changed files with 4710 additions and 64 deletions

19
.dockerignore Normal file
View file

@ -0,0 +1,19 @@
.git
.forgejo
.data
.env
.env.*
!.env.example
bin
coverage
web/node_modules
web/dist
web/coverage
web/playwright-report
web/test-results
*.log
*.out
*.test
Dockerfile*
!Dockerfile

21
.editorconfig Normal file
View file

@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.go]
indent_style = tab
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[*.ps1]
end_of_line = crlf
indent_size = 4

10
.env.example Normal file
View file

@ -0,0 +1,10 @@
DRIVE_HTTP_ADDRESS=:8080
DRIVE_WEB_ROOT=/app/web
DRIVE_STORAGE_ROOT=/storage/.drive
DRIVE_PREVIEW_CACHE_ROOT=/cache/previews
DRIVE_DATABASE_URL=postgres://drive:change-me@postgres:5432/drive?sslmode=disable
DRIVE_LOG_LEVEL=info
POSTGRES_DB=drive
POSTGRES_USER=drive
POSTGRES_PASSWORD=change-me

View file

@ -1,25 +1,34 @@
name: Automated Container Build
name: Container release
on:
push:
branches:
- main
tags:
- "v*"
permissions:
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Log into Local Registry
- uses: actions/checkout@v4
- name: Log into local registry
run: echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ github.actor }}" --password-stdin
- name: Build and publish immutable release
env:
REPOSITORY: ${{ github.repository }}
REVISION: ${{ github.sha }}
TAG: ${{ github.ref_name }}
run: |
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
- name: Build and Push Image
run: |
# Force the entire image path string to lowercase dynamically
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
docker build -t "$IMAGE_PATH" .
docker push "$IMAGE_PATH"
IMAGE_PATH="git.elijahkuntz.com/$(echo "$REPOSITORY" | tr '[:upper:]' '[:lower:]')"
docker build \
--build-arg VERSION="$TAG+$REVISION" \
--label org.opencontainers.image.revision="$REVISION" \
--label org.opencontainers.image.version="$TAG" \
-t "$IMAGE_PATH:$TAG" \
-t "$IMAGE_PATH:sha-$REVISION" \
-t "$IMAGE_PATH:latest" .
docker push "$IMAGE_PATH:$TAG"
docker push "$IMAGE_PATH:sha-$REVISION"
docker push "$IMAGE_PATH:latest"

View file

@ -6,66 +6,62 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
backend:
name: Backend (Python)
name: Backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
- uses: actions/setup-go@v5
with:
python-version: "3.12"
- name: Install dependencies
working-directory: backend
run: |
pip install -e ".[dev]"
- name: Lint (ruff)
working-directory: backend
run: ruff check .
- name: Type check (mypy)
working-directory: backend
run: mypy app/
- name: Test (pytest)
working-directory: backend
run: pytest --tb=short -q
env:
PAPERJET_DATABASE_PATH: ./test.sqlite
PAPERJET_PDF_STORAGE_PATH: ./test_pdfs
PAPERJET_THUMBNAILS_PATH: ./test_thumbs
go-version: "1.26.x"
cache: true
- name: Formatting
run: test -z "$(gofmt -l cmd internal)"
- name: Vet
run: go vet ./cmd/... ./internal/...
- name: Unit and architecture tests
run: go test -race ./cmd/... ./internal/...
frontend:
name: Frontend (TypeScript)
name: Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 22
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
node-version: "24"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
run: npm --prefix web ci
- name: Lint, typecheck, and test
run: npm --prefix web run check
- name: Production build
run: npm --prefix web run build
- name: Lint (eslint)
working-directory: frontend
run: npm run lint
contracts:
name: Contracts and repository policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate OpenAPI
uses: docker://redocly/cli:1.34.5
with:
args: lint api/openapi.yaml
- name: Reject committed secrets and local data
run: |
test ! -e .env
test ! -d .data
- name: Type check (tsc)
working-directory: frontend
run: npx tsc -b
- name: Test (vitest)
working-directory: frontend
run: npx vitest run
container:
name: Container
runs-on: ubuntu-latest
needs: [backend, frontend, contracts]
steps:
- uses: actions/checkout@v4
- name: Build application image
run: docker build --build-arg VERSION=${{ github.sha }} -t drive-v2:${{ github.sha }} .

19
.gitattributes vendored Normal file
View file

@ -0,0 +1,19 @@
* text=auto eol=lf
*.go text eol=lf
*.md text eol=lf
*.sql text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.woff2 binary

35
.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
# Local configuration and secrets
.env
.env.*
!.env.example
*.local
secrets/
# Local data and generated runtime state
.data/
data/
tmp/
*.pid
*.log
# Go
/bin/
/coverage/
*.test
*.out
vendor/
# Frontend
web/node_modules/
web/dist/
web/coverage/
web/playwright-report/
web/test-results/
*.tsbuildinfo
# Editors and operating systems
.idea/
.vscode/
.DS_Store
Thumbs.db
Desktop.ini

26
AGENTS.md Normal file
View file

@ -0,0 +1,26 @@
# Drive v2 agent instructions
`Drive_v2_final_plan.md` is the product and architecture baseline. ADRs in `docs/adr/` supersede it only where they explicitly say so.
## Required commands
- Backend: `go test ./cmd/... ./internal/...`
- Frontend: `npm --prefix web run check`
- Full validation: `docker compose run --rm verify`
- Container build: `docker build -t drive-v2:dev .`
## Non-negotiable rules
- Preserve the invariants in Section 2 of `Drive_v2_final_plan.md`.
- Domain code must not import HTTP, SQL, PostgreSQL, or filesystem adapters.
- Transport handlers parse, call one application service, and serialize; they do not contain persistence or filesystem logic.
- Only the storage adapter may access managed storage paths.
- Every metadata mutation and its change event must commit in one PostgreSQL transaction.
- Committed blobs are immutable. All transfers stream and all sizes/offsets use 64-bit types.
- Do not add endpoints outside `api/openapi.yaml` or queries outside `db/queries/`.
- Do not edit generated files directly. Regenerate them and commit source and output together.
- Do not add dependencies or change architecture without owner approval and an ADR.
- Keep frontend features isolated. Never create a global event bus, central extension switch, or all-purpose file-manager component.
- Never discard errors silently or weaken a test to make a change pass.
Keep changes small and scoped. Read the relevant ADR and module documentation before editing.

7
CODEOWNERS Normal file
View file

@ -0,0 +1,7 @@
# Forgejo uses Go-style regular expressions in CODEOWNERS.
# Invariant and architecture changes require review from the repository owner.
^tests/invariants/.* @Elijah
^docs/adr/.* @Elijah
^api/openapi\.yaml$ @Elijah
^db/migrations/.* @Elijah

12
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,12 @@
# Contributing
Drive v2 is private and owner-maintained. Changes should be made through small branches and reviewed pull requests.
Before opening a pull request:
1. Read `AGENTS.md`, the relevant architecture sections, and applicable ADRs.
2. Keep the change limited to one feature or architectural concern.
3. Run backend, frontend, generated-contract, and container checks applicable to the change.
4. Add an ADR before changing a public contract, dependency policy, storage semantics, or module boundary.
5. Do not combine unrelated cleanup with functional work.

49
Dockerfile Normal file
View file

@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-bookworm AS go-base
WORKDIR /src
FROM node:24-bookworm AS web-dependencies
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
FROM web-dependencies AS web-build
COPY web/ ./
RUN npm run build
FROM go-base AS backend-build
ARG VERSION=dev
COPY go.mod ./
COPY cmd/ ./cmd/
COPY internal/ ./internal/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/drive ./cmd/drive
FROM node:24-bookworm AS verify
COPY --from=go-base /usr/local/go /usr/local/go
ENV PATH="/usr/local/go/bin:${PATH}"
WORKDIR /src
COPY . .
RUN npm --prefix web ci
CMD ["sh", "scripts/verify.sh"]
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install --no-install-recommends -y ca-certificates tini \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 65532 drive \
&& useradd --uid 65532 --gid drive --no-create-home --home-dir /nonexistent drive
WORKDIR /app
COPY --from=backend-build /out/drive /usr/local/bin/drive
COPY --from=web-build /src/web/dist /app/web
ENV DRIVE_HTTP_ADDRESS=:8080 \
DRIVE_WEB_ROOT=/app/web \
DRIVE_STORAGE_ROOT=/storage/.drive \
DRIVE_PREVIEW_CACHE_ROOT=/cache/previews
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/drive"]
CMD ["serve"]

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# Drive v2
Drive v2 is a private, single-owner, self-hosted file suite for Unraid. Stable v1 will provide file management, sharing, OnlyOffice integration, core previews, an installable PWA, a bulk importer, and a durable API suitable for a future desktop sync client.
The approved product and architecture baseline is [`Drive_v2_final_plan.md`](Drive_v2_final_plan.md). Implementation decisions that refine it are recorded in [`docs/adr/`](docs/adr/README.md).
## Repository status
Phase 0 establishes the repository, module boundaries, contracts, development environment, and CI. The running application currently exposes only bootstrap status and health endpoints; product functionality begins in Phase 1.
## Prerequisites
- Docker Engine with Compose v2
- Go 1.26.x for native backend development
- Node.js 24 LTS and npm for native frontend development
Docker is the canonical environment when local toolchains are unavailable.
## Quick start
1. Copy `.env.example` to `.env` and replace the development passwords.
2. Run `docker compose up --build`.
3. Open `http://localhost:8080`.
4. Check `http://localhost:8080/health/live` and `http://localhost:8080/health/ready`.
Do not expose an uninitialized production instance through Nginx Proxy Manager. The approved open setup flow must be completed first.
## Development
- `go test ./cmd/... ./internal/...` runs backend tests.
- `npm --prefix web install` installs frontend dependencies.
- `npm --prefix web run dev` starts the frontend development server.
- `npm --prefix web run check` runs frontend lint, type checking, and tests.
- `docker compose run --rm verify` runs the repository checks in the pinned container environment.
See [`docs/development.md`](docs/development.md) for structure, generated contracts, and workflow details. The approved baseline packages and update rules are in [`docs/dependencies.md`](docs/dependencies.md).
## Licensing
This is currently a private personal project. No license is granted for redistribution or reuse.

8
SECURITY.md Normal file
View file

@ -0,0 +1,8 @@
# Security policy
Drive v2 is currently a private personal project and does not accept public vulnerability reports.
Security-sensitive behavior must follow the threat model in `docs/threat-model.md` and the architecture baseline. Never commit passwords, API tokens, TOTP secrets, OnlyOffice secrets, database credentials, private keys, or production configuration.
If the repository is made public, define a private reporting channel and supported-version policy before the first public release.

91
api/openapi.yaml Normal file
View file

@ -0,0 +1,91 @@
openapi: 3.1.0
info:
title: Drive v2 API
version: 0.0.0
description: >-
Contract-first API for Drive v2. Phase 0 contains only foundation and
health operations; product endpoints are added with their implementation.
servers:
- url: /
paths:
/api/v1/setup/status:
get:
security: []
operationId: getSetupStatus
summary: Report whether the owner account has been initialized
responses:
"200":
description: Current setup state
content:
application/json:
schema:
$ref: "#/components/schemas/SetupStatus"
/health/live:
get:
security: []
operationId: getLiveness
summary: Report process liveness
responses:
"200":
description: Process is live
content:
application/json:
schema:
$ref: "#/components/schemas/HealthStatus"
/health/ready:
get:
security: []
operationId: getReadiness
summary: Report dependency readiness
responses:
"200":
description: Process is ready
content:
application/json:
schema:
$ref: "#/components/schemas/HealthStatus"
"503":
description: A required dependency is unavailable
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
components:
schemas:
SetupStatus:
type: object
required: [initialized, phase, version]
properties:
initialized:
type: boolean
phase:
type: string
version:
type: string
HealthStatus:
type: object
required: [status]
properties:
status:
type: string
enum: [live, ready]
Problem:
type: object
required: [type, title, status, code]
properties:
type:
type: string
format: uri-reference
title:
type: string
status:
type: integer
minimum: 100
maximum: 599
detail:
type: string
instance:
type: string
format: uri-reference
code:
type: string

74
cmd/drive/main.go Normal file
View file

@ -0,0 +1,74 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"drive.local/drivev2/internal/adapters/webassets"
"drive.local/drivev2/internal/config"
"drive.local/drivev2/internal/transport/httpapi"
)
var version = "dev"
func main() {
if err := run(); err != nil {
slog.Error("drive stopped", "error", err)
os.Exit(1)
}
}
func run() error {
command := "serve"
if len(os.Args) > 1 {
command = os.Args[1]
}
switch command {
case "serve":
return serve()
case "version":
fmt.Println(version)
return nil
default:
return fmt.Errorf("unknown command %q", command)
}
}
func serve() error {
cfg, err := config.FromEnvironment()
if err != nil {
return err
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
slog.SetDefault(logger)
server := httpapi.NewServer(cfg, version, logger, webassets.New(cfg.WebRoot))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
logger.Info("starting drive", "address", cfg.HTTPAddress, "version", version)
errCh <- server.ListenAndServe()
}()
select {
case err := <-errCh:
if errors.Is(err, httpapi.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
}
}

61
compose.yaml Normal file
View file

@ -0,0 +1,61 @@
name: drive-v2
services:
postgres:
image: postgres:18.4-bookworm
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-drive}
POSTGRES_USER: ${POSTGRES_USER:-drive}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-drive-development-only}
POSTGRES_INITDB_ARGS: --data-checksums
volumes:
- ./.data/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
networks: [drive-internal]
drive:
build:
context: .
args:
VERSION: ${DRIVE_VERSION:-dev}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DRIVE_HTTP_ADDRESS: :8080
DRIVE_WEB_ROOT: /app/web
DRIVE_STORAGE_ROOT: /storage/.drive
DRIVE_PREVIEW_CACHE_ROOT: /cache/previews
DRIVE_DATABASE_URL: postgres://${POSTGRES_USER:-drive}:${POSTGRES_PASSWORD:-drive-development-only}@postgres:5432/${POSTGRES_DB:-drive}?sslmode=disable
DRIVE_LOG_LEVEL: ${DRIVE_LOG_LEVEL:-info}
ports:
- "${DRIVE_HTTP_PORT:-8080}:8080"
volumes:
- ./.data/storage:/storage
- ./.data/config:/config
- ./.data/previews:/cache/previews
read_only: true
tmpfs:
- /tmp:size=256m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
networks: [drive-internal]
verify:
profiles: [tools]
build:
context: .
target: verify
command: ["sh", "scripts/verify.sh"]
networks:
drive-internal:
internal: true

View file

@ -0,0 +1,10 @@
-- Drive v2 uses forward-only migrations. Rollback is restore-or-fix-forward.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE drive_metadata (
singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO drive_metadata (singleton) VALUES (TRUE);

3
db/queries/health.sql Normal file
View file

@ -0,0 +1,3 @@
-- name: DatabaseHealth :one
SELECT 1::BIGINT AS result;

View file

@ -0,0 +1,6 @@
# ADR-0001: PostgreSQL on btrfs with CoW
Status: Accepted
PostgreSQL data remains on the mirrored exclusive btrfs cache with copy-on-write and PostgreSQL page checksums enabled. `chattr +C` is not used because it would remove btrfs data checksums and mirror self-healing for database files. Capacity, WAL growth, and fragmentation will be monitored when operations work is implemented.

View file

@ -0,0 +1,6 @@
# ADR-0002: Opaque object IDs and same-directory promotion
Status: Accepted
Managed objects use opaque physical IDs. PostgreSQL maps SHA-256 content identities to physical objects. Partials and completed blobs share one shard directory so final promotion is a same-directory rename. Blob-manifest export is required because the filesystem alone is not self-describing.

View file

@ -0,0 +1,6 @@
# ADR-0003: Protect commits, not incomplete transfers
Status: Accepted
Committed content and metadata must recover consistently. Incomplete uploads may be failed or discarded after an unclean shutdown. Restart recovery reconciles partial length with the persisted offset and may recompute hashes instead of making incremental hash state crash-durable.

View file

@ -0,0 +1,6 @@
# ADR-0004: Single application service
Status: Accepted
Production runs HTTP handling and PostgreSQL-leased background workers in one Drive container beside PostgreSQL. This favors simple operation on one private Unraid host. Worker failures must be isolated and observable, and durable state must allow later separation without changing domain behavior.

View file

@ -0,0 +1,6 @@
# ADR-0005: HTTP precondition and conflict semantics
Status: Accepted
Failed `If-Match` or equivalent representation preconditions return `412 Precondition Failed`. Name collisions, invalid hierarchy operations, and other domain conflicts return `409 Conflict`. Both use RFC 9457 problem documents with stable codes and current state where useful.

View file

@ -0,0 +1,6 @@
# ADR-0006: Stable-v1 product scope
Status: Accepted
Stable v1 includes file management, resumable transfers, importer, sharing, OnlyOffice, core previews, PWA, backup/recovery foundations, and future-sync-ready file APIs. Desktop sync, Calendar, archive browsing, and 3D previews are deferred without speculative APIs. Other Google-suite categories require separate proposals.

View file

@ -0,0 +1,6 @@
# ADR-0007: Open first-run setup
Status: Accepted risk
An uninitialized server exposes the owner-creation page without a bootstrap secret. Owner creation is an atomic single-winner transaction and setup becomes permanently unavailable afterward. Operations documentation requires completing setup before proxy exposure. The owner accepts the takeover risk if an uninitialized instance is exposed.

14
docs/adr/README.md Normal file
View file

@ -0,0 +1,14 @@
# Architecture decision records
ADRs are immutable after acceptance. Supersede an earlier decision with a new ADR rather than rewriting its history.
| ADR | Decision | Status |
| --- | --- | --- |
| 0001 | PostgreSQL remains on btrfs with CoW | Accepted |
| 0002 | Opaque object IDs and same-directory promotion | Accepted |
| 0003 | Protect commits, not incomplete transfers | Accepted |
| 0004 | Single application service | Accepted |
| 0005 | HTTP precondition and conflict status codes | Accepted |
| 0006 | Stable-v1 and deferred product scope | Accepted |
| 0007 | Open first-run setup | Accepted risk |

24
docs/architecture.md Normal file
View file

@ -0,0 +1,24 @@
# Architecture guide
The detailed baseline is `Drive_v2_final_plan.md`. This guide is a short implementation map.
## Request flow
```text
HTTP transport -> application use case -> domain rules
|-> PostgreSQL repository
|-> storage/external adapter
```
Transport code handles protocol concerns only. Application services own transactions and coordinate database-plus-storage work. Domain code contains no infrastructure dependencies.
## Durable work
No authoritative state lives only in memory. Upload sessions, commit intents, jobs, leases, and cursors are persisted in PostgreSQL. Committed blobs are immutable and become visible only after storage promotion and metadata commit.
## Frontend
Each feature owns its routes, API hooks, components, state, and tests. Cross-feature access goes through a small public entrypoint. Server state stays in TanStack Query and local UI state remains close to the component or feature that owns it.
Preview rendering is capability-driven and lazy-loaded. Adding a format must not require editing the file manager.

14
docs/dependencies.md Normal file
View file

@ -0,0 +1,14 @@
# Dependency policy
Dependencies are pinned in `go.mod`, `web/package.json`, the npm lockfile, container tags, and CI workflow references. Add a runtime dependency only when its functionality is actively used, and record architectural additions in an ADR before merging.
## Phase 0 baseline
Backend production code currently uses the Go standard library. Chi, pgx, sqlc, and the migration runner are approved by the architecture baseline and will be pinned when their first Phase 1 implementation is added.
Frontend runtime dependencies are React, React DOM, TanStack Query, and TanStack Router. Development dependencies provide TypeScript, Vite, Vitest, ESLint, React tooling, and type declarations.
Container baselines are Go 1.26 on Debian Bookworm, Node 24 on Debian Bookworm, PostgreSQL 18.4 on Debian Bookworm, and a Debian Bookworm slim runtime. Production deployment should pin tested image digests as part of its release configuration.
Dependency updates must pass the full repository check and production image build. Avoid packages that duplicate standard-library functionality or introduce background network behavior, telemetry, install scripts, or unreviewed native binaries.

37
docs/development.md Normal file
View file

@ -0,0 +1,37 @@
# Development guide
## Structure
- `cmd/drive` owns process startup and CLI dispatch.
- `internal/domain` contains dependency-free business rules.
- `internal/application` coordinates use cases through interfaces.
- `internal/adapters` implements PostgreSQL, storage, and external-service ports.
- `internal/transport` maps HTTP contracts to application calls.
- `internal/workers` executes persistent leased jobs.
- `web/src/features` owns frontend product features; `web/src/shared` contains reusable primitives.
- `api/openapi.yaml`, migrations, and sqlc queries are reviewed source contracts.
## Canonical checks
Native:
```text
go test ./cmd/... ./internal/...
npm --prefix web ci
npm --prefix web run check
```
Containerized:
```text
docker compose run --rm verify
docker compose up --build
```
## Contract workflow
Do not add an API handler before its OpenAPI operation exists. Do not write SQL directly in transport or application code; add a named sqlc query. Generated files are outputs and are never edited by hand. CI will add strict generation-drift checks when the first generated client and repository are introduced in Phase 1.
## Architectural changes
Create an ADR before changing storage semantics, public API conventions, package boundaries, deployment topology, or approved dependencies. An ADR records context, decision, consequences, and superseded decisions.

View file

@ -0,0 +1,6 @@
# Operations documentation
The production runbook will cover Unraid share layout, permissions, Nginx Proxy Manager, OnlyOffice networking, graceful shutdown timers, backups, restore, importer use, capacity alerts, and troubleshooting.
Phase 0 provides development Compose only. Do not treat it as a production deployment template until the Phase 7 production checklist is complete.

36
docs/threat-model.md Normal file
View file

@ -0,0 +1,36 @@
# Threat model
## Protected assets
- File contents, metadata, previews, and replacement-recovery blobs.
- Password, sessions, TOTP secrets, recovery codes, API tokens, share tokens, and OnlyOffice secrets.
- Database-to-object mapping, backup data, and blob manifests.
## Trust boundaries
- Browser to Nginx Proxy Manager to Drive HTTP service.
- Drive to PostgreSQL on the private container network.
- Drive to managed cache/array mounts.
- Drive to the existing OnlyOffice Document Server.
- Public-link visitors to explicitly shared resources.
- Read-only importer mounts to managed Drive storage.
## Primary threats
- Unclaimed-instance takeover before first-run setup.
- Session theft, CSRF, credential guessing, token leakage, and proxy-address spoofing.
- Path traversal, symlink following, MIME confusion, malicious previews, and archive expansion.
- SSRF or forged OnlyOffice callbacks.
- Stale writes, duplicate requests, interrupted commits, and premature GC.
- Secret disclosure through logs, images, configuration, backups, or repository history.
## Phase 0 controls
- Production setup must finish before proxy exposure; setup is disabled atomically after owner creation.
- Secrets are environment/file inputs and ignored by Git.
- Non-root container, explicit mounts, private PostgreSQL network, and health endpoints.
- Architectural boundaries prevent HTTP code from reaching database or managed storage directly.
- Security headers, CSRF, authentication, rate limits, scoped tokens, and callback validation are implemented with their Phase 1 features.
Review this document whenever a new external integration, public endpoint, preview processor, or authentication mechanism is introduced.

4
go.mod Normal file
View file

@ -0,0 +1,4 @@
module drive.local/drivev2
go 1.26.0

View file

@ -0,0 +1,2 @@
// Package postgres contains PostgreSQL repository implementations generated from sqlc queries.
package postgres

View file

@ -0,0 +1,2 @@
// Package storage is the only package permitted to access managed storage paths.
package storage

View file

@ -0,0 +1,32 @@
// Package webassets serves the compiled frontend without giving transport code filesystem access.
package webassets
import (
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
)
func New(root string) http.Handler {
files := os.DirFS(root)
fileServer := http.FileServer(http.FS(files))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested := strings.TrimPrefix(filepath.Clean(r.URL.Path), string(filepath.Separator))
if requested == "." {
requested = "index.html"
}
if _, err := fs.Stat(files, requested); err == nil {
fileServer.ServeHTTP(w, r)
return
}
if _, err := fs.Stat(files, "index.html"); err == nil {
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
return
}
http.Error(w, "Drive web assets are not built", http.StatusServiceUnavailable)
})
}

View file

@ -0,0 +1,2 @@
// Package application coordinates transactional Drive use cases through ports.
package application

View file

@ -0,0 +1,77 @@
package architecture_test
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestImportBoundaries(t *testing.T) {
t.Parallel()
repositoryRoot := filepath.Join("..", "..")
rules := []struct {
directory string
forbidden []string
}{
{
directory: "internal/domain",
forbidden: []string{"net/http", "database/sql", "github.com/jackc/pgx", "/internal/adapters/", "/internal/transport/"},
},
{
directory: "internal/transport",
forbidden: []string{"database/sql", "github.com/jackc/pgx", "os"},
},
}
for _, rule := range rules {
rule := rule
t.Run(rule.directory, func(t *testing.T) {
checkDirectory(t, repositoryRoot, rule.directory, rule.forbidden)
})
}
}
func checkDirectory(t *testing.T, root, directory string, forbidden []string) {
t.Helper()
err := filepath.WalkDir(filepath.Join(root, directory), func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, spec := range parsed.Imports {
assertImportAllowed(t, path, spec, forbidden)
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", directory, err)
}
}
func assertImportAllowed(t *testing.T, path string, spec *ast.ImportSpec, forbidden []string) {
t.Helper()
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("unquote import in %s: %v", path, err)
}
for _, pattern := range forbidden {
if importPath == pattern || strings.Contains(importPath, pattern) {
t.Errorf("%s imports forbidden package %q", path, importPath)
}
}
}

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

@ -0,0 +1,55 @@
package config
import (
"fmt"
"log/slog"
"os"
"strings"
)
type Config struct {
HTTPAddress string
WebRoot string
StorageRoot string
PreviewCacheDir string
DatabaseURL string
LogLevel slog.Level
}
func FromEnvironment() (Config, error) {
level, err := parseLogLevel(environment("DRIVE_LOG_LEVEL", "info"))
if err != nil {
return Config{}, err
}
return Config{
HTTPAddress: environment("DRIVE_HTTP_ADDRESS", ":8080"),
WebRoot: environment("DRIVE_WEB_ROOT", "web/dist"),
StorageRoot: environment("DRIVE_STORAGE_ROOT", "/storage/.drive"),
PreviewCacheDir: environment("DRIVE_PREVIEW_CACHE_ROOT", "/cache/previews"),
DatabaseURL: os.Getenv("DRIVE_DATABASE_URL"),
LogLevel: level,
}, nil
}
func environment(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func parseLogLevel(value string) (slog.Level, error) {
switch strings.ToLower(value) {
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("invalid DRIVE_LOG_LEVEL %q", value)
}
}

View file

@ -0,0 +1,18 @@
package config
import (
"log/slog"
"testing"
)
func TestParseLogLevel(t *testing.T) {
t.Parallel()
level, err := parseLogLevel("warning")
if err != nil {
t.Fatalf("parseLogLevel returned an error: %v", err)
}
if level != slog.LevelWarn {
t.Fatalf("parseLogLevel = %v, want %v", level, slog.LevelWarn)
}
}

4
internal/domain/doc.go Normal file
View file

@ -0,0 +1,4 @@
// Package domain contains Drive's business rules and value types.
//
// It must not import HTTP, SQL, PostgreSQL, or filesystem adapter packages.
package domain

View file

@ -0,0 +1,63 @@
package httpapi
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"drive.local/drivev2/internal/config"
)
var ErrServerClosed = http.ErrServerClosed
type Server struct {
httpServer *http.Server
}
func NewServer(cfg config.Config, version string, logger *slog.Logger, frontend http.Handler) *Server {
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"}))
mux.HandleFunc("GET /health/ready", jsonResponse(http.StatusOK, map[string]string{"status": "ready"}))
mux.HandleFunc("GET /api/v1/setup/status", jsonResponse(http.StatusOK, map[string]any{
"initialized": false,
"phase": "foundation",
"version": version,
}))
mux.Handle("/", frontend)
return &Server{httpServer: &http.Server{
Addr: cfg.HTTPAddress,
Handler: requestLogger(logger, mux),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
}}
}
func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
func jsonResponse(status int, payload any) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
slog.Error("encode response", "error", err)
}
}
}
func requestLogger(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
logger.Info("http request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(started).Milliseconds())
})
}

View file

@ -0,0 +1,34 @@
package httpapi
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"drive.local/drivev2/internal/config"
)
func TestLiveHealth(t *testing.T) {
t.Parallel()
server := NewServer(
config.Config{HTTPAddress: ":0", WebRoot: t.TempDir()},
"test",
slog.New(slog.NewTextHandler(io.Discard, nil)),
http.NotFoundHandler(),
)
request := httptest.NewRequest(http.MethodGet, "/health/live", nil)
recorder := httptest.NewRecorder()
server.httpServer.Handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
if !strings.Contains(recorder.Body.String(), `"status":"live"`) {
t.Fatalf("unexpected body: %s", recorder.Body.String())
}
}

2
internal/workers/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package workers runs durable PostgreSQL-backed jobs.
package workers

7
redocly.yaml Normal file
View file

@ -0,0 +1,7 @@
extends:
- recommended
rules:
info-license: off
operation-4xx-response: off

8
scripts/verify.sh Normal file
View file

@ -0,0 +1,8 @@
#!/bin/sh
set -eu
test -z "$(gofmt -l cmd internal)"
go vet ./cmd/... ./internal/...
go test -race ./cmd/... ./internal/...
npm --prefix web run check
npm --prefix web run build

16
sqlc.yaml Normal file
View file

@ -0,0 +1,16 @@
version: "2"
sql:
- engine: postgresql
schema: db/migrations
queries: db/queries
gen:
go:
package: dbgen
out: internal/adapters/postgres/generated
sql_package: pgx/v5
emit_db_tags: true
emit_empty_slices: true
emit_interface: true
emit_json_tags: true
emit_pointers_for_null_types: true

32
web/eslint.config.js Normal file
View file

@ -0,0 +1,32 @@
import eslint from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage", "src/api/generated", "eslint.config.js"] },
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2023,
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
},
rules: {
"max-lines": ["error", { "max": 350, "skipBlankLines": true, "skipComments": true }],
"max-lines-per-function": ["error", { "max": 100, "skipBlankLines": true, "skipComments": true }],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-floating-promises": "error"
}
},
{
files: ["**/*.test.{ts,tsx}"],
languageOptions: { globals: globals.node }
}
);

15
web/index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#14213d" />
<meta name="description" content="Private self-hosted Drive v2" />
<title>Drive v2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3286
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
web/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "drive-v2-web",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24 <25"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -b --pretty false",
"test": "vitest run",
"check": "npm run lint && npm run typecheck && npm run test"
},
"dependencies": {
"@tanstack/react-query": "5.101.2",
"@tanstack/react-router": "1.170.18",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@types/node": "26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.3",
"eslint": "10.7.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.7.0",
"typescript": "6.0.3",
"typescript-eslint": "8.64.0",
"vite": "8.1.0",
"vitest": "4.1.10"
}
}

36
web/src/app/App.tsx Normal file
View file

@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { getSetupStatus } from "../features/setup/api/getSetupStatus";
export function App() {
const status = useQuery({
queryKey: ["setup-status"],
queryFn: getSetupStatus
});
return (
<main className="foundation-shell">
<section className="foundation-card" aria-labelledby="foundation-title">
<p className="eyebrow">Foundation phase</p>
<h1 id="foundation-title">Drive v2</h1>
<p className="summary">
The repository, contracts, architecture boundaries, and deployment shell are ready for product development.
</p>
<dl className="status-list">
<div>
<dt>API</dt>
<dd>{status.isPending ? "Checking…" : status.isError ? "Unavailable" : "Connected"}</dd>
</div>
<div>
<dt>Owner setup</dt>
<dd>{status.data?.initialized === true ? "Complete" : "Begins in Phase 1"}</dd>
</div>
<div>
<dt>Build</dt>
<dd>{status.data?.version ?? "development"}</dd>
</div>
</dl>
</section>
</main>
);
}

View file

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import type { SetupStatus } from "./getSetupStatus";
describe("SetupStatus", () => {
it("represents the foundation state", () => {
const status: SetupStatus = { initialized: false, phase: "foundation", version: "test" };
expect(status.phase).toBe("foundation");
expect(status.initialized).toBe(false);
});
});

View file

@ -0,0 +1,17 @@
export interface SetupStatus {
initialized: boolean;
phase: string;
version: string;
}
export async function getSetupStatus(): Promise<SetupStatus> {
const response = await fetch("/api/v1/setup/status", {
headers: { Accept: "application/json" },
credentials: "same-origin"
});
if (!response.ok) {
throw new Error(`Setup status failed with ${response.status.toString()}`);
}
return (await response.json()) as SetupStatus;
}

28
web/src/main.tsx Normal file
View file

@ -0,0 +1,28 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./app/App";
import "./shared/styles/base.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1
}
}
});
const root = document.getElementById("root");
if (root === null) {
throw new Error("Drive root element is missing");
}
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
);

View file

@ -0,0 +1,123 @@
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #162033;
background: #edf2f7;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
.foundation-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
background:
radial-gradient(circle at 15% 15%, rgb(75 114 170 / 24%), transparent 32rem),
linear-gradient(145deg, #edf2f7, #dfe8f2);
}
.foundation-card {
width: min(42rem, 100%);
padding: clamp(2rem, 6vw, 4rem);
border: 1px solid rgb(20 33 61 / 12%);
border-radius: 1.5rem;
background: rgb(255 255 255 / 88%);
box-shadow: 0 1.5rem 4rem rgb(20 33 61 / 14%);
backdrop-filter: blur(1rem);
}
.eyebrow {
margin: 0 0 0.5rem;
color: #426da9;
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
color: #14213d;
font-size: clamp(2.5rem, 8vw, 5rem);
line-height: 1;
letter-spacing: -0.06em;
}
.summary {
max-width: 34rem;
margin: 1.5rem 0 2.5rem;
color: #52627a;
font-size: 1.1rem;
line-height: 1.65;
}
.status-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin: 0;
}
.status-list div {
padding-top: 1rem;
border-top: 1px solid rgb(20 33 61 / 14%);
}
.status-list dt {
color: #718096;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-list dd {
margin: 0.4rem 0 0;
color: #14213d;
font-weight: 650;
}
@media (max-width: 36rem) {
.status-list {
grid-template-columns: 1fr;
}
}
@media (prefers-color-scheme: dark) {
:root {
color: #e6edf7;
background: #0d1525;
}
.foundation-shell {
background:
radial-gradient(circle at 15% 15%, rgb(66 109 169 / 32%), transparent 30rem),
linear-gradient(145deg, #111b2e, #080d17);
}
.foundation-card {
border-color: rgb(255 255 255 / 10%);
background: rgb(18 29 49 / 86%);
}
h1,
.status-list dd {
color: #f4f7fb;
}
.summary {
color: #adbbcf;
}
}

2
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />

28
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}

8
web/tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

17
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}

17
web/vite.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8080",
"/health": "http://localhost:8080"
}
},
build: {
sourcemap: true
}
});