TipTap editor, version history, search, attachments — and a cleanup

The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.

Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
  missing; it refuses to start. The old fallback silently made every stored
  note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
  not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
  already eaten drawing strokes and note links; sync.hasPendingWrites() now
  guards every path that takes a body from the server.

Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
  to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
  into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
  encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
  fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
  the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.

Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
  covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
  unreadable.

Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
  search, layout, history and drag each live behind a hook, and the four
  panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
  the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
  restoring a history entry pushed a duplicate one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-07 17:00:14 +02:00
1 parent d97591b242
commit 92d525b3ab
71 files changed
+14354 -1826

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
/**
* Attachment plumbing for the editor.
*
* An `<img>` tag cannot carry an Authorization header, so attachment bytes are
* fetched with an authenticated request, kept in IndexedDB and handed to the
* DOM as a blob URL. That also means images render offline, and each one is
* downloaded at most once per device.
*/
import * as sync from "../syncEngine";
import * as db from "../offlineStore";
const _urls = new Map(); // attachmentId -> object URL
const _inflight = new Map(); // attachmentId -> Promise<string>
export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
export const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"];
const readAsDataUrl = (file) =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error || new Error("Could not read file"));
r.readAsDataURL(file);
});
/** Uploads a pasted or dropped image and returns its attachment id. */
export async function uploadImage(file) {
if (!ACCEPTED_TYPES.includes(file.type))
throw new Error(`Unsupported image type: ${file.type || "unknown"}`);
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("Image is larger than 15 MB");
const dataUrl = await readAsDataUrl(file);
const { id } = await sync.uploadAttachment(dataUrl, file.name);
// Seed the cache from the bytes already in hand — no round trip to display it.
try {
await db.putAttachmentBlob(id, file);
} catch {
/* cache is best-effort */
}
return id;
}
/** A displayable URL for an attachment, from cache when possible. */
export function resolveUrl(id) {
if (!id) return Promise.resolve(null);
const cached = _urls.get(id);
if (cached) return Promise.resolve(cached);
if (_inflight.has(id)) return _inflight.get(id);
const job = (async () => {
let blob = await db.getAttachmentBlob(id).catch(() => null);
if (!blob) {
blob = await sync.fetchAttachmentBlob(id);
db.putAttachmentBlob(id, blob).catch(() => {});
}
const url = URL.createObjectURL(blob);
_urls.set(id, url);
return url;
})().finally(() => _inflight.delete(id));
_inflight.set(id, job);
return job;
}
/* Object URLs live as long as the page does. They are a few dozen bytes of
* bookkeeping each and the same image usually reappears as the user moves
* between notes, so they are kept rather than revoked per unmount. */
export function releaseAll() {
for (const url of _urls.values()) URL.revokeObjectURL(url);
_urls.clear();
}