Terminal-themed markdown diary

Single-user diary served as one Rust binary with the Svelte frontend
embedded.

- axum 0.8 + SQLite (sqlx), migrations run at startup
- stateless HMAC session cookie; credentials from .env, compared in
  constant time
- FTS5 full-text search, user input rewritten into quoted prefix terms
- media uploads linked to entries by scanning bodies for /api/media/<id>,
  which scopes share-token media access and entry deletion
- unguessable share links (192-bit token) serving a public read-only page
- vim keybindings via CodeMirror plus a modal browse layer and : command
  line; disabled on touch devices
This commit is contained in:
nvrl committed 2026-09-05 05:39:25 +02:00
commit 4002a35305
41 files changed
+6195

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
export interface EntrySummary {
id: number
title: string
excerpt: string
created_at: number
updated_at: number
shared: boolean
share_token: string | null
}
export interface Entry {
id: number
title: string
body: string
created_at: number
updated_at: number
shared: boolean
share_token: string | null
}
export interface SharedEntry {
title: string
body: string
created_at: number
updated_at: number
token: string
}
export interface MediaFile {
id: string
filename: string
mime: string
size: number
created_at: number
url: string
entry_id: number | null
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`/api${path}`, {
credentials: 'same-origin',
...init,
headers:
init.body instanceof FormData
? init.headers
: { 'content-type': 'application/json', ...init.headers },
})
if (!response.ok) {
let message = response.statusText
try {
message = (await response.json()).error ?? message
} catch {
/* the body was not JSON; the status text will do */
}
throw new ApiError(response.status, message)
}
return response.status === 204 ? (undefined as T) : await response.json()
}
const body = (data: unknown) => JSON.stringify(data)
export const api = {
me: () => request<{ user: string }>('/me'),
login: (username: string, password: string) =>
request<{ user: string }>('/login', { method: 'POST', body: body({ username, password }) }),
logout: () => request<{ ok: true }>('/logout', { method: 'POST' }),
listEntries: (q = '') =>
request<EntrySummary[]>(`/entries${q ? `?q=${encodeURIComponent(q)}` : ''}`),
getEntry: (id: number) => request<Entry>(`/entries/${id}`),
createEntry: (input: { title: string; body: string; created_at?: number }) =>
request<Entry>('/entries', { method: 'POST', body: body(input) }),
updateEntry: (id: number, input: { title: string; body: string; created_at?: number }) =>
request<Entry>(`/entries/${id}`, { method: 'PUT', body: body(input) }),
deleteEntry: (id: number) => request<{ ok: true }>(`/entries/${id}`, { method: 'DELETE' }),
share: (id: number) => request<{ token: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }),
unshare: (id: number) => request<{ ok: true }>(`/entries/${id}/share`, { method: 'DELETE' }),
listMedia: () => request<MediaFile[]>('/media'),
upload: (files: File[]) => {
const form = new FormData()
for (const file of files) form.append('file', file)
return request<MediaFile[]>('/media', { method: 'POST', body: form })
},
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
readShared: (token: string) => request<SharedEntry>(`/share/${encodeURIComponent(token)}`),
}