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:
1 parent
915856e1a4
commit
53965281b4
5 files changed
+101
-8
No files matched your search
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
Reference in new issue
Block a user