Store imported PDFs as encrypted, deduplicated attachments

Stage two of PDF support: the file can now get in and out. No viewer
yet — that is next.

A PDF is an attachment like any other, so it inherits everything already
built: encryption at rest, naming by content hash, deduplication, the
orphan sweep, and the authenticated retrieval route the client already
caches offline. The note points at it through notes.pdf_attachment_id.

Raw request body rather than the base64 JSON images travel in. A scanned
book is tens of megabytes and base64 adds a third to that, on a path that
carries nothing but the file. The bytes are checked for %PDF- before
anything is written, so the declared content type is never trusted.

Images and PDFs are limited separately — INKNOTE_MAX_IMAGE_MB (25) and
INKNOTE_MAX_PDF_MB (200) — because a 25 MB picture is nearly always a
mistake and a 25 MB document is not. Both configurable; the limits exist
so a runaway upload cannot fill the disk, not to ration anything. The
JSON body limit now follows the image limit instead of being a second
number that could drift away from it.

Verified against a real PDF: uploaded, stored with the INKENC01 header,
returned byte-identical, deduplicated on a second upload, a GIF renamed
to .pdf refused with 415, unauthenticated refused with 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 20:06:34 +02:00
1 parent 915856e1a4
commit 53965281b4
5 files changed
+101 -8

No files matched your search

+22
View File
@@ -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) |