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

+2
View File
@@ -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
+73 -8
View File
@@ -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" });
+2
View File
@@ -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:-}
+2
View File
@@ -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:-}
+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) |