Harden CI and switch release builds to tagged immutable images
This commit is contained in:
parent
bed2e6cfb6
commit
f24e96efa7
60 changed files with 4710 additions and 64 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
21
.editorconfig
Normal 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
10
.env.example
Normal 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
|
||||||
|
|
||||||
|
|
@ -1,25 +1,34 @@
|
||||||
name: Automated Container Build
|
name: Container release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- uses: actions/checkout@v4
|
||||||
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: Log into Local Registry
|
- name: Build and publish immutable release
|
||||||
|
env:
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
REVISION: ${{ github.sha }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
IMAGE_PATH="git.elijahkuntz.com/$(echo "$REPOSITORY" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
docker build \
|
||||||
- name: Build and Push Image
|
--build-arg VERSION="$TAG+$REVISION" \
|
||||||
run: |
|
--label org.opencontainers.image.revision="$REVISION" \
|
||||||
# Force the entire image path string to lowercase dynamically
|
--label org.opencontainers.image.version="$TAG" \
|
||||||
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
|
-t "$IMAGE_PATH:$TAG" \
|
||||||
|
-t "$IMAGE_PATH:sha-$REVISION" \
|
||||||
docker build -t "$IMAGE_PATH" .
|
-t "$IMAGE_PATH:latest" .
|
||||||
docker push "$IMAGE_PATH"
|
docker push "$IMAGE_PATH:$TAG"
|
||||||
|
docker push "$IMAGE_PATH:sha-$REVISION"
|
||||||
|
docker push "$IMAGE_PATH:latest"
|
||||||
|
|
|
||||||
|
|
@ -6,66 +6,62 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend:
|
backend:
|
||||||
name: Backend (Python)
|
name: Backend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
- name: Set up Python 3.12
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
go-version: "1.26.x"
|
||||||
|
cache: true
|
||||||
- name: Install dependencies
|
- name: Formatting
|
||||||
working-directory: backend
|
run: test -z "$(gofmt -l cmd internal)"
|
||||||
run: |
|
- name: Vet
|
||||||
pip install -e ".[dev]"
|
run: go vet ./cmd/... ./internal/...
|
||||||
|
- name: Unit and architecture tests
|
||||||
- name: Lint (ruff)
|
run: go test -race ./cmd/... ./internal/...
|
||||||
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
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend (TypeScript)
|
name: Frontend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
- name: Set up Node.js 22
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "24"
|
||||||
cache: "npm"
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: frontend
|
run: npm --prefix web ci
|
||||||
run: npm ci
|
- name: Lint, typecheck, and test
|
||||||
|
run: npm --prefix web run check
|
||||||
|
- name: Production build
|
||||||
|
run: npm --prefix web run build
|
||||||
|
|
||||||
- name: Lint (eslint)
|
contracts:
|
||||||
working-directory: frontend
|
name: Contracts and repository policy
|
||||||
run: npm run lint
|
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)
|
container:
|
||||||
working-directory: frontend
|
name: Container
|
||||||
run: npx tsc -b
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend, frontend, contracts]
|
||||||
- name: Test (vitest)
|
steps:
|
||||||
working-directory: frontend
|
- uses: actions/checkout@v4
|
||||||
run: npx vitest run
|
- name: Build application image
|
||||||
|
run: docker build --build-arg VERSION=${{ github.sha }} -t drive-v2:${{ github.sha }} .
|
||||||
|
|
|
||||||
19
.gitattributes
vendored
Normal file
19
.gitattributes
vendored
Normal 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
35
.gitignore
vendored
Normal 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
26
AGENTS.md
Normal 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
7
CODEOWNERS
Normal 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
12
CONTRIBUTING.md
Normal 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
49
Dockerfile
Normal 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
40
README.md
Normal 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
8
SECURITY.md
Normal 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
91
api/openapi.yaml
Normal 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
74
cmd/drive/main.go
Normal 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
61
compose.yaml
Normal 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
|
||||||
|
|
||||||
10
db/migrations/000001_foundation.sql
Normal file
10
db/migrations/000001_foundation.sql
Normal 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
3
db/queries/health.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- name: DatabaseHealth :one
|
||||||
|
SELECT 1::BIGINT AS result;
|
||||||
|
|
||||||
6
docs/adr/0001-postgresql-btrfs-cow.md
Normal file
6
docs/adr/0001-postgresql-btrfs-cow.md
Normal 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.
|
||||||
|
|
||||||
6
docs/adr/0002-opaque-object-identities.md
Normal file
6
docs/adr/0002-opaque-object-identities.md
Normal 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.
|
||||||
|
|
||||||
6
docs/adr/0003-incomplete-transfer-posture.md
Normal file
6
docs/adr/0003-incomplete-transfer-posture.md
Normal 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.
|
||||||
|
|
||||||
6
docs/adr/0004-single-application-service.md
Normal file
6
docs/adr/0004-single-application-service.md
Normal 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.
|
||||||
|
|
||||||
6
docs/adr/0005-http-conflict-semantics.md
Normal file
6
docs/adr/0005-http-conflict-semantics.md
Normal 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.
|
||||||
|
|
||||||
6
docs/adr/0006-v1-product-scope.md
Normal file
6
docs/adr/0006-v1-product-scope.md
Normal 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.
|
||||||
|
|
||||||
6
docs/adr/0007-open-first-run-setup.md
Normal file
6
docs/adr/0007-open-first-run-setup.md
Normal 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
14
docs/adr/README.md
Normal 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
24
docs/architecture.md
Normal 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
14
docs/dependencies.md
Normal 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
37
docs/development.md
Normal 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.
|
||||||
6
docs/operations/README.md
Normal file
6
docs/operations/README.md
Normal 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
36
docs/threat-model.md
Normal 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
4
go.mod
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module drive.local/drivev2
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
2
internal/adapters/postgres/doc.go
Normal file
2
internal/adapters/postgres/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Package postgres contains PostgreSQL repository implementations generated from sqlc queries.
|
||||||
|
package postgres
|
||||||
2
internal/adapters/storage/doc.go
Normal file
2
internal/adapters/storage/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Package storage is the only package permitted to access managed storage paths.
|
||||||
|
package storage
|
||||||
32
internal/adapters/webassets/handler.go
Normal file
32
internal/adapters/webassets/handler.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
2
internal/application/doc.go
Normal file
2
internal/application/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Package application coordinates transactional Drive use cases through ports.
|
||||||
|
package application
|
||||||
77
internal/architecture/architecture_test.go
Normal file
77
internal/architecture/architecture_test.go
Normal 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
55
internal/config/config.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/config/config_test.go
Normal file
18
internal/config/config_test.go
Normal 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
4
internal/domain/doc.go
Normal 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
|
||||||
63
internal/transport/httpapi/server.go
Normal file
63
internal/transport/httpapi/server.go
Normal 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())
|
||||||
|
})
|
||||||
|
}
|
||||||
34
internal/transport/httpapi/server_test.go
Normal file
34
internal/transport/httpapi/server_test.go
Normal 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
2
internal/workers/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Package workers runs durable PostgreSQL-backed jobs.
|
||||||
|
package workers
|
||||||
7
redocly.yaml
Normal file
7
redocly.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
|
||||||
|
rules:
|
||||||
|
info-license: off
|
||||||
|
operation-4xx-response: off
|
||||||
|
|
||||||
8
scripts/verify.sh
Normal file
8
scripts/verify.sh
Normal 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
16
sqlc.yaml
Normal 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
32
web/eslint.config.js
Normal 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
15
web/index.html
Normal 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
3286
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
web/package.json
Normal file
37
web/package.json
Normal 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
36
web/src/app/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
11
web/src/features/setup/api/getSetupStatus.test.ts
Normal file
11
web/src/features/setup/api/getSetupStatus.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
17
web/src/features/setup/api/getSetupStatus.ts
Normal file
17
web/src/features/setup/api/getSetupStatus.ts
Normal 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
28
web/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
123
web/src/shared/styles/base.css
Normal file
123
web/src/shared/styles/base.css
Normal 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
2
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
28
web/tsconfig.app.json
Normal file
28
web/tsconfig.app.json
Normal 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
8
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
17
web/tsconfig.node.json
Normal file
17
web/tsconfig.node.json
Normal 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
17
web/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue