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>
74 lines
2.5 KiB
JavaScript
74 lines
2.5 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|