notes: private Obsidian vault tracker at /trackers/notes
All checks were successful
Deploy production / deploy (push) Successful in 1m19s

Vault files stay the source of truth (bind-mounted read-write at /notes):
atomic writes chowned to the vault owner, hash-checked saves (409 on a
newer file), deletes to .trash. notes.* is a full-text/link index kept
current by a recursive watcher plus a 60s rescan; live updates over the
owner-only /ws/notes channel (paths and hashes only). Wikilinks, embeds,
backlinks, tags, callouts, image paste. Owner-only everywhere (404).
This commit is contained in:
2026-10-26 01:27:41 +00:00
parent 6654c0990a
commit e33afe5387
28 changed files with 44610 additions and 3 deletions

View File

@@ -0,0 +1,22 @@
import { requireOwner } from '#lib/server/owner.js';
import { allEntries, isReady } from '#lib/server/notes/indexer.js';
import { vaultAvailable } from '#lib/server/notes/vault.js';
import type { LayoutServerLoad } from './$types';
// Owner only, always: everyone else (logged out, or any other account) gets a plain 404, and
// hooks.server.ts blocks the whole /trackers/notes prefix (pages, API, files) as a backstop.
// The tree comes from the in-memory index; it doesn't depend on the URL, so switching notes
// (?note=…) only reruns the page load.
export const load: LayoutServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
const available = await vaultAvailable();
return {
available,
ready: isReady(),
/** [path, kind (n/a/c), mtime] per file: compact, the tree and link resolver need no more. */
tree: allEntries().map(
(e) => [e.path, e.kind === 'note' ? 'n' : e.kind === 'conflict' ? 'c' : 'a', e.mtime] as const
)
};
};

View File

@@ -0,0 +1,23 @@
import { requireOwner } from '#lib/server/owner.js';
import { backlinks } from '#lib/server/notes/indexer.js';
import { readNote, VaultError } from '#lib/server/notes/vault.js';
import { cleanVaultPath, kindOf } from '#lib/notes/shared.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const path = cleanVaultPath(event.url.searchParams.get('note'));
if (!path || kindOf(path) === 'attachment')
return { note: null, missing: !!event.url.searchParams.get('note') };
try {
const note = await readNote(path);
return {
note: { path, ...note, backlinks: await backlinks(path).catch(() => []) },
missing: false
};
} catch (e) {
if (e instanceof VaultError && (e.status === 404 || e.status === 400))
return { note: null, missing: true };
throw e;
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { backlinks, touch } from '#lib/server/notes/indexer.js';
import { readNote, trashFile, writeFileAtomic } from '#lib/server/notes/vault.js';
import { noStore, vaultFail } from '#lib/server/notes/http.js';
import { cleanVaultPath, extOf, kindOf } from '#lib/notes/shared.js';
import type { RequestHandler } from './$types';
// Read, save and delete one note. Saves carry the hash the editor loaded (baseHash): if the file
// changed on disk since (Obsidian on another device, Syncthing), the save is refused with 409
// and the current hash, never silently overwriting the newer file.
const MAX_NOTE = 5 * 1024 * 1024;
function pathOf(v: unknown, { notesOnly = true } = {}) {
const path = cleanVaultPath(v);
if (!path) error(400, 'Bad path');
if (notesOnly && extOf(path) !== 'md') error(400, 'Only .md notes');
return path;
}
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const path = pathOf(event.url.searchParams.get('path'), { notesOnly: false });
if (kindOf(path) === 'attachment') error(400, 'Not a note');
try {
const note = await readNote(path);
const withLinks = event.url.searchParams.get('backlinks') === '1';
return json(
{ path, ...note, ...(withLinks ? { backlinks: await backlinks(path) } : {}) },
{ headers: noStore }
);
} catch (e) {
return vaultFail(e);
}
};
export const PUT: RequestHandler = async (event) => {
requireOwner(event);
const body = (await event.request.json().catch(() => null)) as {
path?: string;
text?: string;
baseHash?: string | null;
force?: boolean;
} | null;
if (!body || typeof body.text !== 'string') error(400, 'Bad request');
const path = pathOf(body.path);
if (Buffer.byteLength(body.text) > MAX_NOTE) error(413, 'Note too large');
const base = body.force ? undefined : body.baseHash === null ? null : body.baseHash;
if (!body.force && base !== null && typeof base !== 'string') error(400, 'baseHash required');
try {
const r = await writeFileAtomic(path, body.text, base);
await touch(path);
return json({ path, ...r }, { headers: noStore });
} catch (e) {
return vaultFail(e);
}
};
export const DELETE: RequestHandler = async (event) => {
requireOwner(event);
const path = pathOf(event.url.searchParams.get('path'), { notesOnly: false });
const hash = event.url.searchParams.get('hash') ?? undefined;
try {
await trashFile(path, hash);
await touch(path);
return json({ ok: true }, { headers: noStore });
} catch (e) {
return vaultFail(e);
}
};

