feat: mirror the diary to Proton Drive as a device

The data directory is the whole diary, and until now keeping a copy of it
was someone's manual job. The server can now register itself in Proton
Drive as a device — its own sync root, next to the desktop clients — and
keep that folder equal to DIARY_DATA_DIR, encrypted client-side by the
same Rust SDK the Linux client uses.

One interactive `narl-diary proton-login`, then nothing prompts again.
The stored session carries the tokens, the mailbox password (the key
chain is rebuilt from it on every start) and the account key salts, which
have to be captured at login: `core/v4/keys/salts` needs the `locked`
scope, and only a password-login token has it. Refresh tokens are
single-use, so every rotation is written back before it can be needed.
It lives as a 0600 file next to the database, on the same volume as the
data it protects; `--features keyring` puts it in the OS keyring instead,
for hosts that have a Secret Service.

The mirror is one-way and change-driven: writes mark the diary dirty and
it is mirrored once the writing stops, with an interval as the backstop —
an hour of writing is one backup, not sixty. `diary.db` goes up as a
VACUUM INTO snapshot, so it is a real point in time rather than a file
copied out from under a writer, and as a new revision each time, so
Proton keeps the older ones. Uploads are immutable, so they are written
once and skipped by size afterwards. Deletions do not propagate unless
asked: a backup that forgets on command is one accident away from being
no backup at all.

Identification follows Proton's third-party rules — an honest
`external-drive-narl_diary@<version>-alpha`, the disclosure printed
before any credential is asked for, and remembered folder uids instead of
walking the tree every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5Gk8FPkRXu1e6Nj2BCRE1
This commit is contained in:
nvrlandClaude Opus 5 committed 2026-09-05 11:38:11 +02:00
1 parent c606169b01
commit bac8a4c1d4
21 files changed
+4229 -46

No files matched your search

+16
View File
@@ -38,6 +38,18 @@ export interface MediaFile {
entry_ids: number[]
}
/** The Proton Drive mirror, as `/api/backup` reports it. */
export interface BackupStatus {
configured: boolean
device: string | null
running: boolean
pending: boolean
last_run_at: number | null
last_success_at: number | null
last_error: string | null
last: { uploaded: number; skipped: number; pruned: number; bytes: number } | null
}
export class ApiError extends Error {
constructor(
readonly status: number,
@@ -107,5 +119,9 @@ export const api = {
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
backupStatus: () => request<BackupStatus>('/backup'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
readShared: (token: string) => request<SharedEntry>(`/share/${encodeURIComponent(token)}`),
}
+48
View File
@@ -1,3 +1,4 @@
import { api, type BackupStatus } from './api'
import { diary, type Theme } from './store.svelte'
import { formatDay, parseDay } from './util'
@@ -50,6 +51,28 @@ const setTheme = (name: string) => {
diary.say(`theme ${name}`)
}
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
const ago = (at: number | null) => {
if (!at) return 'never'
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - at)
if (seconds < 90) return `${seconds}s ago`
if (seconds < 5400) return `${Math.round(seconds / 60)}m ago`
if (seconds < 172800) return `${Math.round(seconds / 3600)}h ago`
return `${Math.round(seconds / 86400)}d ago`
}
const describeBackup = (status: BackupStatus) => {
if (!status.configured) {
return diary.say('proton drive backups are off — run `narl-diary proton-login` on the server', 'error')
}
if (status.last_error) {
return diary.say(`backup failed: ${status.last_error}`, 'error')
}
const where = status.device ? `${status.device} · ` : ''
const state = status.running ? 'running now' : status.pending ? 'changes waiting' : 'up to date'
diary.say(`${where}${state} · last ${ago(status.last_success_at)}`)
}
/** A filename that survives a download folder: `2026-09-05-first-light.md`. */
const exportName = (title: string, at: number) => {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
@@ -401,6 +424,31 @@ export const commands: CommandSpec[] = [
diary.say(`unknown option: ${option}`, 'error')
},
},
{
name: 'backup',
group: 'view',
help: 'when the diary was last mirrored to proton drive',
run: () => diary.guard(async () => describeBackup(await api.backupStatus())),
bang: {
help: 'back up to proton drive now, rather than when the diary falls quiet',
run: () =>
diary.guard(async () => {
// Whatever is in the editor belongs in the backup that was just
// asked for, so it goes to the server before the mirror runs.
await diary.flush()
diary.say('backing up …')
const status = await api.backupNow()
if (status.last_error) return diary.say(`backup failed: ${status.last_error}`, 'error')
const last = status.last
diary.say(
last
? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged`
: 'proton drive backups are off — run `narl-diary proton-login` on the server',
last ? 'info' : 'error',
)
}),
},
},
{
name: 'help',
aliases: ['h'],