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

@@ -342,6 +342,28 @@ registered callback URL. The integration follows the installed Better Auth APIs;
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit) see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth). and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
## Notes
`/trackers/notes` (owner-only, always: every page, API, attachment and the `/ws/notes` socket 404s
for anyone else) is a web editor for Stan's Obsidian vault, two-way and near-real-time.
- **Source of truth:** the markdown files in `~/obsidian-vault` on the server (Syncthing folder
`obsidian-vault`, synced to his devices), bind-mounted read-write at `/notes` by
`scripts/deploy-remote.sh` (`NOTES_HOST_DIR`). The site reads and writes `.md` files directly:
atomic writes (hidden `.notes-tmp-*` file in the same folder, fsync, rename), chowned to the
vault owner, frontmatter and everything else kept byte-for-byte. Deleting moves the file to
`<vault>/.trash` like Obsidian does.
- **Index only:** `notes.files` / `notes.links` (full-text search, tags, wikilinks, backlinks) are
rebuilt from the files by `src/lib/server/notes/indexer.ts`: recursive `fs.watch` plus a full
rescan every minute. Dropping the tables loses nothing.
- **Live:** file changes (from Obsidian via Syncthing, or the site) are pushed to open pages over
`wss://stanrunge.dev/ws/notes` (paths and hashes only, never content). Saves carry the hash the
editor loaded; if the file changed in between the save is refused (409) and the page offers
load theirs / overwrite / save a copy.
- **Ignored:** anything under a dot entry (`.obsidian`, `.trash`, `.stfolder`, `.stversions`,
`.DS_Store`, Syncthing temps). `*.sync-conflict-*` files are listed as warnings, not indexed.
- Syncthing should ignore the site's write temps: `(?d).notes-tmp-*` in the vault's `.stignore`.
## Development ## Development
There are two environments: local dev and production. Prod is the source of truth There are two environments: local dev and production. Prod is the source of truth

View File

