diff --git a/.env.example b/.env.example index a35c90e..9e0d731 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ INKNOTE_ADMIN_PASSWORD= # That and the alternative: readme.md → Where the data lives. # ── Optional ── unset keeps the default shown +# INKNOTE_MAX_IMAGE_MB=25 # pasted images +# INKNOTE_MAX_PDF_MB=200 # imported PDFs, limited separately # INKNOTE_UID=1000 # must own INKNOTE_DATA_DIR # INKNOTE_GID=1000 # MAX_VERSIONS_PER_NOTE=50 diff --git a/backend/server.js b/backend/server.js index 1490a76..abca15e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -59,6 +59,20 @@ const JWT_SECRET = requireSecret("JWT_SECRET"); const ENCRYPTION_KEY = requireSecret("ENCRYPTION_KEY", { hexBytes: 32 }); const ENCRYPTION_KEY_BUF = Buffer.from(ENCRYPTION_KEY, "hex"); +/** Reads a positive number from the environment, or falls back. */ +const envNum = (name, fallback) => { + const v = Number(process.env[name]); + return Number.isFinite(v) && v > 0 ? v : fallback; +}; +const mb = (name, fallbackMb) => envNum(name, fallbackMb) * 1024 * 1024; + +/* Images and PDFs are limited separately: a picture that large is almost always + * a mistake, while a scanned book legitimately is. Both are generous because + * this is a private instance — the limits exist so a runaway upload cannot fill + * the disk, not to ration anything. */ +const MAX_IMAGE_BYTES = mb("INKNOTE_MAX_IMAGE_MB", 25); +const MAX_PDF_BYTES = mb("INKNOTE_MAX_PDF_MB", 200); + // Which origins may call the API. Same-origin deployments (nginx in front) need // none; set a comma-separated list only if you serve the UI from another host. const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "") @@ -282,6 +296,13 @@ if (!tableColumns("notes").includes("drawing_strokes")) { db.exec("ALTER TABLE notes ADD COLUMN drawing_strokes BLOB"); } +// A PDF note points at the attachment holding its file. The old pdf_path column +// stored a filesystem path instead, which is why it never survived a container +// rebuild; attachments are content-addressed, encrypted and deduplicated. +if (!tableColumns("notes").includes("pdf_attachment_id")) { + db.exec("ALTER TABLE notes ADD COLUMN pdf_attachment_id TEXT"); +} + if (!tableColumns("users").includes("is_admin")) { db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"); } @@ -474,7 +495,9 @@ const app = express(); app.set("trust proxy", 1); // nginx sits in front; needed for real client IPs app.use(helmet()); app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : { origin: false })); -app.use(express.json({ limit: "25mb" })); +// Images arrive base64-encoded inside JSON, which inflates them by a third; +// PDFs take the raw-body route below and never pass through here. +app.use(express.json({ limit: `${Math.ceil((MAX_IMAGE_BYTES / 1048576) * 1.4)}mb` })); /* Password checks are deliberately slow, which makes them both a brute-force * defence and a denial-of-service lever. Cap attempts per username+IP. */ @@ -761,7 +784,7 @@ function isBigShrink(previous, next) { /* ─── Note routes ─── */ app.get("/api/notes", authMiddleware, (req, res) => { - const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId); + const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId); res.json(notes); }); @@ -817,13 +840,13 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => { }); app.post("/api/notes", authMiddleware, (req, res) => { - const { type, title, content, notebookId, starred } = req.body; + const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body; const id = uuid(); const encContent = content ? encrypt(content) : null; - db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) - .run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, req.deviceId); + db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId); db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)") .run(req.userId, id, req.deviceId); @@ -1078,7 +1101,7 @@ const ATTACHMENT_DIR = path.join(UPLOAD_DIR, "attachments"); if (!fs.existsSync(ATTACHMENT_DIR)) fs.mkdirSync(ATTACHMENT_DIR, { recursive: true }); const ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]); -const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024; + /** * Encrypts any attachment still lying on disk in the clear. @@ -1127,7 +1150,8 @@ app.post("/api/attachments", authMiddleware, (req, res) => { const buf = Buffer.from(m[2], "base64"); if (buf.length === 0) return res.status(400).json({ error: "Empty file" }); - if (buf.length > MAX_ATTACHMENT_BYTES) return res.status(413).json({ error: "File too large" }); + if (buf.length > MAX_IMAGE_BYTES) + return res.status(413).json({ error: `Image is larger than ${Math.round(MAX_IMAGE_BYTES / 1048576)} MB` }); // Trust the bytes, not the declared type. const sniffed = detectImageMime(buf); @@ -1151,6 +1175,47 @@ app.post("/api/attachments", authMiddleware, (req, res) => { res.json({ id, mime, size: buf.length, deduplicated: false }); }); +/** + * Takes a PDF and stores it like any other attachment: encrypted, named by the + * hash of its contents, shared between notes that hold the same file. + * + * Raw body rather than the base64 JSON the image route uses — a scanned book + * runs to tens of megabytes, and base64 would add a third to that for no gain + * on a path that carries nothing but the file itself. + */ +app.post( + "/api/attachments/pdf", + authMiddleware, + express.raw({ type: ["application/pdf", "application/octet-stream"], limit: MAX_PDF_BYTES }), + (req, res) => { + const buf = req.body; + if (!Buffer.isBuffer(buf) || buf.length === 0) return res.status(400).json({ error: "No file" }); + + // Trust the bytes, not the header. Every PDF starts with %PDF-. + if (buf.subarray(0, 5).toString("ascii") !== "%PDF-") { + return res.status(415).json({ error: "That does not look like a PDF" }); + } + + const filename = typeof req.query.filename === "string" ? req.query.filename.slice(0, 200) : null; + const sha256 = crypto.createHash("sha256").update(buf).digest("hex"); + const existing = db + .prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?") + .get(req.userId, sha256); + if (existing) { + return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true }); + } + + const filePath = path.join(ATTACHMENT_DIR, sha256); + if (!fs.existsSync(filePath)) writeFileAtomic(filePath, encryptBuffer(buf)); + + const id = uuid(); + db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)") + .run(id, req.userId, sha256, "application/pdf", buf.length, filename); + + res.json({ id, mime: "application/pdf", size: buf.length, deduplicated: false }); + } +); + app.get("/api/attachments/:id", authMiddleware, (req, res) => { const a = db.prepare("SELECT * FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!a) return res.status(404).json({ error: "Not found" }); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 64de105..fb05e18 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -31,6 +31,8 @@ services: - NODE_ENV=development # Optional tuning; unset means the server's own defaults apply. - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} + - INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-} + - INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-} - MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-} - VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-} - VERSION_COALESCE_MINUTES=${VERSION_COALESCE_MINUTES:-} diff --git a/docker-compose.yml b/docker-compose.yml index 8034d07..1c6cc1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: - NODE_ENV=production # Optional tuning; unset means the server's own defaults apply. - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} + - INKNOTE_MAX_IMAGE_MB=${INKNOTE_MAX_IMAGE_MB:-} + - INKNOTE_MAX_PDF_MB=${INKNOTE_MAX_PDF_MB:-} - MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-} - VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-} - VERSION_COALESCE_MINUTES=${VERSION_COALESCE_MINUTES:-} diff --git a/readme.md b/readme.md index 07027d0..71ac202 100644 --- a/readme.md +++ b/readme.md @@ -409,6 +409,27 @@ a snippet around the first match. --- +## PDF storage + +An imported PDF is stored as an attachment: encrypted, named by the hash of its +contents, and shared by any note holding the same file. The note points at it +through `notes.pdf_attachment_id`. + +It goes up as a raw body rather than the base64 JSON images use — a scanned book +runs to tens of megabytes and base64 would add a third to that for nothing. The +bytes are checked for the `%PDF-` signature before anything is stored, so the +declared content type is never taken on trust. + +Images and PDFs are limited separately, because a 25 MB picture is nearly always +a mistake while a 25 MB document is not: `INKNOTE_MAX_IMAGE_MB` (25) and +`INKNOTE_MAX_PDF_MB` (200). The limits are there so a runaway upload cannot fill +the disk, not to ration anything. + +> An earlier `POST /api/notes/pdf` stored a *filesystem path* in the note, which +> is why it never survived a container rebuild. That route is gone. + +--- + ## Encryption at rest Note bodies and uploaded files are both encrypted with AES-256-GCM under @@ -451,6 +472,7 @@ on every keystroke. They are now separate blobs; the body only keeps an id. | Method | Endpoint | Description | |--------|-------------------------|------------------------------------| +| POST | `/api/attachments/pdf` | raw PDF body, `?filename=` | `{id, mime, size, deduplicated}` | | POST | `/api/attachments` | Upload a base64 data URL | | GET | `/api/attachments/:id` | Fetch the bytes (owner only) |