From b3936076027e126d404832194f67fc88e47eeaec Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 10 Jun 2026 15:37:23 -0700 Subject: [PATCH] Initial commit with architecture file --- .forgejo/workflows/build.yml | 25 ++ .gitignore | 13 + Project Architecture.md | 619 +++++++++++++++++++++++++++++++++++ 3 files changed, 657 insertions(+) create mode 100644 .forgejo/workflows/build.yml create mode 100644 .gitignore create mode 100644 Project Architecture.md diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..4ed4ca5 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,25 @@ +name: Automated Container Build + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Log into Local Registry + 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" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f185868 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# IDE & Editor files +.vscode/ +.cursor/ +.idea/ +*.swp +*.xml + +# Build outputs (if your Node app eventually compiles or bundles code) +dist/ +build/ +out/ + +# Local application run data diff --git a/Project Architecture.md b/Project Architecture.md new file mode 100644 index 0000000..b084c52 --- /dev/null +++ b/Project Architecture.md @@ -0,0 +1,619 @@ +# Self-Hosted PDF Editor — Implementation Plan + +**Project codename:** `paperjet` (rename freely) +**Document version:** 1.1 +**Status:** Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code. + +> **v1.1 changes:** resolved the four open questions — upload ceiling set to 200 MB; versioning reframed around accidental-loss recovery with a document-level **trash/restore** added as the primary safety net; cookie/proxy behavior pinned (direct HTTP for dev only, everything through the HTTPS proxy in production); signature capture specified as **draw + type-to-signature with live multi-font preview**. + +--- + +## 1. Purpose & Scope + +A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo. + +It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application's own containers are permitted. + +### Non-goals (v1) +- Multi-user collaboration / real-time co-editing +- True content editing (reflowing or rewriting the PDF's existing text stream) +- OCR (can be added later as an annotation-source plugin) +- Cloud sync + +The architecture must not foreclose these. Future features are designed for, not built now. + +--- + +## 2. Key Architectural Decisions (and changes from earlier discussion) + +These decisions supersede anything discussed earlier in planning. Where they change a prior assumption, the change and its rationale are called out. + +### 2.1 Rendering happens on the client, not the server *(CHANGED)* +Earlier planning floated server-side page rendering (PyMuPDF renders each page to an image, the browser displays the image). **We are not doing that.** The browser renders pages directly with PDF.js. + +**Why:** Client-side rendering gives crisp, GPU-accelerated, infinitely zoomable pages with no per-page network round-trip and far less bandwidth — which matters for the WAN/reverse-proxy use case. Server-side image rendering would be blurry on zoom, slow over WAN, and heavier on the server. + +**Consequence:** The server's PDF engine (PyMuPDF) is used for exactly two things — generating small thumbnails for the home page, and flattening annotations into the final exported PDF. It never renders full pages for the live editor. + +### 2.2 The server stores annotations opaquely; only export understands them +The backend persists the annotation array as a JSON blob and does not parse individual annotation types for normal CRUD. Only the **export renderer** has per-type logic, dispatched through a type registry. This is the core extensibility decision: a new annotation type (e.g. "stamp", "checkbox", "redaction") requires a new frontend tool and a new export-renderer function, with **no database migration and no API change**. + +### 2.3 `pdf-lib` is dropped entirely *(CHANGED)* +Because all PDF writing happens server-side in PyMuPDF, the browser never manipulates raw PDF bytes. Earlier planning included `pdf-lib` as a fallback writer; it is removed. One rendering engine for display (PDF.js), one writing engine for export (PyMuPDF). Fewer engines, fewer coordinate-mismatch bugs. + +### 2.4 Versions store annotation snapshots, not flattened PDFs — and exist to recover from accidental loss *(CHANGED / IMPROVED)* +Earlier planning suggested flattening a real PDF per version. Instead, a version is a lightweight JSON snapshot of the annotation state. The original uploaded PDF is immutable on disk; every version is that original plus a layer of annotations. Flattening to an actual PDF happens **only on export/download**. + +**Purpose drives the policy.** Versioning here is a *safety net against accidental loss*, not a granular editing timeline. There are two distinct things a user can lose by accident, and each gets its own net: +- **A whole document**, deleted by mistake → recovered via a document-level **trash/restore** (soft delete, §6.2 / §9.6). +- **Annotation work inside a document**, cleared or overwritten by mistake → recovered via **version checkpoints** (§9.2). + +Because the goal is recovery rather than fine-grained history, auto-checkpoints are taken at meaningful safety boundaries (document close, and immediately before any destructive operation like "clear all" or a restore) rather than on a noisy time-based cadence. Manual checkpoints are always available. + +**Why snapshots:** they're kilobytes, not megabytes, so both nets are effectively free. Any version restores by loading its JSON, and exports on demand. + +### 2.5 Canonical coordinate space is fixed and documented +All stored coordinates are in **PDF points (1/72 inch), top-left origin, relative to the page's unrotated CropBox.** This single rule is the most important correctness constraint in the project (see §8). + +### 2.6 Session auth with hashed password, not HTTP Basic *(CHANGED)* +Because the app is WAN-exposed, auth is a real login screen backed by an Argon2id password hash and a signed, httpOnly, SameSite cookie — not HTTP Basic Auth. Login is rate-limited. + +--- + +## 3. Technology Stack (final) + +| Layer | Choice | Notes | +|---|---|---| +| Frontend language | TypeScript (strict mode) | Type safety across the PDF/screen coordinate boundary | +| Frontend framework | React 18 | Largest ecosystem; best-represented in all agents' training | +| Build tool | Vite | Fast, simple, standard | +| PDF rendering (client) | PDF.js (`pdfjs-dist`) | Display only | +| Canvas editing overlay | Fabric.js | Text boxes, drawing, drag/resize/select handles | +| Frontend state | Zustand | Lightweight, predictable; good fit for editor + undo stack | +| Styling | Tailwind CSS | Fast iteration toward a clean Sejda-like light UI | +| Backend language | Python 3.12 | Chosen for PyMuPDF | +| Backend framework | FastAPI | Async, typed, auto-generates OpenAPI | +| PDF processing (server) | PyMuPDF (`pymupdf` / fitz) | Thumbnails + export flatten. MuPDF compiled into the wheel — self-contained | +| Validation | Pydantic v2 | Mirrors the TS annotation types | +| ORM | SQLAlchemy 2.x | With Alembic for migrations | +| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container | +| Password hashing | Argon2id (`argon2-cffi`) | | +| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry | +| ASGI server (backend) | uvicorn | Behind nginx | +| Orchestration | Docker Compose | | +| External programs / containers / APIs | **None** | Hard requirement | + +**The user's own nginx reverse proxy** sits in front of this stack and is out of scope for the compose file (it already exists). The compose stack exposes one HTTP port for the proxy to target. + +--- + +## 4. System Architecture + +``` + ┌───────────────────────────┐ + Internet (WAN) ───────▶│ User's nginx reverse proxy│ (existing, out of scope) + LAN ──────────────────▶│ TLS termination, routing │ + └─────────────┬─────────────┘ + │ http :8080 + ┌─────────────▼─────────────────────────────┐ + │ Docker Compose │ + │ │ + │ ┌──────────────┐ ┌────────────────┐ │ + │ │ frontend │ │ backend │ │ + │ │ nginx │─────▶│ FastAPI │ │ + │ │ serves SPA │ /api │ uvicorn │ │ + │ │ proxies /api│ │ PyMuPDF │ │ + │ └──────────────┘ │ SQLAlchemy │ │ + │ └───────┬────────┘ │ + │ │ │ + │ ┌──────────▼────────┐ │ + │ │ volumes │ │ + │ │ pdf_storage/ │ │ + │ │ thumbnails/ │ │ + │ │ db/app.sqlite │ │ + │ └───────────────────┘ │ + └────────────────────────────────────────────┘ +``` + +- The `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work." +- The user's outer reverse proxy points at the `frontend` container's published port. + +--- + +## 5. Repository Structure + +A single monorepo. Two deployable images. + +``` +paperjet/ +├── README.md +├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first +├── docker-compose.yml +├── .env.example +├── .github/workflows/ci.yml # lint + typecheck + test on push +│ +├── frontend/ +│ ├── Dockerfile # multi-stage: build SPA, serve via nginx +│ ├── nginx.conf # SPA fallback + /api proxy +│ ├── package.json +│ ├── tsconfig.json # strict: true +│ ├── vite.config.ts +│ ├── tailwind.config.ts +│ └── src/ +│ ├── main.tsx +│ ├── app/ # routing, layout, theme +│ ├── pages/ +│ │ ├── LoginPage.tsx +│ │ ├── HomePage.tsx # recent + upload + library tabs +│ │ └── EditorPage.tsx +│ ├── features/ +│ │ ├── auth/ +│ │ ├── library/ # list, upload, delete, search +│ │ ├── editor/ +│ │ │ ├── canvas/ # Fabric.js integration +│ │ │ ├── tools/ # one module per annotation tool +│ │ │ ├── toolbar/ +│ │ │ ├── pages/ # PDF.js page rendering +│ │ │ └── history/ # undo/redo stack +│ │ └── versions/ +│ ├── lib/ +│ │ ├── coords.ts # ⚠ THE coordinate transform module (§8) +│ │ ├── api/ # generated/typed API client +│ │ └── annotations/ # shared annotation type defs + registry +│ └── types/ # generated from backend OpenAPI +│ +├── backend/ +│ ├── Dockerfile +│ ├── pyproject.toml +│ ├── alembic/ # migrations +│ └── app/ +│ ├── main.py # FastAPI app, router mounting +│ ├── config.py # env-driven settings (Pydantic Settings) +│ ├── db.py # engine, session, WAL pragma +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic request/response models +│ ├── api/v1/ +│ │ ├── auth.py +│ │ ├── documents.py +│ │ ├── annotations.py +│ │ ├── versions.py +│ │ └── export.py +│ ├── services/ +│ │ ├── storage.py # file IO on the volume +│ │ ├── thumbnails.py # PyMuPDF thumbnail generation +│ │ └── export/ # PyMuPDF flatten + type registry (§9.3) +│ │ ├── renderer.py +│ │ └── handlers/ # one handler per annotation type +│ ├── auth/ # hashing, session cookie, dependencies +│ └── tests/ +│ +└── shared/ + └── annotation-schema.json # canonical schema; source for TS + Pydantic +``` + +**`shared/annotation-schema.json`** is the contract for the annotation data model. Both the TypeScript types and the Pydantic models derive from it. When the model changes, it changes here first. + +--- + +## 6. Data Model + +SQLite, WAL mode enabled. UUIDv4 string primary keys throughout. + +### 6.1 Tables + +**`settings`** (singleton row, `id = 1`) +| column | type | notes | +|---|---|---| +| id | INTEGER PK | always 1 | +| password_hash | TEXT | Argon2id; NULL until first-run setup | +| created_at | TEXT (ISO8601) | | +| updated_at | TEXT | | + +**`documents`** +| column | type | notes | +|---|---|---| +| id | TEXT PK | uuid | +| title | TEXT | display name, user-editable; defaults to filename | +| original_filename | TEXT | | +| file_path | TEXT | path on `pdf_storage` volume | +| thumbnail_path | TEXT | path on `thumbnails` volume; NULL until generated | +| size_bytes | INTEGER | | +| page_count | INTEGER | | +| created_at | TEXT | upload time | +| updated_at | TEXT | last edit (drives "recently edited") | +| deleted_at | TEXT | soft delete; NULL = active | + +**`annotation_states`** (current working layer, one row per document) +| column | type | notes | +|---|---|---| +| document_id | TEXT PK FK | → documents.id | +| data | TEXT (JSON) | the annotation array (§7) | +| updated_at | TEXT | written by autosave | + +**`versions`** (history snapshots) +| column | type | notes | +|---|---|---| +| id | TEXT PK | uuid | +| document_id | TEXT FK | | +| label | TEXT | NULL for auto-checkpoints | +| data | TEXT (JSON) | full annotation snapshot | +| kind | TEXT | `manual` \| `auto` | +| created_at | TEXT | | + +Indexes: `documents(updated_at)`, `documents(deleted_at)`, `versions(document_id, created_at)`. + +### 6.2 Soft deletes / trash (v1 feature) +Deleting a document is a **soft delete**: it sets `documents.deleted_at` and moves the row out of the active library into a **Trash** view, from which it can be **restored**. This is the primary recovery path for an accidentally deleted file. The underlying PDF, thumbnail, and annotation state are retained while the document is in trash. + +- **Restore** clears `deleted_at`, returning the document to the library with its annotations intact. +- **Permanent delete** happens either when the user empties trash explicitly, or automatically after a retention window (default **30 days** in trash), at which point files and rows are purged. Retention is a config value. +- Bulk delete and bulk restore operate on multiple selected items. + +--- + +## 7. Annotation Data Model + +The single most reused structure in the system. Defined once in `shared/annotation-schema.json`, mirrored as a TS discriminated union and Pydantic models. + +### 7.1 Envelope (common to all types) + +```jsonc +{ + "id": "uuid", + "page": 0, // 0-indexed + "type": "text", // discriminator; drives the registry + "rect": { // CANONICAL space: PDF points, top-left origin (§8) + "x": 72.0, + "y": 144.0, + "width": 200.0, + "height": 48.0 + }, + "rotation": 0, // degrees, clockwise, about rect center + "z": 3, // z-order within the page + "props": { /* type-specific, opaque to the server */ }, + "createdAt": "2026-06-10T...", + "updatedAt": "2026-06-10T..." +} +``` + +### 7.2 Per-type `props` (v1 set) + +| type | `props` shape (summary) | +|---|---| +| `text` | `{ text, fontFamily, fontSize, color, align, bold, italic, lineHeight }` | +| `draw` | `{ paths: [[x,y]...], strokeColor, strokeWidth, opacity }` (points in rect-local space) | +| `signature` | discriminated by `mode`:
• draw → `{ mode: "draw", ref, strokeColor }` (a captured raster/vector signature, stored as an asset)
• type → `{ mode: "type", text, fontFamily, color }` (rendered as text in a bundled signature font) | +| `image` | `{ ref, naturalWidth, naturalHeight }` (uploaded raster) | +| `highlight` | `{ color, opacity }` | +| `shape` | `{ kind: "rect"\|"ellipse"\|"line"\|"arrow", strokeColor, fillColor, strokeWidth }` | + +Large binary payloads (signature PNGs, placed images) are **not** inlined into the JSON. They are uploaded to an assets endpoint and referenced by id (`ref`). This keeps the annotation JSON small and the autosave PUT cheap. See §9.4. + +### 7.3 Extensibility contract +- Adding a type = (a) add its `props` schema to `annotation-schema.json`, (b) add a frontend tool module under `editor/tools/`, (c) register a frontend renderer + an export handler in the registries. **No DB or API change.** +- The server must round-trip unknown `type` values without error (forward compatibility): if an export handler is missing for a type, it is skipped with a logged warning, never a crash. + +--- + +## 8. The Coordinate System (read this twice) + +**Canonical storage space:** PDF points (1/72"), **top-left origin** (y increases downward), relative to each page's CropBox at **0° rotation**. + +Rationale for top-left origin: Fabric.js canvas space is top-left, and PyMuPDF's high-level API is top-left native — so two of the three engines need no flip. Only PDF.js (whose `viewport` PDF-point conversions are bottom-left) is flipped, in exactly one place. + +`lib/coords.ts` is the **only** module permitted to convert between coordinate spaces. No component computes its own transforms inline. It exposes: + +```ts +// canonical (PDF points, top-left) ↔ screen (CSS pixels at current zoom) +pdfToScreen(p: PdfPoint, viewport: PageViewport): ScreenPoint +screenToPdf(p: ScreenPoint, viewport: PageViewport): PdfPoint +pdfRectToScreen(r: PdfRect, viewport): ScreenRect +screenRectToPdf(r: ScreenRect, viewport): PdfRect +``` + +It must correctly handle, with unit tests for each: +1. **Zoom** — scaling without cumulative drift. +2. **Page rotation** — pages with intrinsic /Rotate of 90/180/270°. +3. **Device pixel ratio** — retina vs standard displays. +4. **CropBox ≠ MediaBox** — pages whose visible box is offset from the media origin. +5. **Y-axis flip** for the PDF.js boundary specifically. + +**Verification gate:** before any annotation tool is built, write a round-trip test — `screenToPdf(pdfToScreen(p)) ≈ p` within 0.01pt across all rotations and zoom levels — and a cross-engine test that places a known rect via the frontend transform and confirms PyMuPDF stamps it in the same visual location. The export renderer and the live editor must agree pixel-for-pixel. This gate is non-negotiable; misalignment here corrupts every saved annotation. + +--- + +## 9. Backend Behavior + +### 9.1 Autosave model +- Frontend debounces 500ms after the last edit, then `PUT /documents/{id}/annotations` with the **full** current annotation array (idempotent full-state replace — simpler and more crash-safe than diff patches at this scale). +- The PUT updates `annotation_states.data` and bumps `documents.updated_at`. +- Concurrent-tab safety: last write wins (single user, acceptable). Include an `updatedAt` in the payload so a stale tab can be detected and warned, but do not block. + +### 9.2 Versioning model +- **Manual checkpoint:** `POST /documents/{id}/versions` snapshots current working state into `versions` with `kind=manual` and an optional label. Manual versions are kept indefinitely. +- **Auto checkpoint:** the backend snapshots automatically at *safety boundaries* — on document close, and immediately **before any destructive operation** (a "clear all", or a version restore). This keeps the net focused on recoverable accidents rather than logging every keystroke. Auto versions are retained for a rolling window (default **30 days**, config-driven) and pruned beyond it; manual versions are never auto-pruned. +- **Restore:** `POST /documents/{id}/versions/{vid}/restore` first auto-snapshots the *current* state (so a restore is itself undoable), then copies the target version's `data` into the working state. + +In-document accidental edits during a single session are also covered by the in-memory undo/redo stack (§12.3); versions are the cross-session and pre-destructive-action net. + +### 9.3 Export (flatten) via type registry +`services/export/renderer.py` opens the original PDF with PyMuPDF, iterates the annotation array, and dispatches each by `type` to a handler in `services/export/handlers/`. Each handler receives `(page, annotation, coord_ctx)` and draws into the page using PyMuPDF primitives (`insert_textbox`, `draw_line`, `insert_image`, `draw_rect`, etc.). Output is written to a temp file and streamed back. + +- The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else. +- Missing handler → log + skip, never crash (§7.3). +- Export accepts an optional `versionId` to export a historical version instead of the working state. +- Fonts: bundle inside the backend image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability. + +### 9.4 Assets (binary annotation payloads) +- `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`. +- Annotations reference assets by `ref`; the JSON stays small. +- Orphan assets (unreferenced after edits) are swept periodically. + +### 9.5 Thumbnails +- On upload, generate a single first-page thumbnail (PyMuPDF, ~300px wide) to `thumbnails/`. Cheap. Regenerate lazily if missing. Used by home-page cards. + +### 9.6 Trash / restore +- Delete sets `documents.deleted_at`; the document leaves the active library and appears in **Trash**. +- Restore clears `deleted_at`. +- A periodic sweep permanently purges documents whose `deleted_at` is older than the retention window (default 30 days), deleting their PDF, thumbnail, assets, annotation state, and versions. Purge is also triggerable manually via "empty trash". +- All list queries for the active library filter `deleted_at IS NULL`; the trash view filters `deleted_at IS NOT NULL`. + +--- + +## 10. Full API Specification + +Base path `/api/v1`. All responses JSON unless noted. All endpoints except `auth/*` and `health` require a valid session cookie; unauthenticated requests get `401`. Errors use a consistent envelope: `{ "error": { "code": str, "message": str } }`. + +### 10.1 Auth + +| Method | Path | Body | Returns | Notes | +|---|---|---|---|---| +| GET | `/auth/status` | — | `{ authenticated: bool, setupRequired: bool }` | `setupRequired` true if no password set | +| POST | `/auth/setup` | `{ password }` | `204` | First-run only; 409 if already set | +| POST | `/auth/login` | `{ password }` | `204` + sets cookie | Rate-limited (see §11) | +| POST | `/auth/logout` | — | `204` | Clears cookie | +| PUT | `/auth/password` | `{ currentPassword, newPassword }` | `204` | 403 on mismatch | + +### 10.2 Documents + +| Method | Path | Body / Query | Returns | +|---|---|---|---| +| GET | `/documents` | `?sort=recent\|title&q=&deleted=false&limit=&offset=` | `{ items: DocumentMeta[], total }` | +| POST | `/documents` | multipart `file` | `DocumentMeta` (201) | +| GET | `/documents/{id}` | — | `DocumentMeta` | +| PATCH | `/documents/{id}` | `{ title? }` | `DocumentMeta` | +| DELETE | `/documents/{id}` | — | `204` (soft delete → trash) | +| POST | `/documents/bulk-delete` | `{ ids: string[] }` | `{ deleted: number }` (soft) | +| POST | `/documents/{id}/restore` | — | `DocumentMeta` (un-trash) | +| POST | `/documents/bulk-restore` | `{ ids: string[] }` | `{ restored: number }` | +| DELETE | `/documents/{id}?permanent=true` | — | `204` (hard delete from trash) | +| POST | `/documents/trash/empty` | — | `{ purged: number }` | +| GET | `/documents/{id}/file` | — | `application/pdf` (original bytes; PDF.js fetches this) | +| GET | `/documents/{id}/thumbnail` | — | `image/png` | + +`?deleted=false` (default) lists the active library; `?deleted=true` lists trash. Hard delete requires the explicit `permanent=true` flag so it can never happen by accident from the normal delete path. + +`DocumentMeta`: +```jsonc +{ "id", "title", "originalFilename", "sizeBytes", "pageCount", + "createdAt", "updatedAt", "thumbnailUrl" } +``` + +### 10.3 Annotations (working state) + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/documents/{id}/annotations` | — | `{ data: Annotation[], updatedAt }` | +| PUT | `/documents/{id}/annotations` | `{ data: Annotation[], baseUpdatedAt? }` | `{ updatedAt }` | + +`baseUpdatedAt` (optional) lets the server warn on stale-tab overwrite (returns `409` with current state if provided and mismatched; client may force with a retry omitting it). + +### 10.4 Assets + +| Method | Path | Body | Returns | +|---|---|---|---| +| POST | `/documents/{id}/assets` | multipart `file` | `{ ref, url }` | +| GET | `/documents/{id}/assets/{ref}` | — | binary (image/png etc.) | + +### 10.5 Versions + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/documents/{id}/versions` | — | `{ items: VersionMeta[] }` | +| POST | `/documents/{id}/versions` | `{ label? }` | `VersionMeta` (201) | +| GET | `/documents/{id}/versions/{vid}` | — | `{ data: Annotation[], meta }` | +| POST | `/documents/{id}/versions/{vid}/restore` | — | `{ updatedAt }` | +| DELETE | `/documents/{id}/versions/{vid}` | — | `204` | + +`VersionMeta`: `{ id, label, kind, createdAt, annotationCount }`. + +### 10.6 Export + +| Method | Path | Body | Returns | +|---|---|---|---| +| POST | `/documents/{id}/export` | `{ versionId?, flatten: true }` | `application/pdf` (synchronous; single-user scale) | + +If export ever becomes slow for huge files, migrate to a job pattern (`202` + `GET /jobs/{id}`). The endpoint shape is designed so this is additive, not breaking. + +### 10.7 Health + +| Method | Path | Returns | +|---|---|---| +| GET | `/health` | `{ status: "ok", version }` | + +### 10.8 OpenAPI is generated, not hand-maintained +FastAPI emits `/api/v1/openapi.json`. The frontend's typed API client and `types/` are generated from it (e.g. `openapi-typescript`). **Agents must regenerate types after any schema change** rather than hand-editing them. This keeps frontend and backend in lockstep. + +--- + +## 11. Authentication & Security + +- **Single password, no username.** Stored as Argon2id hash in `settings.password_hash`. +- **First-run setup:** if `setupRequired`, the UI shows a "set your password" screen before anything else. `/auth/setup` works only while no hash exists. +- **Session:** on login, issue a signed token (itsdangerous or JWT) in a cookie that is `httpOnly`, `Secure`, `SameSite=Lax`, with a sane expiry (e.g., 7 days, sliding). No token in localStorage (XSS-safe). +- **Login rate limiting:** exponential backoff / lockout after N failed attempts per IP (e.g., 5 attempts → 15-min lockout). In-memory counter is fine for single-user; document it. +- **CSRF:** because auth is cookie-based, protect state-changing routes. With `SameSite=Lax` plus a custom `X-Requested-With` header required on mutating requests (the SPA always sends it; cross-site form posts can't), CSRF is adequately mitigated for single-user. Document the reasoning. +- **Upload validation:** verify magic bytes (`%PDF-`), enforce a max size (config), and open with PyMuPDF in a guarded try/except to reject malformed/malicious files before storing. Never trust the client-supplied content-type or filename; sanitize filenames. +- **Path safety:** all file paths derive from server-generated UUIDs, never from user input. No user string ever becomes a filesystem path. +- **Secrets:** `SECRET_KEY`, cookie settings, max upload size, etc. come from environment variables (`.env`), never hardcoded. +- **Security headers** (set in nginx): `Content-Security-Policy` (restrict to self; allow the wasm/worker for PDF.js), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`. +- **TLS** is terminated at the user's outer reverse proxy. **In production, all traffic — LAN included — goes through that HTTPS proxy**, so `COOKIE_SECURE=true` and the browser always sees HTTPS; the `Secure` cookie flag is honored. **Direct HTTP access to the published port is only for local development/testing**, where `COOKIE_SECURE=false` is set so the session cookie works over plain HTTP. This single env var is the only difference between the two modes; document both in `.env.example`. + +--- + +## 12. Frontend Architecture + +### 12.1 Pages +- **LoginPage** — password entry (and first-run setup variant). +- **HomePage** — three zones in a clean light layout: + - **Recently edited** — cards (thumbnail, title, edited-time), sorted by `updatedAt`. + - **Upload** — prominent button **and** full-page drag-and-drop dropzone with progress. + - **Library tab** — grid/list of all PDFs with multi-select, search, and delete (single + bulk). Selection mode reveals a delete action bar. + - **Trash view** — soft-deleted documents with **restore** (single + bulk) and **permanent delete** / **empty trash**. Shows days remaining before auto-purge. +- **EditorPage** — the canvas workspace. + +### 12.2 Editor composition +- **PageStack** — renders each PDF page via PDF.js into a ``, lazily (virtualized) for large docs. +- **AnnotationLayer** — a Fabric.js canvas overlaid per page, sized to the rendered page, fed transforms from `lib/coords.ts`. +- **Toolbar** — tool selection (select, text, draw, signature, image, highlight, shape), color/size controls, zoom, undo/redo, save-status indicator, export. +- **Tool modules** (`editor/tools/*`) — each tool implements a small interface: `onActivate`, `onPointerDown/Move/Up`, `createAnnotation`, `renderToFabric`. Registered in a frontend tool registry mirroring the backend handler registry. +- **VersionsPanel** — list, label, restore, delete; opens as a side drawer. + +### 12.3 Editor state (Zustand) +- `document`, `annotations` (working set), `selection`, `activeTool`, `zoom`, `saveStatus`. +- **Undo/redo:** a command-stack history of annotation-set mutations (add/modify/delete/transform). Each user action pushes an inverse command. History is per-document and in-memory (lost on reload — acceptable; persisted versions cover durable history). Keep the stack bounded (e.g., 100 steps). +- Autosave subscribes to annotation-set changes, debounced. + +### 12.5 Signature capture +Triggered by the signature tool, a modal offers two tabs (Sejda-style): +- **Draw** — a freehand canvas (smoothed strokes) where the user signs with mouse/trackpad/touch/stylus. On confirm, the drawn signature is rasterized to a transparent PNG, uploaded via the assets endpoint, and placed as a `signature` annotation with `mode: "draw"` referencing the asset. +- **Type** — a text input plus a **live preview that renders the typed text simultaneously in several bundled signature/script fonts** (the same faces available server-side for export). The user picks a font; on confirm a `signature` annotation with `mode: "type"` is created carrying the text, chosen `fontFamily`, and color. + +Once placed, a signature behaves like any other annotation — drag, resize, rotate, delete, undo/redo. Recently used signatures may be offered for quick re-insertion (future enhancement; not required for v1). Because typed signatures are stored as text + font (not flattened to an image until export), they stay crisp at any zoom and re-render identically on the server. + +### 12.6 UI / visual design direction +Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrained palette (one accent color), soft shadows, rounded corners, clear iconography. Tailwind tokens defined once in `tailwind.config.ts`. The editor chrome stays out of the way; the page is the focus. Mobile/touch: the canvas supports pinch-zoom and touch drag; toolbar collapses into an overflow menu on narrow viewports. Define a small design-token set (spacing scale, radius, accent, neutrals) up front so all three agents produce visually consistent components. + +--- + +## 13. Docker Compose & Deployment + +```yaml +# docker-compose.yml (illustrative; agents finalize) +services: + backend: + build: ./backend + environment: + - SECRET_KEY=${SECRET_KEY} + - MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200} + - TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30} + - AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30} + - COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy) + volumes: + - pdf_storage:/data/pdfs + - thumbnails:/data/thumbnails + - db:/data/db + expose: + - "8000" + restart: unless-stopped + + frontend: + build: ./frontend + depends_on: [backend] + ports: + - "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this + restart: unless-stopped + +volumes: + pdf_storage: + thumbnails: + db: +``` + +- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `backend:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated. +- A 200 MB ceiling comfortably covers 100–200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large. +- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs). +- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image. +- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups. + +--- + +## 14. Extensibility / Future Features (design for, don't build) + +| Future feature | How the design already accommodates it | +|---|---| +| New annotation types (stamp, redaction, checkbox) | Annotation registry + opaque server storage; no schema/API change | +| OCR / searchable text | Add as an "annotation source" that emits `text` annotations; export already handles text | +| Form filling (AcroForms) | Add a `formfield` annotation type + PyMuPDF widget handler; API unchanged | +| Async export for huge files | Export endpoint shape allows `202 + job` migration additively | +| Multi-user | Add a `users` table + per-row ownership; auth already cookie/session based | +| Page operations (merge/split/rotate/reorder) | New document-level endpoints; PyMuPDF supports natively, no new programs | +| Real-time collaboration | Autosave is full-state today; swap to CRDT/WebSocket later without changing storage canon | + +**Rule:** never hardcode the v1 tool list anywhere that would require editing in multiple places to extend. Tools and export handlers are discovered from their registries. + +--- + +## 15. Rules for AI Agents + +Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the whole file) into every working session. + +1. **Read `ARCHITECTURE.md` (this file) fully before writing code.** Do not infer architecture from existing partial code. +2. **Conform to the API contract.** Never invent endpoint shapes, field names, or status codes. If the contract is insufficient, propose a change to this document first; do not improvise divergent shapes. +3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8). +4. **Store coordinates only in canonical space.** Never persist screen pixels. +5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`. +6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the existing two images are fine. If a task seems to need an external dependency, stop and flag it; do not add one. +7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing. +8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material. +9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass. +10. **Small, single-purpose changes.** One feature or fix per change set. Conventional-commit messages. Don't refactor unrelated code in a feature change. +11. **Frontend is TypeScript strict; backend is fully type-hinted.** No `any` on the frontend without a written justification. CI runs lint + typecheck + tests and must pass. +12. **Respect the rendering split:** PDF.js renders for display; PyMuPDF writes for export. Don't introduce a second display renderer or a second writer. +13. **Idempotent, full-state autosave.** Don't switch to diff-based saving without updating this document and the version model. +14. **When uncertain, ask the plan, not the vibe.** If two reasonable implementations exist, pick the one this document specifies; if it doesn't specify, propose an amendment here before coding. + +--- + +## 16. Implementation Phases + +Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early. + +**Phase 0 — Scaffolding** +Monorepo, both Dockerfiles, compose, `.env.example`, CI (lint/typecheck/test), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds. + +**Phase 1 — Auth & Library core** +First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + **trash/restore** (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). *Done when:* a user can log in, upload, see, delete, and restore PDFs. + +**Phase 2 — Coordinate module & rendering (CRITICAL)** +`lib/coords.ts` with full unit tests (round-trip across zoom/rotation/DPR). PDF.js page rendering in the editor. The cross-engine verification gate (§8) against PyMuPDF. *Done when:* the verification gate passes. No annotation tools before this. + +**Phase 3 — Annotation engine & text tool** +Annotation data model end-to-end, Fabric.js overlay, the `text` tool (add/move/resize/edit), working-state GET/PUT, debounced autosave, save-status UI. *Done when:* a text box can be placed, moved, autosaved, reloaded, and round-trips through canonical space correctly. + +**Phase 4 — Remaining tools** +draw, **signature (draw + type-to-signature with live multi-font preview)**, image (+ assets endpoint), highlight, shape — each via the tool/handler registries. *Done when:* all v1 tools place and persist. + +**Phase 5 — Versioning & undo/redo** +Manual + auto checkpoints (on close and before destructive ops), versions panel, restore, retention prune; in-memory undo/redo command stack. *Done when:* history can be checkpointed, browsed, and restored; undo/redo works within a session. + +**Phase 6 — Export** +PyMuPDF flatten via the handler registry, bundled fonts, export of working state or a chosen version. *Done when:* exported PDFs match the on-screen editor pixel-for-pixel across all tools, rotations, and zoom levels. + +**Phase 7 — Polish** +Sejda-grade light UI pass, mobile/touch refinement, error states, empty states, large-document virtualization, accessibility basics, backup documentation. + +--- + +## 17. Resolved Decisions (formerly open questions) + +All four open questions are now settled: + +- **Upload ceiling:** 200 MB (`MAX_UPLOAD_MB=200`), comfortably covering 100–200 page documents with embedded images. No separate page-count ceiling; PDF.js virtualization handles large docs. The same limit must be raised in both the app's nginx and the user's outer reverse proxy. +- **Versioning purpose & policy:** the safety net against accidental loss. A whole-document accidental delete is recovered via **trash/restore** (30-day retention). Accidental annotation loss inside a document is recovered via **version checkpoints** — taken manually anytime, and automatically on document close and before destructive operations (clear-all, restore). Manual versions kept indefinitely; auto versions retained 30 days. +- **Networking:** production routes **all** traffic (LAN included) through the HTTPS reverse proxy with `COOKIE_SECURE=true`. Direct HTTP to the published port is **dev/testing only**, with `COOKIE_SECURE=false`. +- **Signature capture:** two modes — **draw on canvas**, and **type-to-signature** with multiple bundled signature fonts and a Sejda-style live preview rendering the typed text in each font as you type. Both stored as `signature` annotations (`mode: "draw"` | `mode: "type"`); the same fonts are bundled server-side so export matches the preview exactly. + +--- + +*End of plan v1.1. Amendments are made to this document first, then to code.*