Consolidate Docker deployment into a single PaperJet container
This commit is contained in:
parent
db9e2ca51b
commit
65abb5154e
13 changed files with 290 additions and 151 deletions
|
|
@ -1,10 +1,10 @@
|
|||
# Self-Hosted PDF Editor — Implementation Plan
|
||||
|
||||
**Project codename:** `paperjet` (rename freely)
|
||||
**Document version:** 1.1
|
||||
**Document version:** 1.2
|
||||
**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**.
|
||||
> **v1.2 changes:** the runtime deployment is consolidated into one Docker image containing nginx, the compiled SPA, and FastAPI/uvicorn. The existing v1.1 decisions remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
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.
|
||||
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 image are permitted.
|
||||
|
||||
### Non-goals (v1)
|
||||
- Multi-user collaboration / real-time co-editing
|
||||
|
|
@ -78,8 +78,8 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
|
|||
| 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 |
|
||||
| Web server | nginx | Serves the static React build and proxies /api to loopback uvicorn |
|
||||
| ASGI server | uvicorn | Runs on loopback inside the same application image |
|
||||
| Orchestration | Docker Compose | |
|
||||
| External programs / containers / APIs | **None** | Hard requirement |
|
||||
|
||||
|
|
@ -98,43 +98,44 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
|
|||
┌─────────────▼─────────────────────────────┐
|
||||
│ Docker Compose │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ frontend │ │ backend │ │
|
||||
│ │ nginx │─────▶│ FastAPI │ │
|
||||
│ │ serves SPA │ /api │ uvicorn │ │
|
||||
│ │ proxies /api│ │ PyMuPDF │ │
|
||||
│ └──────────────┘ │ SQLAlchemy │ │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼────────┐ │
|
||||
│ │ volumes │ │
|
||||
│ │ pdf_storage/ │ │
|
||||
│ │ thumbnails/ │ │
|
||||
│ │ db/app.sqlite │ │
|
||||
│ └───────────────────┘ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ paperjet image │ │
|
||||
│ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │
|
||||
│ │ React SPA FastAPI + PyMuPDF │ │
|
||||
│ └───────────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼────────┐ │
|
||||
│ │ 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.
|
||||
- The single `paperjet` container serves the SPA and reverse-proxies `/api/*` to its loopback-only FastAPI process. The browser sees a single origin — no CORS complexity, cookies "just work."
|
||||
- The user's outer reverse proxy points at the published port of the `paperjet` container.
|
||||
|
||||
---
|
||||
|
||||
## 5. Repository Structure
|
||||
|
||||
A single monorepo. Two deployable images.
|
||||
A single monorepo with one deployable runtime image. The Dockerfile uses a
|
||||
Node build stage and a Python/nginx runtime stage.
|
||||
|
||||
```
|
||||
paperjet/
|
||||
├── README.md
|
||||
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── docker-entrypoint.sh
|
||||
├── .dockerignore
|
||||
├── .env.example
|
||||
├── .github/workflows/ci.yml # lint + typecheck + test on push
|
||||
├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx
|
||||
│ ├── nginx.conf # SPA fallback + /api proxy
|
||||
│ ├── nginx.conf # SPA fallback + loopback /api proxy
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json # strict: true
|
||||
│ ├── vite.config.ts
|
||||
|
|
@ -163,7 +164,6 @@ paperjet/
|
|||
│ └── types/ # generated from backend OpenAPI
|
||||
│
|
||||
├── backend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── pyproject.toml
|
||||
│ ├── alembic/ # migrations
|
||||
│ └── app/
|
||||
|
|
@ -342,7 +342,7 @@ In-document accidental edits during a single session are also covered by the in-
|
|||
- 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.
|
||||
- Fonts: bundle inside the application 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 }`.
|
||||
|
|
@ -499,29 +499,29 @@ Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrain
|
|||
## 13. Docker Compose & Deployment
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (illustrative; agents finalize)
|
||||
# docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
paperjet:
|
||||
image: ${PAPERJET_IMAGE:-paperjet:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
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)
|
||||
PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
|
||||
PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
|
||||
PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
|
||||
PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30}
|
||||
PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
PAPERJET_DATABASE_PATH: /data/db/app.sqlite
|
||||
PAPERJET_PDF_STORAGE_PATH: /data/pdfs
|
||||
PAPERJET_THUMBNAILS_PATH: /data/thumbnails
|
||||
PAPERJET_DEBUG: ${DEBUG:-false}
|
||||
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
|
||||
- "${HTTP_PORT:-4982}:80"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
|
@ -530,10 +530,10 @@ volumes:
|
|||
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.
|
||||
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `127.0.0.1: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.
|
||||
- The root Dockerfile builds the SPA with Node, installs production Python dependencies and bundled fonts, and copies both into one nginx/Python runtime image.
|
||||
- docker-entrypoint.sh supervises nginx and loopback uvicorn so a failed child process stops the container cleanly.
|
||||
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
|
||||
|
||||
---
|
||||
|
|
@ -563,7 +563,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
|
|||
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.
|
||||
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the application image 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.
|
||||
|
|
@ -580,7 +580,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
|
|||
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.
|
||||
Monorepo, the root Dockerfile and entrypoint, single-service Compose, `.env.example`, CI (lint/typecheck/test/container smoke), 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.
|
||||
|
|
@ -616,4 +616,4 @@ All four open questions are now settled:
|
|||
|
||||
---
|
||||
|
||||
*End of plan v1.1. Amendments are made to this document first, then to code.*
|
||||
*End of plan v1.2. Amendments are made to this document first, then to code.*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue