notes: private Obsidian vault tracker at /trackers/notes
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
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:
22
src/routes/trackers/notes/+layout.server.ts
Normal file
22
src/routes/trackers/notes/+layout.server.ts
Normal 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
|
||||
)
|
||||
};
|
||||
};
|
||||
23
src/routes/trackers/notes/+page.server.ts
Normal file
23
src/routes/trackers/notes/+page.server.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
1643
src/routes/trackers/notes/+page.svelte
Normal file
1643
src/routes/trackers/notes/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
70
src/routes/trackers/notes/api/note/+server.ts
Normal file
70
src/routes/trackers/notes/api/note/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
13
src/routes/trackers/notes/api/search/+server.ts
Normal file
13
src/routes/trackers/notes/api/search/+server.ts
Normal 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 });
|
||||
};
|
||||
14
src/routes/trackers/notes/api/tree/+server.ts
Normal file
14
src/routes/trackers/notes/api/tree/+server.ts
Normal 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 });
|
||||
};
|
||||
39
src/routes/trackers/notes/api/upload/+server.ts
Normal file
39
src/routes/trackers/notes/api/upload/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
48
src/routes/trackers/notes/file/[...path]/+server.ts
Normal file
48
src/routes/trackers/notes/file/[...path]/+server.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user