Commit Graph
9 Commits
Author SHA1 Message Date
nvrl fe1b12042d login: guessing from elsewhere no longer slows a browser already in use
The login throttle was one counter for everyone, so a stranger failing
on purpose every thirty seconds kept the owner on 429s too. A browser
that logs in now gets a signed diary_device cookie (__Host- over HTTPS,
SameSite=Strict, 400 days), and a login carrying a valid one is slowed
only by its own throttle. Everything else shares the anonymous one, as
before. At most 64 known browsers are tracked; the least recently used
is forgotten first. The failed-login mail says which kind of browser
the failures came from.
2026-10-20 12:25:36 +02:00
nvrl 165561f6a2 sessions: every signed-in browser is a row, and one can be signed out
A session cookie now carries the id of a row in the new sessions table,
and is refused once that row is gone. :sessions lists the browsers
signed in, newest first, and :sessions rm <n> signs one out without
touching the rest; it locks the vault too, since the vault is open for
the whole workspace. Logging out deletes the row, :logout! and a login
password change delete them all, and the sweep drops expired rows.
last_seen is kept in memory and written at most every five minutes.

Cookies issued before this carry no id and are refused: every browser
logs in once more after the upgrade.
2026-10-20 12:19:26 +02:00
nvrl e0f0ebb646 style: cargo fmt, once, so it can be checked from here on
No change but layout. Kept apart from everything else so that blame
can skip it (git config blame.ignoreRevsFile) and no real change hides
in it.
2026-10-20 05:54:37 +02:00
nvrl ee13ae44fa runtime: ranges, health, logs, supervised loops, strict config
Media answers Range requests with 206 and Content-Range, so videos seek
and Safari plays them. Sealed files too: the vault's opener now decrypts
by record position (STREAM BE32 through StreamPrimitive, the same
construction and the same files), so a range starts at the record that
holds its first byte. Compression is limited to text, because a gzipped
body no longer matches the byte ranges a player asks for.

GET /api/health answers without a session once the database does, and
the compose healthcheck asks it instead of only opening the port.
Requests are logged at info with share tokens and keys left out of the
path and no query strings. A request that is not answered in 30 seconds
gets a 503, except uploads, imports, the export, re-sealing and backups.

The backup, mail, sweep and SFTP loops run under a supervisor that logs
a panic and starts the loop again. The backup loop starts without a
Proton session and picks up a later proton-login without a restart. A
lock file keeps backup-now and the server from mirroring at once, and an
unreadable session key file turns backups off instead of failing boot.

Numbers in the configuration are checked against bounds at startup, so
a session length or an upload limit can no longer overflow later, and a
switch that is neither on nor off is an error rather than off. Config
and MailConfig print with the password, secret and SMTP URL redacted.

A session epoch is signed into each cookie (migration 0014). :logout!
advances it, which ends every session, and a changed WORKSPACE_PASSWORD
advances it on the next start. Cookies issued before this keep working
in the first epoch.
2026-10-20 02:01:50 +02:00
nvrl a641d9b6ba vault: files sealed under a password that is never stored
Argon2id turns the vault password into a key that seals a random master
key; only the sealed copy is kept. Each file gets its own key, sealed
under the master key in its header with its media id, and its bytes are
AES-256-GCM STREAM records, so a swapped, cut or altered file refuses
to open. Uploads with ?encrypt=true are sealed as they stream in.

:vault, :unlock, :lock, :passwd, :encrypt, :decrypt and :upload! drive
it. The vault locks after WORKSPACE_VAULT_IDLE_MIN idle minutes, on
logout and on restart; while locked, sealed files answer 423, share
links never serve them, and :export! refuses rather than leave them out.

Sealing an existing file rewrites it under a new id and retires the old
one: the mirror deletes the plaintext from Proton Drive outright,
trash included, whatever WORKSPACE_BACKUP_PRUNE says, and drops it from
the readable files/ view. The mirror now compares on-disk sizes, so
sealed files are not re-hashed every run.
2026-10-19 16:03:59 +02:00
nvrl 49b2056215 feat: spaces, folder import, boards and mail; rename to narl-workspace
The diary grows into a workspace: a flat list of dated entries becomes a
tree of spaces, and the app is renamed to match what it now is.

Spaces and nodes
  One recursive `nodes` table replaces `entries`: a space is a root node,
  and folders and documents nest under it without limit. Entries keep
  their ids through the migration, so share tokens, media links and the
  drafts in localStorage all still resolve.

Folder import
  A folder or a zip is uploaded as a document tree. Markdown becomes
  documents titled from their first heading, everything else becomes
  media, and relative links between the imported files are rewritten to
  the paths they landed at, so a set of cross-linked notes stays
  navigable after the upload.

Boards
  A kanban board per space, with the space as the board: lists, cards,
  due dates and an optional link to a document. `diary` has none.

Mail
  SMTP notifications through an outbox. A mail is enqueued under a key
  naming the occurrence it belongs to, so a restart cannot resend and a
  relay that refuses is retried with a widening backoff. Four kinds: the
  diary reminder, cards due and overdue, a weekly digest, and logins
  from a browser that has not been seen before or a burst of failures.
  All of it is dormant until WORKSPACE_SMTP_URL is set.

Rename
  Everything user-visible says workspace; everything that already exists
  outside this repo keeps its name, because renaming it would break a
  running deployment for tidiness alone: the database file, the docker
  volume, the session cookie, the Proton device, the keyring entry and
  the localStorage keys. Configuration reads WORKSPACE_* first and falls
  back to DIARY_*, so the deployed .env keeps working untouched.
2026-09-18 14:53:22 +02:00
nvrlandClaude Opus 5 bac8a4c1d4 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
2026-09-05 11:38:11 +02:00
nvrl 41d2d7ef77 feat: slow down repeated failed logins
The login endpoint answered guesses as fast as they arrived, against a
single static password.

Wrong attempts now back off — 1s, 2s, 4s, up to 30s — and a correct one
clears the count. It is a single counter rather than a table keyed by
address: there is only one account, so an attacker gains nothing by
rotating source IPs, and there is no per-client map to grow. The cap is
there so that someone hammering the login delays the owner by half a
minute rather than locking them out.

Wrong credentials now answer 401 rather than 400. The server also warns
at startup if DIARY_PASSWORD is still the value from .env.example.
2026-09-05 09:47:27 +02:00
nvrl 4002a35305 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
2026-09-05 05:39:25 +02:00