@@ -34,7 +34,8 @@ export default defineConfig({
'stats', 'stats',
'ai', 'ai',
'movies', 'movies',
'worldtodo' 'worldtodo',
'notes'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,

View File

@@ -0,0 +1,47 @@
CREATE SCHEMA "notes";
--> statement-breakpoint
CREATE TABLE "notes"."files" (
"path" text PRIMARY KEY,
"kind" text NOT NULL,
"folder" text NOT NULL,
"title" text NOT NULL,
"ext" text NOT NULL,
"size" bigint NOT NULL,
"mtime" timestamp with time zone NOT NULL,
"hash" text NOT NULL,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"frontmatter" jsonb,
"body" text,
"search" tsvector,
"indexed_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notes"."links" (
"src_path" text,
"seq" integer,
"target" text NOT NULL,
"target_key" text NOT NULL,
"embed" boolean DEFAULT false NOT NULL,
CONSTRAINT "links_pkey" PRIMARY KEY("src_path","seq")
);
--> statement-breakpoint
CREATE INDEX "files_search_idx" ON "notes"."files" USING gin ("search");--> statement-breakpoint
CREATE INDEX "files_tags_idx" ON "notes"."files" USING gin ("tags");--> statement-breakpoint
CREATE INDEX "files_folder_idx" ON "notes"."files" ("folder");--> statement-breakpoint
CREATE INDEX "files_kind_idx" ON "notes"."files" ("kind");--> statement-breakpoint
CREATE INDEX "links_target_key_idx" ON "notes"."links" ("target_key");--> statement-breakpoint
ALTER TABLE "notes"."links" ADD CONSTRAINT "links_src_path_files_path_fkey" FOREIGN KEY ("src_path") REFERENCES "notes"."files"("path") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
-- Privacy: notes are always private. Like tasks and health, only the app role can read or write
-- (no personal_ro); the owner-only checks live in the app. The tables are only a search/link
-- index rebuilt from the Obsidian vault files. Grants only run on prod (migrations as `personal`).
REVOKE ALL ON SCHEMA notes FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA notes TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA notes TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA notes
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -262,6 +262,30 @@ try {
const [{ n: worldtodoTasks }] = await client`select count(*)::int as n from worldtodo.tasks`; const [{ n: worldtodoTasks }] = await client`select count(*)::int as n from worldtodo.tasks`;
if (worldtodoTasks < 68) if (worldtodoTasks < 68)
throw new Error(`worldtodo.tasks has ${worldtodoTasks} rows, expected the 68 seed tasks`); throw new Error(`worldtodo.tasks has ${worldtodoTasks} rows, expected the 68 seed tasks`);
// Notes (always private; only a search/link index of the Obsidian vault): app role read/write,
// personal_ro none. 2 = files, links.
const NOTES_TABLES = 2;
const [notes] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'notes', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'notes' and c.relkind = 'r'
`;
if (
Number(notes.tables) !== NOTES_TABLES ||
Number(notes.app_rw) !== NOTES_TABLES ||
Number(notes.ro_read) !== 0 ||
notes.public_usage
)
throw new Error(`notes schema tables/grants are wrong: ${JSON.stringify(notes)}`);
// Energy tracker reads the ned-collector tables (SELECT only). // Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client` const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -271,7 +295,7 @@ try {
if (!Number(energy.hours)) if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days'); console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log( console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}` `health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
); );
const [catalog] = await client` const [catalog] = await client`
select select

View File

@@ -108,6 +108,30 @@ YAML
else else
echo "No $pubg_tiles_host_dir/erangel on this host; the PUBG map will have no tiles" >&2 echo "No $pubg_tiles_host_dir/erangel on this host; the PUBG map will have no tiles" >&2
fi fi
# Notes tracker: the Obsidian vault (Syncthing folder "obsidian-vault") is bind-mounted READ-WRITE
# at /notes. The files are the source of truth; the site edits them in place (atomic temp+rename,
# chowned to the vault owner) and keeps notes.* as a search/link index. Owner-only in the app.
notes_host_dir=${NOTES_HOST_DIR:-/home/stan/obsidian-vault}
case "$notes_host_dir" in /*) ;; *) echo 'NOTES_HOST_DIR must be absolute' >&2; exit 2 ;; esac
if sudo docker run --rm --entrypoint sh \
--mount "type=bind,source=$notes_host_dir,target=/notes,readonly" "$image" \
-c 'test -d /notes/.obsidian' 2>/dev/null; then
notes_override="$HOME/compose.notes.yml"
rm -f "$notes_override"
cat >"$notes_override" <<YAML
services:
app:
environment:
NOTES_ROOT: /notes
volumes:
- type: bind
source: $notes_host_dir
target: /notes
YAML
compose_files+=(-f "$notes_override")
else
echo "No Obsidian vault at $notes_host_dir on this host; the notes tracker will be empty" >&2
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1) previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
switched=0 switched=0
rollback() { rollback() {

View File

@@ -4,6 +4,8 @@ import { startGamesPoller } from '#lib/server/games/poller.js';
import { startOsuGoalsPoller } from '#lib/server/osu-goals/poller.js'; import { startOsuGoalsPoller } from '#lib/server/osu-goals/poller.js';
import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js'; import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
import { registerWorldtodoChannels } from '#lib/server/worldtodo/live.js'; import { registerWorldtodoChannels } from '#lib/server/worldtodo/live.js';
import { registerNotesChannel } from '#lib/server/notes/live.js';
import { startNotesIndexer } from '#lib/server/notes/indexer.js';
import { building } from '$app/env'; import { building } from '$app/env';
import { auth } from '#lib/server/auth.js'; import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js'; import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
@@ -13,7 +15,8 @@ const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected) // Background jobs that live in the app process: the Spotify scrobbler (idle until connected)
// the CS2 / PUBG / R6 match history poller and the osu! goals refresh. Also the WebSocket channels (served by // the CS2 / PUBG / R6 match history poller and the osu! goals refresh. Also the WebSocket channels (served by
// server.js on the same Bun server): the PUBG drop map rooms and the worldwide todo list. // server.js on the same Bun server): the PUBG drop map rooms, the worldwide todo list and the
// notes live updates.
export const init: ServerInit = () => { export const init: ServerInit = () => {
if (!building) { if (!building) {
startMusicPoller(); startMusicPoller();
@@ -21,6 +24,9 @@ export const init: ServerInit = () => {
startOsuGoalsPoller(); startOsuGoalsPoller();
registerPubgMapChannel(); registerPubgMapChannel();
registerWorldtodoChannels(); registerWorldtodoChannels();
// Notes: index the Obsidian vault (NOTES_ROOT) and push file changes to /ws/notes (owner only).
registerNotesChannel();
void startNotesIndexer().catch((e) => console.error('[notes] indexer failed to start', e));
} }
}; };

175
src/lib/notes/render.ts Normal file
View File

@@ -0,0 +1,175 @@
import { Marked, type Tokens } from 'marked';
import {
IMAGE_EXT,
baseName,
extOf,
noteName,
parseWikiInner,
splitFrontmatter,
type Resolver
} from './shared';
// Markdown preview for the notes editor (browser side). Obsidian flavoured: [[wikilinks]] and
// ![[embeds]] resolved against the vault, #tags, ==highlights==, callouts. Raw HTML in a note is
// shown as text, and links only keep http(s)/mailto/tel or vault-relative targets.
const esc = (s: string) =>
s.replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!
);
export type RenderCtx = {
resolver: Resolver;
from: string;
noteHref: (path: string, heading?: string | null) => string;
fileHref: (path: string) => string;
};
/** Anchor id for a heading, also used for [[note#heading]] links. */
export const headingSlug = (s: string) =>
'h-' +
s
.replace(/&[a-z]+;|&#\d+;/g, '')
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-|-$/g, '');
const SAFE_URL = /^(https?:|mailto:|tel:|#)/i;
export function renderNote(
text: string,
ctx: RenderCtx
): { html: string; properties: Record<string, unknown> } {
const { data, body } = splitFrontmatter(text);
const marked = new Marked({ gfm: true, breaks: true });
marked.use({
renderer: {
heading({ tokens, depth }: Tokens.Heading) {
const inner = this.parser.parseInline(tokens);
const id = headingSlug(inner.replace(/<[^>]+>/g, ''));
return `<h${depth} id="${esc(id)}">${inner}</h${depth}>\n`;
},
html({ text }: Tokens.HTML | Tokens.Tag) {
return esc(text);
},
link({ href, title, tokens }: Tokens.Link) {
const inner = this.parser.parseInline(tokens);
if (SAFE_URL.test(href))
return `<a href="${esc(href)}"${title ? ` title="${esc(title)}"` : ''} target="_blank" rel="noopener noreferrer">${inner}</a>`;
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return inner;
let target = href;
try {
target = decodeURIComponent(href);
} catch {
/* raw */
}
const l = parseWikiInner(target, false, href);
const hit = ctx.resolver.resolve(l.target, ctx.from);
if (!hit)
return `<a class="wikilink unresolved" href="${esc(ctx.noteHref(l.target.endsWith('.md') ? l.target : `${l.target}.md`))}">${inner}</a>`;
const h = extOf(hit) === 'md' ? ctx.noteHref(hit, l.heading) : ctx.fileHref(hit);
return `<a class="wikilink" href="${esc(h)}">${inner}</a>`;
},
image({ href, title, text }: Tokens.Image) {
if (/^https?:/i.test(href))
return `<img src="${esc(href)}" alt="${esc(text)}"${title ? ` title="${esc(title)}"` : ''} loading="lazy" referrerpolicy="no-referrer">`;
let target = href;
try {
target = decodeURIComponent(href);
} catch {
/* raw */
}
const hit = ctx.resolver.resolve(target, ctx.from);
if (!hit) return `<span class="embed-missing">${esc(text || target)}</span>`;
return `<img src="${esc(ctx.fileHref(hit))}" alt="${esc(text)}" loading="lazy">`;
},
blockquote({ tokens }: Tokens.Blockquote) {
const inner = this.parser.parse(tokens);
const m = /^<p>\[!([a-z-]+)\]([+-]?)\s*([^<\n]*)(?:<br>|\n)?/i.exec(inner);
if (!m) return `<blockquote>${inner}</blockquote>`;
const kind = m[1].toLowerCase();
const titleText = m[3].trim() || kind.charAt(0).toUpperCase() + kind.slice(1);
const rest = inner.slice(m[0].length);
return `<div class="callout callout-${esc(kind)}"><div class="callout-title">${titleText}</div><div class="callout-body"><p>${rest}</div></div>`;
}
},
extensions: [
{
name: 'wikilink',
level: 'inline',
start: (src: string) => {
const i = src.indexOf('[[');
if (i < 0) return undefined;
return i > 0 && src[i - 1] === '!' ? i - 1 : i;
},
tokenizer(src: string) {
const m = /^(!?)\[\[([^\]\n]+?)\]\]/.exec(src);
if (m)
return { type: 'wikilink', raw: m[0], link: parseWikiInner(m[2], m[1] === '!', m[0]) };
},
renderer(token) {
const l = token.link as ReturnType<typeof parseWikiInner>;
const hit = ctx.resolver.resolve(l.target, ctx.from);
const label =
l.alias ?? (l.target ? noteName(l.target) : '') + (l.heading ? ` › ${l.heading}` : '');
if (l.embed && hit && IMAGE_EXT.has(extOf(hit))) {
const w =
l.alias && /^\d+(x\d+)?$/.test(l.alias) ? ` width="${l.alias.split('x')[0]}"` : '';
return `<img class="embed" src="${esc(ctx.fileHref(hit))}" alt="${esc(baseName(hit))}"${w} loading="lazy">`;
}
if (l.embed && hit && extOf(hit) !== 'md')
return `<a class="embed-file" href="${esc(ctx.fileHref(hit))}" target="_blank" rel="noopener">${esc(baseName(hit))}</a>`;
if (!hit) {
const guess = l.target
? l.target.endsWith('.md')
? l.target
: `${l.target}.md`
: ctx.from;
return `<a class="wikilink unresolved" href="${esc(ctx.noteHref(guess, l.heading))}" title="not created yet">${esc(label || l.raw)}</a>`;
}
return `<a class="wikilink${l.embed ? ' embed-note' : ''}" href="${esc(ctx.noteHref(hit, l.heading))}">${l.embed ? '↳ ' : ''}${esc(label || noteName(hit))}</a>`;
}
},
{
name: 'tag',
level: 'inline',
start: (src: string) => {
const m = /(^|[\s(])#[\p{L}\p{N}_\-/]*[\p{L}_\-/]/u.exec(src);
return m ? m.index + m[1].length : undefined;
},
tokenizer(src: string) {
const m = /^#([\p{L}\p{N}_\-/]*[\p{L}_\-/][\p{L}\p{N}_\-/]*)/u.exec(src);
if (m) return { type: 'tag', raw: m[0], tag: m[1] };
},
renderer: (token) =>
`<span class="tag" data-tag="${esc(String(token.tag).toLowerCase())}">#${esc(String(token.tag))}</span>`
},
{
name: 'highlight',
level: 'inline',
start: (src: string) => {
const i = src.indexOf('==');
return i < 0 ? undefined : i;
},
tokenizer(src: string) {
const m = /^==(?=\S)([^=\n]+?)==/.exec(src);
if (m)
return {
type: 'highlight',
raw: m[0],
text: m[1],
tokens: this.lexer.inlineTokens(m[1])
};
},
renderer(token) {
return `<mark>${this.parser.parseInline(token.tokens ?? [])}</mark>`;
}
}
]
});
// Obsidian %%comments%% are hidden in reading view.
const visible = body.replace(/%%[\s\S]*?%%/g, '');
return { html: marked.parse(visible, { async: false }) as string, properties: data };
}

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
cleanVaultPath,
extractLinks,
extractTags,
isIgnoredPath,
kindOf,
makeResolver,
sanitizeTitle,
splitFrontmatter
} from './shared';
import { renderNote } from './render';
describe('vault paths', () => {
it('ignores Obsidian, Syncthing and our own temp files', () => {
for (const p of [
'.obsidian/app.json',
'.trash/x.md',
'.stfolder',
'.stversions/a.md',
'a/.DS_Store',
'a/.syncthing.x.md.tmp',
'a/~syncthing~x.md.tmp',
'a/.notes-tmp-123',
'a/.x.md.swp'
])
expect(isIgnoredPath(p), p).toBe(true);
expect(isIgnoredPath('Cars/Toyota/Supra.md')).toBe(false);
expect(isIgnoredPath('notes/2026-08-03.md')).toBe(false);
});
it('classifies conflict copies, attachments and notes', () => {
expect(kindOf('2026-02-26.sync-conflict-20260305-155629-4COQNSA.md')).toBe('conflict');
expect(kindOf('Cars/Pasted image 1.png')).toBe('attachment');
expect(kindOf('a/b.md')).toBe('note');
});
it('rejects traversal, absolute and hidden paths', () => {
expect(cleanVaultPath('../etc/passwd')).toBeNull();
expect(cleanVaultPath('a/../../b.md')).toBeNull();
expect(cleanVaultPath('.obsidian/app.json')).toBeNull();
expect(cleanVaultPath('a\0b.md')).toBeNull();
expect(cleanVaultPath(42)).toBeNull();
expect(cleanVaultPath('/Quick Notes/x.md')).toBe('Quick Notes/x.md');
expect(sanitizeTitle('a/b: c?')).toBe('a b c');
});
});
describe('markdown parsing', () => {
it('keeps frontmatter raw and parses properties', () => {
const text = '---\ntags: [car, toyota]\naliases:\n - Supra\ntitle: "MK4"\n---\n# Hi #inline\n';
const fm = splitFrontmatter(text);
expect(fm.raw + fm.body).toBe(text);
expect(fm.data).toEqual({ tags: ['car', 'toyota'], aliases: ['Supra'], title: 'MK4' });
expect(extractTags(fm.body, fm.data)).toEqual(['car', 'inline', 'toyota']);
});
it('skips tags in code, headings and links', () => {
expect(extractTags('# Heading\n`#nope` [[Note#Sec]] #yes/sub #123 x#no')).toEqual(['yes/sub']);
});
it('extracts wikilinks, embeds and relative markdown links', () => {
const links = extractLinks(
'see [[Folder/Note#Part|alias]], ![[img.png|300]] and [x](Other%20Note.md) [w](https://x.y)\n```\n[[code]]\n```'
);
expect(links.map((l) => [l.target, l.heading, l.alias, l.embed])).toEqual([
['Folder/Note', 'Part', 'alias', false],
['img.png', null, '300', true],
['Other Note.md', null, null, false]
]);
});
it('resolves links like Obsidian (path, same folder, shortest path)', () => {
const r = makeResolver(['A/Note.md', 'B/C/Note.md', 'B/Other.md', 'Cars/pic.png', 'Top.md']);
expect(r.resolve('Note', 'B/Other.md')).toBe('A/Note.md');
expect(r.resolve('C/Note', 'Top.md')).toBe('B/C/Note.md');
expect(r.resolve('Note', 'B/C/x.md')).toBe('B/C/Note.md');
expect(r.resolve('pic.png', 'Top.md')).toBe('Cars/pic.png');
expect(r.resolve('top', 'A/Note.md')).toBe('Top.md');
expect(r.resolve('Missing', 'Top.md')).toBeNull();
});
it('renders wikilinks and embeds, escapes raw HTML and unsafe links', () => {
const r = makeResolver(['A/Note.md', 'pic.png']);
const { html } = renderNote(
'[[Note]] ![[pic.png]] <script>x</script> [bad](javascript:alert(1)) ==hi== #tag',
{
resolver: r,
from: 'Top.md',
noteHref: (p) => `/n?note=${encodeURIComponent(p)}`,
fileHref: (p) => `/f/${p}`
}
);
expect(html).toContain('href="/n?note=A%2FNote.md"');
expect(html).toContain('src="/f/pic.png"');
expect(html).not.toContain('<script>');
expect(html).not.toContain('javascript:');
expect(html).toContain('<mark>hi</mark>');
expect(html).toContain('class="tag"');
});
});

296
src/lib/notes/shared.ts Normal file
View File

@@ -0,0 +1,296 @@
// Pure helpers for the notes tracker (/trackers/notes), shared by the server indexer and the
// browser editor: vault path rules, frontmatter, tags, [[wikilinks]] and Obsidian-style link
// resolution. The Obsidian vault on disk is the source of truth; nothing here touches it.
export type NoteKind = 'note' | 'attachment' | 'conflict';
/** One vault file as the tree, search and link resolver see it (no content). */
export type VaultEntry = {
path: string;
kind: NoteKind;
title: string;
mtime: number;
size: number;
hash: string;
tags: string[];
};
export type WikiLink = {
/** Link target as written, without heading/block or alias (may be '' for [[#heading]]). */
target: string;
heading: string | null;
alias: string | null;
embed: boolean;
raw: string;
};
export const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp']);
const CONFLICT_RE = /\.sync-conflict-\d{8}-\d{6}-[A-Z0-9]{7}/;
export const extOf = (path: string) => {
const name = path.slice(path.lastIndexOf('/') + 1);
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
};
export const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1);
export const folderOf = (path: string) => {
const i = path.lastIndexOf('/');
return i < 0 ? '' : path.slice(0, i);
};
/** "Folder/My note.md" → "My note". */
export const noteName = (path: string) => baseName(path).replace(/\.md$/i, '');
export const isConflictName = (path: string) => CONFLICT_RE.test(baseName(path));
/**
* Which vault files the site ignores entirely: anything in or under a dot entry (.obsidian,
* .trash, .stfolder, .stversions, .DS_Store, Syncthing's .syncthing.*.tmp and our own
* .notes-tmp-* write temps), Syncthing ~syncthing~*.tmp, editor swap files.
*/
export function isIgnoredPath(path: string): boolean {
if (!path || path.includes('\0')) return true;
for (const seg of path.split('/')) {
if (!seg || seg.startsWith('.') || seg.startsWith('~syncthing~')) return true;
}
return /\.(tmp|swp|swx)$|~$/i.test(path);
}
export function kindOf(path: string): NoteKind {
if (isConflictName(path)) return 'conflict';
return extOf(path) === 'md' ? 'note' : 'attachment';
}
/**
* Validates a vault-relative path from a client: posix, no leading slash, no `..`, no
* hidden or ignored segments. Returns the normalised path or null.
*/
export function cleanVaultPath(input: unknown): string | null {
if (typeof input !== 'string') return null;
const path = input
.normalize('NFC')
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
if (!path || path.length > 1024) return null;
const segs = path.split('/');
// eslint-disable-next-line no-control-regex -- strip control characters from paths and titles
if (segs.some((s) => !s || s === '.' || s === '..' || s.length > 255 || /[\x00-\x1f]/.test(s)))
return null;
if (isIgnoredPath(path)) return null;
return path;
}
/** A safe file name for a new note title ("a/b: c?" → "a b c"). */
export function sanitizeTitle(title: string): string {
return (
title
.normalize('NFC')
// eslint-disable-next-line no-control-regex -- strip control characters from paths and titles
.replace(/[\\/:*?"<>|#^[\]\x00-\x1f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\.+/, '')
.slice(0, 180)
);
}
// ---------------------------------------------------------------- frontmatter
export type Frontmatter = { raw: string; data: Record<string, unknown>; body: string };
/** Splits a leading `---` YAML block off; `raw` keeps it byte-for-byte (never rewritten). */
export function splitFrontmatter(text: string): Frontmatter {
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
if (!m) return { raw: '', data: {}, body: text };
return { raw: m[0], data: parseYamlLite(m[1]), body: text.slice(m[0].length) };
}
const unquote = (v: string) => {
const s = v.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))
return s.slice(1, -1);
return s;
};
function scalar(v: string): unknown {
const s = v.trim();
if (s === '') return null;
if (s.startsWith('[') && s.endsWith(']'))
return s
.slice(1, -1)
.split(',')
.map((x) => unquote(x))
.filter(Boolean);
if (/^(true|false)$/i.test(s)) return s.toLowerCase() === 'true';
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
return unquote(s);
}
/** Top-level keys only: scalars, [inline, lists] and "- block" lists. Enough for Obsidian properties. */
export function parseYamlLite(src: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
let listKey: string | null = null;
for (const line of src.split(/\r?\n/)) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const item = /^\s+-\s*(.*)$/.exec(line) ?? (listKey ? /^-\s+(.*)$/.exec(line) : null);
if (item && listKey) {
const arr = (out[listKey] as unknown[]) ?? [];
arr.push(unquote(item[1]));
out[listKey] = arr;
continue;
}
const kv = /^([^\s:#][^:]*):(?:\s+(.*))?$/.exec(line);
if (!kv) continue;
const key = kv[1].trim();
const value = kv[2] ?? '';
if (value.trim() === '') {
out[key] = [];
listKey = key;
} else {
out[key] = scalar(value);
listKey = null;
}
}
for (const [k, v] of Object.entries(out)) if (Array.isArray(v) && !v.length) out[k] = null;
return out;
}
// ---------------------------------------------------------------- tags and links
/** Markdown with fenced and inline code blanked out (tags/links inside code don't count). */
export function stripCode(md: string): string {
return md
.replace(/(^|\n)(```|~~~)[^\n]*\n[\s\S]*?(\n\2[^\n]*(?=\n|$)|$)/g, (m) =>
m.replace(/[^\n]/g, ' ')
)
.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
}
const TAG_RE = /(^|[\s(,;])#([\p{L}\p{N}_\-/]*[\p{L}_\-/][\p{L}\p{N}_\-/]*)/gu;
export function extractTags(text: string, fm?: Record<string, unknown>): string[] {
const tags = new Set<string>();
const fmTags = fm?.tags ?? fm?.tag;
const list = Array.isArray(fmTags)
? fmTags
: typeof fmTags === 'string'
? fmTags.split(/[,\s]+/)
: [];
for (const t of list) {
const s = String(t).replace(/^#/, '').trim();
if (s) tags.add(s.toLowerCase());
}
const clean = stripCode(text).replace(/\[\[[^\]]*\]\]|\]\([^)]*\)/g, ' ');
for (const m of clean.matchAll(TAG_RE)) tags.add(m[2].replace(/\/+$/, '').toLowerCase());
tags.delete('');
return [...tags].sort();
}
export const WIKILINK_RE = /(!?)\[\[([^\]\n]+?)\]\]/g;
export function parseWikiInner(inner: string, embed: boolean, raw: string): WikiLink {
let target = inner;
let alias: string | null = null;
const pipe = target.indexOf('|');
if (pipe >= 0) {
alias = target.slice(pipe + 1).trim() || null;
target = target.slice(0, pipe);
}
let heading: string | null = null;
const hash = target.search(/[#^]/);
if (hash >= 0) {
heading = target.slice(hash + 1).trim() || null;
target = target.slice(0, hash);
}
return { target: target.trim(), heading, alias, embed, raw };
}
export function extractLinks(text: string): WikiLink[] {
const out: WikiLink[] = [];
const clean = stripCode(text);
for (const m of clean.matchAll(WIKILINK_RE)) out.push(parseWikiInner(m[2], m[1] === '!', m[0]));
// Plain markdown links to local notes: [text](Some%20Note.md)
for (const m of clean.matchAll(/(!?)\[[^\]\n]*\]\(([^)\s]+)\)/g)) {
const href = m[2];
if (/^[a-z][a-z0-9+.-]*:|^#|^\//i.test(href)) continue;
let target = href;
try {
target = decodeURIComponent(href);
} catch {
/* keep raw */
}
out.push({ ...parseWikiInner(target, m[1] === '!', m[0]) });
}
return out;
}
/** Lowercased last path segment without .md: what a link is matched on first. */
export const linkKey = (target: string) => noteName(target).toLowerCase();
/** Obsidian shows the file name; a frontmatter `title` wins. */
export function titleOf(path: string, body: string, fm?: Record<string, unknown>): string {
if (typeof fm?.title === 'string' && fm.title.trim()) return fm.title.trim().slice(0, 300);
return noteName(path);
}
// ---------------------------------------------------------------- resolution
/** Lookup tables for Obsidian-style link resolution over the whole vault. */
export type Resolver = {
resolve(target: string, from: string): string | null;
};
export function makeResolver(paths: Iterable<string>): Resolver {
const exact = new Map<string, string>();
const byName = new Map<string, string[]>();
for (const p of paths) {
const lower = p.toLowerCase();
exact.set(lower, p);
const key = extOf(p) === 'md' ? noteName(p).toLowerCase() : baseName(p).toLowerCase();
const arr = byName.get(key);
if (arr) arr.push(p);
else byName.set(key, [p]);
}
for (const arr of byName.values())
arr.sort((a, b) => a.split('/').length - b.split('/').length || a.length - b.length);
return {
resolve(target, from) {
const t = target.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
if (!t) return from;
const lower = t.toLowerCase();
const hasExt = extOf(t) !== '';
const tries = hasExt ? [lower, `${lower}.md`] : [`${lower}.md`, lower];
const dir = folderOf(from).toLowerCase();
for (const c of tries) {
const hit = exact.get(c) ?? (dir ? exact.get(`${dir}/${c}`) : undefined);
if (hit) return hit;
}
const key = hasExt && extOf(t) !== 'md' ? baseName(lower) : noteName(lower);
const cands = byName.get(key);
if (!cands?.length) return null;
if (t.includes('/')) {
const suffix = hasExt ? lower : `${lower}.md`;
const m = cands.find((c) => c.toLowerCase().endsWith(`/${suffix}`));
if (m) return m;
}
const same = cands.find((c) => folderOf(c).toLowerCase() === dir);
return same ?? cands[0];
}
};
}
// ---------------------------------------------------------------- live messages
/** WebSocket /ws/notes server → client. Never carries note content, only paths and hashes. */
export type NotesEvent =
| { t: 'hello'; files: number }
| {
t: 'change';
path: string;
kind: NoteKind;
hash: string;
mtime: number;
size: number;
title: string;
tags: string[];
created: boolean;
}
| { t: 'delete'; path: string };

View File

@@ -379,6 +379,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false, external: false,
private: false private: false
}, },
{
name: 'notes',
subtitle: 'my Obsidian vault: notes, wiki and daily notes, live-synced both ways',
href: '/trackers/notes',
external: false,
private: true
},
{ {
name: 'health', name: 'health',
subtitle: 'body, workouts, sleep, meds and vaccinations', subtitle: 'body, workouts, sleep, meds and vaccinations',

View File

@@ -32,3 +32,4 @@ export * from './ai';
export * from './movies'; export * from './movies';
export * from './worldtodo'; export * from './worldtodo';
export * from './osu-goals'; export * from './osu-goals';
export * from './notes';

View File

@@ -0,0 +1,70 @@
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
customType,
index,
integer,
jsonb,
primaryKey,
text
} from 'drizzle-orm/pg-core';
import { notesSchema } from './schemas';
import { timestamptz } from './helpers';
// Notes tracker (/trackers/notes, owner-only). The Obsidian vault (markdown files on the host,
// Syncthing-synced, bind-mounted at NOTES_ROOT) is the source of truth; these tables are only
// a search and link index that src/lib/server/notes/ rebuilds from the files (file watcher plus a
// periodic rescan). Dropping and rebuilding them loses nothing.
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
export const notesFiles = notesSchema.table(
'files',
{
/** Vault-relative posix path, e.g. "Cars/Toyota/Supra.md". */
path: text('path').primaryKey(),
/** note (.md), attachment (images, pdf, canvas…) or conflict (*.sync-conflict-*, never indexed as a note). */
kind: text('kind').notNull(),
folder: text('folder').notNull(),
title: text('title').notNull(),
ext: text('ext').notNull(),
size: bigint('size', { mode: 'number' }).notNull(),
mtime: timestamptz('mtime').notNull(),
/** sha256 of the file bytes; the editor's save-conflict check compares against it. */
hash: text('hash').notNull(),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
frontmatter: jsonb('frontmatter').$type<Record<string, unknown>>(),
/** Note text without frontmatter (notes only), for snippets. */
body: text('body'),
search: tsvector('search'),
indexedAt: timestamptz('indexed_at').notNull().defaultNow()
},
(t) => [
index('files_search_idx').using('gin', t.search),
index('files_tags_idx').using('gin', t.tags),
index('files_folder_idx').on(t.folder),
index('files_kind_idx').on(t.kind)
]
);
export const notesLinks = notesSchema.table(
'links',
{
srcPath: text('src_path')
.notNull()
.references(() => notesFiles.path, { onDelete: 'cascade', onUpdate: 'cascade' }),
seq: integer('seq').notNull(),
/** Target as written ("Folder/Note", "image.png"), without #heading or |alias. */
target: text('target').notNull(),
/** Lowercased last segment without .md; backlinks match on it, then resolve exactly. */
targetKey: text('target_key').notNull(),
embed: boolean('embed').notNull().default(false)
},
(t) => [
primaryKey({ columns: [t.srcPath, t.seq] }),
index('links_target_key_idx').on(t.targetKey)
]
);

View File

@@ -28,3 +28,4 @@ export const statsSchema = pgSchema('stats');
export const aiSchema = pgSchema('ai'); export const aiSchema = pgSchema('ai');
export const moviesSchema = pgSchema('movies'); export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo'); export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes');

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { VaultError } from './vault';
/** VaultError → JSON response (409 carries the current hash); anything else rethrows. */
export function vaultFail(e: unknown) {
if (e instanceof VaultError) {
if (e.status === 409) return json({ error: e.message, ...e.extra }, { status: 409 });
if (e.status === 404) error(404, 'Not Found');
return json({ error: e.message }, { status: e.status });
}
throw e;
}
export const noStore = { 'cache-control': 'private, no-store' };

View File

@@ -0,0 +1,377 @@
import { watch, type FSWatcher } from 'node:fs';
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
import { client } from '#lib/server/db/index.js';
import {
baseName,
extOf,
extractLinks,
extractTags,
folderOf,
isIgnoredPath,
kindOf,
linkKey,
makeResolver,
noteName,
splitFrontmatter,
titleOf,
type NotesEvent,
type Resolver,
type VaultEntry
} from '#lib/notes/shared.js';
import { NOTES_ROOT, readBytes, sha256, vaultAvailable, walk } from './vault';
// Keeps notes.files / notes.links (search + link index only) in step with the vault files:
// - on start: load the index from Postgres, then a full rescan (stat walk; re-reads only files
// whose mtime or size changed, drops rows for files that are gone)
// - fs.watch (recursive, inotify) for near-instant updates, debounced per path
// - a full rescan every RESCAN_MS as a safety net (missed events, a folder moved in, e.g. the
// old ~/notes merged into <vault>/notes/)
// Changes are announced to listeners (the owner-only /ws/notes channel) as path + hash only.
const RESCAN_MS = 60_000;
const DEBOUNCE_MS = 250;
const MAX_HASH_BYTES = 64 * 1024 * 1024;
const MAX_BODY = 400_000;
const entries = new Map<string, VaultEntry>();
const listeners = new Set<(e: NotesEvent) => void>();
let resolver: Resolver | null = null;
let started = false;
let ready = false;
let watcher: FSWatcher | null = null;
let queue: Promise<unknown> = Promise.resolve();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
let rescanTimer: ReturnType<typeof setTimeout> | null = null;
let rescanning: Promise<void> | null = null;
const log = (...a: unknown[]) => console.log('[notes]', ...a);
function emit(e: NotesEvent) {
for (const l of listeners) {
try {
l(e);
} catch (err) {
console.error('[notes] listener', err);
}
}
}
export function onNotesEvent(fn: (e: NotesEvent) => void) {
listeners.add(fn);
return () => listeners.delete(fn);
}
export const isReady = () => ready;
export const allEntries = () => [...entries.values()];
export const getEntry = (path: string) => entries.get(path);
export function getResolver() {
resolver ??= makeResolver(entries.keys());
return resolver;
}
function serial<T>(job: () => Promise<T>): Promise<T> {
const run = queue.then(job, job);
queue = run.catch(() => {});
return run;
}
async function loadFromDb() {
const rows = await client<
{
path: string;
kind: VaultEntry['kind'];
title: string;
mtime: number;
size: number;
hash: string;
tags: string[];
}[]
>`select path, kind, title, round(extract(epoch from mtime) * 1000)::float8 as mtime, size::float8 as size, hash, tags from notes.files`;
for (const r of rows)
entries.set(r.path, {
path: r.path,
kind: r.kind,
title: r.title,
mtime: Number(r.mtime),
size: Number(r.size),
hash: r.hash,
tags: r.tags ?? []
});
resolver = null;
}
async function indexFile(path: string, quiet = false) {
let file;
try {
file = await readBytes(path);
} catch {
return removeFile(path, quiet);
}
const kind = kindOf(path);
const prev = entries.get(path);
const hash = file.size > MAX_HASH_BYTES ? sha256(`${file.size}:${file.mtime}`) : file.hash;
let title = kind === 'note' ? noteName(path) : baseName(path);
let tags: string[] = [];
let fm: Record<string, unknown> | null = null;
let body: string | null = null;
let links: { target: string; targetKey: string; embed: boolean }[] = [];
if (kind === 'note') {
const text = file.buf.toString('utf8');
const split = splitFrontmatter(text);
fm = Object.keys(split.data).length ? split.data : null;
title = titleOf(path, split.body, split.data);
tags = extractTags(split.body, split.data);
body = split.body.slice(0, MAX_BODY);
const seen = new Set<string>();
for (const l of extractLinks(split.body)) {
if (!l.target) continue;
const k = `${l.target}\0${l.embed}`;
if (seen.has(k)) continue;
seen.add(k);
links.push({ target: l.target.slice(0, 1000), targetKey: linkKey(l.target), embed: l.embed });
}
links = links.slice(0, 2000);
}
const searchText = kind === 'note' ? `${split(path)} ${fm ? JSON.stringify(fm) : ''}` : '';
await client.begin(async (sql) => {
await sql`
insert into notes.files (path, kind, folder, title, ext, size, mtime, hash, tags, frontmatter, body, search, indexed_at)
values (
${path}, ${kind}, ${folderOf(path)}, ${title}, ${extOf(path)}, ${file.size},
${new Date(file.mtime).toISOString()}::timestamptz, ${hash}, ${tags}::text[], ${fm ? JSON.stringify(fm) : null}::jsonb,
${body},
${
kind === 'note'
? sql`setweight(to_tsvector('simple', ${title}), 'A') || setweight(to_tsvector('simple', ${tags.join(' ') + ' ' + searchText}), 'B') || setweight(to_tsvector('simple', ${body ?? ''}), 'C')`
: null
},
now()
)
on conflict (path) do update set
kind = excluded.kind, folder = excluded.folder, title = excluded.title, ext = excluded.ext,
size = excluded.size, mtime = excluded.mtime, hash = excluded.hash, tags = excluded.tags,
frontmatter = excluded.frontmatter, body = excluded.body, search = excluded.search,
indexed_at = now()`;
await sql`delete from notes.links where src_path = ${path}`;
if (links.length)
await sql`insert into notes.links ${sql(
links.map((l, i) => ({
src_path: path,
seq: i,
target: l.target,
target_key: l.targetKey,
embed: l.embed
})),
'src_path',
'seq',
'target',
'target_key',
'embed'
)}`;
});
const entry: VaultEntry = { path, kind, title, mtime: file.mtime, size: file.size, hash, tags };
entries.set(path, entry);
if (!prev) resolver = null;
if (!quiet && (!prev || prev.hash !== hash || prev.mtime !== file.mtime))
emit({ t: 'change', ...entry, created: !prev });
}
/** Path words for search ("Cars/Toyota/Supra.md" → "Cars Toyota Supra"). */
const split = (path: string) => path.replace(/\.md$/i, '').replace(/[/_-]+/g, ' ');
async function removeFile(path: string, quiet = false) {
const had = entries.delete(path);
await client`delete from notes.files where path = ${path}`;
if (had) {
resolver = null;
if (!quiet) emit({ t: 'delete', path });
}
}
async function removePrefix(prefix: string) {
for (const p of [...entries.keys()]) if (p.startsWith(`${prefix}/`)) await removeFile(p);
}
/** Full rescan: index new/changed files, drop rows for files that are gone. */
export function rescan(quiet = false): Promise<void> {
rescanning ??= serial(async () => {
const t0 = Date.now();
const files = await walk();
const onDisk = new Set<string>();
let changed = 0;
let failed = 0;
for (const f of files) {
onDisk.add(f.path);
const e = entries.get(f.path);
if (e && e.mtime === f.mtime && e.size === f.size && e.kind === kindOf(f.path)) continue;
changed++;
try {
await indexFile(f.path, quiet);
} catch (err) {
if (!failed++)
console.error('[notes] index failed:', err instanceof Error ? err.message : err);
}
}
let removed = 0;
for (const p of [...entries.keys()])
if (!onDisk.has(p)) {
removed++;
await removeFile(p, quiet);
}
if (changed || removed || !ready)
log(
`rescan: ${files.length} files, ${changed - failed} indexed, ${failed} failed, ${removed} removed in ${Date.now() - t0} ms`
);
ready = true;
}).finally(() => {
rescanning = null;
});
return rescanning;
}
function scheduleRescan(ms = 1000) {
if (rescanTimer) clearTimeout(rescanTimer);
rescanTimer = setTimeout(() => {
rescanTimer = null;
void rescan();
}, ms);
}
async function check(path: string) {
let s;
try {
s = await stat(join(NOTES_ROOT, path));
} catch {
await removeFile(path);
await removePrefix(path);
return;
}
if (s.isDirectory()) return scheduleRescan();
if (!s.isFile()) return;
const e = entries.get(path);
if (e && e.mtime === Math.floor(s.mtimeMs) && e.size === s.size) return;
await indexFile(path);
}
/** Re-index one path now (after the site itself wrote it), so the API answers with fresh data. */
export const touch = (path: string) => serial(() => check(path));
function onFsEvent(_ev: string, name: string | Buffer | null) {
if (!name) return scheduleRescan();
const path = String(name).normalize('NFC').replace(/\\/g, '/');
if (isIgnoredPath(path)) {
// A Syncthing temp renamed into place shows up as its final name too; only folders matter.
return;
}
const t = timers.get(path);
if (t) clearTimeout(t);
timers.set(
path,
setTimeout(() => {
timers.delete(path);
void serial(() => check(path)).catch((e) => console.error('[notes] check', path, e));
}, DEBOUNCE_MS)
);
}
function startWatcher() {
try {
watcher = watch(NOTES_ROOT, { recursive: true }, onFsEvent);
watcher.on('error', (e) => {
console.error('[notes] watcher error, restarting in 5 s', e);
watcher?.close();
watcher = null;
setTimeout(startWatcher, 5000);
});
} catch (e) {
console.error('[notes] watcher failed; relying on rescans', e);
}
}
/** Called once from hooks.server.ts init(). No-op when the vault isn't mounted (dev, tests). */
export async function startNotesIndexer() {
if (started) return;
started = true;
if (!(await vaultAvailable())) {
log(`no vault at ${NOTES_ROOT}; notes tracker disabled`);
return;
}
try {
await loadFromDb();
} catch (e) {
console.error('[notes] could not load the index (migration not run?)', e);
started = false;
return;
}
startWatcher();
await rescan(true).catch((e) => console.error('[notes] initial rescan', e));
setInterval(
() => void rescan().catch((e) => console.error('[notes] rescan', e)),
RESCAN_MS
).unref?.();
}
// ---------------------------------------------------------------- queries
export type SearchHit = {
path: string;
title: string;
folder: string;
snippet: string;
tags: string[];
};
/** Full-text search (prefix match on every word, `#tag` filters), best matches first. */
export async function search(q: string, limit = 40): Promise<SearchHit[]> {
const tagFilter = [...q.matchAll(/#([\p{L}\p{N}_\-/]+)/gu)].map((m) => m[1].toLowerCase());
const rest = q.replace(/#[\p{L}\p{N}_\-/]+/gu, ' ');
const words = [...rest.matchAll(/[\p{L}\p{N}]+/gu)].map((m) => m[0].toLowerCase()).slice(0, 12);
if (!words.length && !tagFilter.length) return [];
const tsq = words.map((w) => `${w}:*`).join(' & ');
const like = `%${rest.trim().replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
const rows = await client<SearchHit[]>`
select path, title, folder, tags,
${
words.length
? client`ts_headline('simple', coalesce(body, ''), to_tsquery('simple', ${tsq}), 'MaxFragments=1, MaxWords=22, MinWords=8, StartSel=«, StopSel=», HighlightAll=false')`
: client`left(coalesce(body, ''), 160)`
} as snippet
from notes.files
where kind = 'note'
${tagFilter.length ? client`and tags @> ${tagFilter}::text[]` : client``}
${
words.length
? client`and (search @@ to_tsquery('simple', ${tsq}) or path ilike ${like})`
: client``
}
order by
${words.length ? client`(title ilike ${like}) desc, ts_rank(search, to_tsquery('simple', ${tsq})) desc,` : client``}
mtime desc
limit ${limit}`;
return rows.map((r) => ({ ...r, snippet: r.snippet.replace(/\s+/g, ' ').trim().slice(0, 300) }));
}
/** Notes linking to `path` (wikilinks, embeds and relative markdown links), resolved exactly. */
export async function backlinks(path: string) {
const key = extOf(path) === 'md' ? noteName(path).toLowerCase() : baseName(path).toLowerCase();
const rows = await client<{ src_path: string; target: string; embed: boolean }[]>`
select src_path, target, embed from notes.links where target_key = ${key}`;
const r = getResolver();
const out = new Map<string, { path: string; title: string; embed: boolean }>();
for (const row of rows) {
if (row.src_path === path || r.resolve(row.target, row.src_path) !== path) continue;
out.set(row.src_path, {
path: row.src_path,
title: entries.get(row.src_path)?.title ?? noteName(row.src_path),
embed: row.embed
});
}
return [...out.values()].sort((a, b) => a.title.localeCompare(b.title));
}
export async function tagCounts() {
return client<{ tag: string; n: number }[]>`
select tag, count(*)::int as n from notes.files, unnest(tags) as tag
where kind = 'note' group by tag order by n desc, tag limit 300`;
}

View File

@@ -0,0 +1,69 @@
import { registerChannel, type Socket } from '#lib/server/ws/hub.js';
import { auth } from '#lib/server/auth.js';
import { client } from '#lib/server/db/index.js';
import { isOwner } from '#lib/server/owner.js';
import type { NotesEvent } from '#lib/notes/shared.js';
import { allEntries, onNotesEvent } from './indexer';
// Realtime side of the notes tracker: wss://<host>/ws/notes. Owner-only: the upgrade request's
// session cookie must belong to the owner account, otherwise a plain 404 (like the pages).
// Messages only say which file changed (path, hash, mtime, title, tags); the page then fetches
// the content over the authenticated API. Clients never send anything but pings.
type Data = { channel: string; ip: string; userId: string; sessionId: string; checked: number };
type WS = Socket<Data>;
const sockets = new Set<WS>();
const MAX_SOCKETS = 20;
const RECHECK_MS = 5 * 60_000;
async function ownerSession(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session || !isOwner({ locals: { user: session.user } as App.Locals })) return null;
return session;
}
let registered = false;
export function registerNotesChannel() {
if (registered) return;
registered = true;
onNotesEvent((e: NotesEvent) => {
const text = JSON.stringify(e);
for (const ws of sockets) ws.send(text);
});
// Sessions can be revoked (logout elsewhere): re-validate open sockets now and then.
setInterval(async () => {
for (const ws of sockets) {
if (Date.now() - ws.data.checked < RECHECK_MS) continue;
ws.data.checked = Date.now();
try {
const rows = await client`
select 1 from auth.session where id = ${ws.data.sessionId} and expires_at > now()`;
if (!rows.length) ws.close(4401, 'session ended');
} catch (e) {
console.error('[ws notes] recheck', e);
}
}
}, 60_000).unref?.();
registerChannel('notes', {
async authorize(request) {
const s = await ownerSession(request);
if (!s) return new Response('Not Found', { status: 404 });
if (sockets.size >= MAX_SOCKETS) return new Response('Too Many Requests', { status: 429 });
return { userId: s.user.id, sessionId: s.session.id, checked: Date.now() };
},
open(raw) {
const ws = raw as unknown as WS;
sockets.add(ws);
ws.send(JSON.stringify({ t: 'hello', files: allEntries().length } satisfies NotesEvent));
},
message(raw, message) {
const ws = raw as unknown as WS;
if (message !== 'ping') return void ws.close(1008, 'unexpected');
ws.send('pong');
},
close(raw) {
sockets.delete(raw as unknown as WS);
}
});
}

View File

@@ -0,0 +1,276 @@
import { createHash, randomUUID } from 'node:crypto';
import {
chown,
mkdir,
open,
readFile,
readdir,
realpath,
rename,
stat,
unlink
} from 'node:fs/promises';
import { dirname, join, resolve, sep } from 'node:path';
import { baseName, cleanVaultPath, isIgnoredPath } from '#lib/notes/shared.js';
// File access to the Obsidian vault (bind-mounted read-write at NOTES_ROOT, default /notes).
// Every path from a client goes through cleanVaultPath + inside(), so nothing outside the vault,
// and nothing hidden (.obsidian, .trash, .stversions…), can be read or written.
// Writes are atomic: a hidden temp file in the same folder (.notes-tmp-*, ignored by the indexer,
// see the Syncthing ignore note in the README) is fsynced and then renamed over the target.
// The container runs as root, so new files and folders are chowned to the vault owner (the uid
// and gid of the vault root), which keeps Syncthing and Obsidian on Stan's devices happy.
export const NOTES_ROOT = resolve(process.env.NOTES_ROOT || '/notes');
export class VaultError extends Error {
constructor(
public status: number,
message: string,
public extra?: Record<string, unknown>
) {
super(message);
}
}
export const sha256 = (buf: Uint8Array | string) => createHash('sha256').update(buf).digest('hex');
let rootReal: string | null = null;
let owner: { uid: number; gid: number } | null = null;
export async function vaultAvailable(): Promise<boolean> {
try {
const s = await stat(NOTES_ROOT);
if (!s.isDirectory()) return false;
rootReal ??= await realpath(NOTES_ROOT);
owner ??= { uid: s.uid, gid: s.gid };
return true;
} catch {
return false;
}
}
async function root() {
if (!(await vaultAvailable())) throw new VaultError(503, 'Vault not mounted');
return rootReal!;
}
/** Absolute path for a clean vault path, refusing anything that escapes (incl. via symlinks). */
async function inside(path: string): Promise<string> {
const clean = cleanVaultPath(path);
if (!clean) throw new VaultError(400, 'Bad path');
const base = await root();
const abs = resolve(base, clean);
if (!abs.startsWith(base + sep)) throw new VaultError(400, 'Bad path');
// The deepest existing ancestor must resolve inside the vault too.
let probe = dirname(abs);
for (;;) {
try {
const real = await realpath(probe);
if (real !== base && !real.startsWith(base + sep)) throw new VaultError(400, 'Bad path');
break;
} catch (e) {
if (e instanceof VaultError) throw e;
const up = dirname(probe);
if (up === probe) throw new VaultError(400, 'Bad path');
probe = up;
}
}
return abs;
}
async function fixOwner(abs: string) {
if (!owner || process.getuid?.() !== 0) return;
await chown(abs, owner.uid, owner.gid).catch(() => {});
}
async function ensureDir(absDir: string) {
const base = await root();
const missing: string[] = [];
let d = absDir;
while (d.startsWith(base + sep)) {
try {
const s = await stat(d);
if (!s.isDirectory()) throw new VaultError(409, 'A file is in the way of that folder');
break;
} catch (e) {
if (e instanceof VaultError) throw e;
missing.unshift(d);
d = dirname(d);
}
}
for (const m of missing) {
await mkdir(m).catch((e) => {
if (e?.code !== 'EEXIST') throw e;
});
await fixOwner(m);
}
}
export type FileStat = { path: string; size: number; mtime: number };
/** Every non-ignored file in the vault (stat only), for the indexer's rescans. */
export async function walk(): Promise<FileStat[]> {
const base = await root();
const out: FileStat[] = [];
async function go(rel: string) {
let entries;
try {
entries = await readdir(rel ? join(base, rel) : base, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const name = e.name.normalize('NFC');
const p = rel ? `${rel}/${name}` : name;
if (isIgnoredPath(p)) continue;
if (e.isDirectory()) await go(p);
else if (e.isFile()) {
try {
const s = await stat(join(base, rel, e.name));
out.push({ path: p, size: s.size, mtime: Math.floor(s.mtimeMs) });
} catch {
/* vanished mid-walk */
}
}
}
}
await go('');
return out;
}
/** Folders (for the new-note picker), sorted. */
export async function folders(): Promise<string[]> {
const base = await root();
const out: string[] = [];
async function go(rel: string) {
let entries;
try {
entries = await readdir(rel ? join(base, rel) : base, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (!e.isDirectory()) continue;
const p = rel ? `${rel}/${e.name.normalize('NFC')}` : e.name.normalize('NFC');
if (isIgnoredPath(p)) continue;
out.push(p);
await go(p);
}
}
await go('');
return out.sort((a, b) => a.localeCompare(b));
}
export async function readBytes(path: string) {
const abs = await inside(path);
try {
const [buf, s] = await Promise.all([readFile(abs), stat(abs)]);
return { buf, hash: sha256(buf), mtime: Math.floor(s.mtimeMs), size: s.size, abs };
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') throw new VaultError(404, 'Not found');
throw e;
}
}
export async function readNote(path: string) {
const r = await readBytes(path);
return { text: r.buf.toString('utf8'), hash: r.hash, mtime: r.mtime, size: r.size };
}
async function currentHash(abs: string): Promise<string | null> {
try {
return sha256(await readFile(abs));
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') return null;
throw e;
}
}
/**
* Atomically writes a file. `baseHash` is the hash the editor last loaded: a string means "only if
* the file still has this hash", null means "only if it does not exist yet" (create), and
* undefined means overwrite unconditionally (forced after the user saw the conflict).
* A mismatch throws 409 with the current hash, so a newer file is never silently overwritten.
*/
export async function writeFileAtomic(
path: string,
data: Uint8Array | string,
baseHash?: string | null
) {
const abs = await inside(path);
await ensureDir(dirname(abs));
const tmp = join(dirname(abs), `.notes-tmp-${randomUUID()}`);
const fh = await open(tmp, 'wx', 0o644);
try {
await fh.writeFile(data);
await fh.sync();
} finally {
await fh.close();
}
try {
await fixOwner(tmp);
if (baseHash !== undefined) {
const now = await currentHash(abs);
if (now !== baseHash)
throw new VaultError(
409,
baseHash === null ? 'A file with that name already exists' : 'Changed elsewhere',
{
currentHash: now
}
);
}
await rename(tmp, abs);
} catch (e) {
await unlink(tmp).catch(() => {});
throw e;
}
const s = await stat(abs);
const buf = typeof data === 'string' ? Buffer.from(data) : data;
return { hash: sha256(buf), mtime: Math.floor(s.mtimeMs), size: s.size };
}
/**
* "Deletes" like Obsidian's default: moves the file into <vault>/.trash (kept on the server,
* not synced, since the vault's .stignore ignores .trash). Refuses if the file changed since
* the editor loaded it.
*/
export async function trashFile(path: string, baseHash?: string) {
const abs = await inside(path);
if (baseHash !== undefined) {
const now = await currentHash(abs);
if (now === null) throw new VaultError(404, 'Not found');
if (now !== baseHash) throw new VaultError(409, 'Changed elsewhere', { currentHash: now });
}
const base = await root();
const trash = join(base, '.trash');
await mkdir(trash).catch((e) => {
if (e?.code !== 'EEXIST') throw e;
});
await fixOwner(trash);
const name = baseName(path);
let dest = join(trash, name);
if (
await stat(dest).then(
() => true,
() => false
)
) {
const dot = name.lastIndexOf('.');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
dest = join(
trash,
dot > 0 ? `${name.slice(0, dot)} ${stamp}${name.slice(dot)}` : `${name} ${stamp}`
);
}
await rename(abs, dest);
}
export async function fileExists(path: string) {
try {
return (await stat(await inside(path))).isFile();
} catch {
return false;
}
}

View File

@@ -27,6 +27,8 @@ export const ownerOnlyPrefixes = [
'/trackers/houses', '/trackers/houses',
'/trackers/data-holders', '/trackers/data-holders',
'/trackers/books/files', '/trackers/books/files',
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
'/trackers/notes',
// AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only. // AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only.
'/trackers/ai/bots', '/trackers/ai/bots',
'/trackers/ai/usage', '/trackers/ai/usage',
@@ -62,6 +64,7 @@ export const ownerWritePrefixes = [
'/trackers/wildlife', '/trackers/wildlife',
'/trackers/music', '/trackers/music',
'/trackers/pc', '/trackers/pc',
'/trackers/notes',
'/trackers/ai', '/trackers/ai',
'/trackers/movies' '/trackers/movies'
] as const; ] as const;

View File

@@ -96,6 +96,18 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M20 3v4M22 5h-4M4 17v2M5 18H3' 'M20 3v4M22 5h-4M4 17v2M5 18H3'
] ]
}, },
notes: {
from: '#fde68a',
to: '#f59e0b',
ink: '#422006',
// notebook with a pen line
paths: [
'M6 3h11a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6z',
'M6 3v18',
'M4 7h3M4 12h3M4 17h3',
'M10 8h6M10 12h6M10 16h3'
]
},
health: { health: {
from: '#f472b6', from: '#f472b6',
to: '#db2777', to: '#db2777',

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;
}
};