View File

@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { search, tagCounts } from '#lib/server/notes/indexer.js';
import { noStore } from '#lib/server/notes/http.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async (event) => {
requireOwner(event);
if (event.url.searchParams.has('tags'))
return json({ tags: await tagCounts() }, { headers: noStore });
const q = (event.url.searchParams.get('q') ?? '').slice(0, 200);
return json({ hits: await search(q) }, { headers: noStore });
};

View File

@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { allEntries } from '#lib/server/notes/indexer.js';
import { noStore } from '#lib/server/notes/http.js';
import type { RequestHandler } from './$types';
// The whole tree again (the page uses it after a WebSocket reconnect, in case it missed events).
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const tree = allEntries().map(
(e) => [e.path, e.kind === 'note' ? 'n' : e.kind === 'conflict' ? 'c' : 'a', e.mtime] as const
);
return json({ tree }, { headers: noStore });
};

View File

@@ -0,0 +1,39 @@
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { touch } from '#lib/server/notes/indexer.js';
import { fileExists, writeFileAtomic } from '#lib/server/notes/vault.js';
import { noStore, vaultFail } from '#lib/server/notes/http.js';
import { IMAGE_EXT, cleanVaultPath, extOf, folderOf } from '#lib/notes/shared.js';
import type { RequestHandler } from './$types';
// Pasted/dropped images, saved like Obsidian does with its default settings (no attachment
// folder configured): "Pasted image YYYYMMDDHHmmss.png" in the vault root.
const MAX = 20 * 1024 * 1024;
export const POST: RequestHandler = async (event) => {
requireOwner(event);
const form = await event.request.formData().catch(() => null);
const file = form?.get('file');
if (!(file instanceof File)) error(400, 'No file');
if (file.size > MAX) error(413, 'Too large');
const ext = (extOf(file.name) || file.type.split('/')[1] || '')
.toLowerCase()
.replace('jpeg', 'jpg');
if (!IMAGE_EXT.has(ext) && ext !== 'pdf') error(415, 'Images or PDFs only');
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
const folder = cleanVaultPath(form?.get('folder')) ?? '';
let name = `Pasted image ${stamp}.${ext}`;
for (let i = 1; await fileExists(folder ? `${folder}/${name}` : name); i++)
name = `Pasted image ${stamp} ${i}.${ext}`;
const path = folder ? `${folder}/${name}` : name;
if (folderOf(path) !== folder) error(400, 'Bad path');
try {
const r = await writeFileAtomic(path, new Uint8Array(await file.arrayBuffer()), null);
await touch(path);
return json({ path, name, ...r }, { headers: noStore });
} catch (e) {
return vaultFail(e);
}
};

View File

@@ -0,0 +1,48 @@
import { error } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { readBytes, VaultError } from '#lib/server/notes/vault.js';
import { cleanVaultPath, extOf } from '#lib/notes/shared.js';
import type { RequestHandler } from './$types';
// Attachments (images embedded with ![[…]], PDFs) straight from the vault, owner only.
const TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
pdf: 'application/pdf',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
mp4: 'video/mp4',
webm: 'video/webm'
};
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const path = cleanVaultPath(event.params.path);
if (!path) error(404, 'Not Found');
try {
const f = await readBytes(path);
const etag = `"${f.hash.slice(0, 32)}"`;
const headers: Record<string, string> = {
'content-type': TYPES[extOf(path)] ?? 'application/octet-stream',
'cache-control': 'private, max-age=300',
etag,
'x-content-type-options': 'nosniff',
// SVGs and anything else are never allowed to run script in the site's origin.
'content-security-policy':
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
'content-disposition': TYPES[extOf(path)] ? 'inline' : 'attachment'
};
if (event.request.headers.get('if-none-match') === etag)
return new Response(null, { status: 304, headers });
return new Response(f.buf, { headers });
} catch (e) {
if (e instanceof VaultError) error(404, 'Not Found');
throw e;
}
};