`file_text::catch_up` read every file without a row, which after a write meant
walking every file node there is to find the one or two just put in. It now
takes the part of the tree to look in: an upload asks about the folder it went
into, an import about the folder it was poured into, an SFTP save about the
folder it landed in, a decrypt about what was decrypted. Only an import at the
root and the pass at start look at everything.
The excerpt of a file found by its text marks each matched word between
U+0002 and U+0003, which no text file has a use for, and the list draws them
lit. A cut through a match leaves an opening without its close, and the rest
is lit to the end rather than the marker shown.
The screenshots are taken again, so the search one shows it.
`breaks` is on because the diary is written a line at a time and a line typed
in the browser is meant. Markdown written elsewhere is usually wrapped at a
width instead — an import, a save over the SFTP mount from vim with
`textwidth`, a pasted README — and kept its breaks, one in the middle of every
sentence, worst on a phone.
`hardWrapped` tells the two apart by what a wrapping editor leaves behind:
every line but the last is long, each break comes where the next word would
not have fitted on the line before, and the lines seldom end where a sentence
does. A paragraph that looks like that has its soft breaks read as spaces; a
break asked for outright, two spaces or a backslash at the end of the line,
stays. Lines broken by hand — a short sentence and then another, a list of
things that happened, a verse — keep their breaks as before.
rust-embed refused to derive without `web/dist`, so on a fresh clone
`cargo check`, `cargo test` and rust-analyzer failed with a derive error that
did not say what was missing. With `allow_missing` the embed is empty until
the frontend is built, and the handler gives the answer it already had for
that case, "frontend not built". The release build is unchanged once
`web/dist` is there, which `make build`, the Dockerfile and CI all see to.
The browser grouped the agenda by its own midnight and remembered that it had
opened it by its own date, while the server counts due dates, mails and the
diary in `WORKSPACE_TIMEZONE`. A laptop on a trip, or a browser set to UTC,
showed a card as late that the morning mail called due today, and opened the
agenda a second time on one workspace day or not at all. A day was also taken
to be 86400 seconds, which it is not when the clocks change.
The agenda answer now carries `today`: the date in the configured timezone,
when it starts and ends, and when the seventh day after it ends, all from the
same `day_bounds` the mails use. The browser groups by those and keys the
once-a-day opening on that date. The screenshot run marks the agenda as seen
under the same date.
Three small things the docs pass turned up.
A search of nothing but punctuation — `!!!`, `--` — gave the FTS query no
terms, and the list handler then fell through to browsing with no parent,
which answered with the spaces as if they were matches. It now finds nothing.
Two spaces could have the same name: the slug was made unique (`work-2`) but
the name kept, so the bar showed two pills that read alike and `:space work`
picked the first. A new space, a space renamed, and a directory made at the
root of the mount are refused when another space has that name in any case or
that slug. Folders and documents may still share names; they are told apart by
where they sit.
A name had no ceiling. A file is called by its name on the mount, where most
filesystems stop at 255 bytes, so a document, folder, file or space is now at
most 200 characters, checked where one is made or renamed.
`PUT /api/nodes/{id}` wrote the whole node, and a field left out was taken as
empty: a rename sent without the body erased the text. The browser always sent
it, which was the other half of the trouble. To rename a document it sent the
body it had — the draft for one open here, a copy fetched a moment earlier for
any other — and without the version it had seen, so a save made in another
tab, on the phone or over the mount in between was written over, kept only as
a revision nobody was told about.
Now `name` and `body`, like the rest, change only when they are sent. A rename
sends the name alone, and for a document open in a tile the `updated_at` that
tile last saw, so a rename of a document saved elsewhere since is refused the
way `:w` is, rather than stamping a new version over it that the next autosave
would then write the draft past. Toggling a space's board sends only that.
The README grew a section per feature and is a good reference, but nothing
said where to begin. docs/wiki now has a getting-started page, a guide that
walks through one day with the workspace and explains each part where it comes
up, and a page per part of the app, each with screenshots of what it
describes.
Three pages are read out of the code rather than written, so they cannot drift
from it: the keys and commands from Help.svelte and commands.ts, the same lists
`:help` draws; the settings from .env.example, which gains a heading for the
vault, trash and history settings so they no longer sit under mail; and the
API from the README's table. `make wiki` writes them, and `--check` fails when
one is stale or a link between pages leads nowhere. `--publish` lays the pages
out for a GitHub wiki: links without `.md`, images beside them, `[[` escaped so
the wiki does not take a command's help text for a link to a page.
The screenshots are taken by `make screenshots`: the debug binary on a
temporary data directory, with a password made up for the run, filled with an
example workspace over the API, and a headless Chromium driven over the
DevTools protocol through the views the docs show. Nothing outside the
temporary directory is touched but docs/images, and the pictures are cut to
256 colours, a third of their size, which a dark UI does not show.
The README opens with the banner, a CI badge, the guide, the features grouped
by what they are for, and six of the screenshots.
The text of every file in the tree that is text, by its type or by its
first 8 KiB reading as UTF-8 without a NUL, now goes into `file_text` and a
second FTS5 index beside the documents', up to its first 256 KiB, the same
part the viewer shows. A search asks both, lists a node found in both once,
and marks a file found by its text with `in_text`; the list shows the words
around the match under its name.
There is one way in, `file_text::catch_up`, which reads every unsealed file
that has no row yet. An upload, an import, an SFTP save and a decrypt call
it once their own transaction is done, and the server runs it at start for
anything that slipped past. A file that is not text gets an empty row, so it
is read once and not again.
The triggers keep it honest the other way. New bytes drop the old text, so
an SFTP save is read again, and `:encrypt` writes a file under a new media
id, so its plaintext is gone in the same statement that seals it. The text
of a sealed file is refused outright, whatever path tries to store it;
otherwise it would sit in `diary.db` and its mirror beside the sealed bytes.
PDFs and office documents are not read: their text needs a parser this
binary does not carry.
`:repeat weekly` gives a card a rule: daily, weekly, monthly, yearly, or
every +Nd, +Nw, +Nm, +Ny. Finishing it, by a move into the last list or by
the API's done, leaves a copy at the bottom of the inbox with the same
title, notes, document and rule, due on the next date. The finished card
gives its rule up in the same transaction, so reopening it and finishing it
again makes no second copy.
The next date is counted on the calendar in WORKSPACE_TIMEZONE, so noon
stays noon across a change of the clocks, and months are counted from the
old due date rather than step by step, so a card due on the 31st comes back
on the 31st wherever a month has one. A card finished late skips the dates
already past; one without a date counts from the moment it was finished.
A move and an edit answer with `again`, the copy, so the status line can
say when the card comes back. The board and the agenda show the rule as
`↻ weekly`, the rail has a button for it, and `:` in the agenda now opens
the line with the agenda kept up, so `:repeat` knows which card.
The rule travels in `.workspace.json` without a format bump: an older build
ignores the field and imports the card as one that does not repeat. An
import drops a rule it cannot read.
A folder called templates at the root of any space holds templates. `:new
<template> [yyyy-mm-dd] [name]` starts a document from one, with {{date}},
{{weekday}}, {{time}} and {{title}} filled in on the server, in
WORKSPACE_TIMEZONE, for the date the document is for. A new diary entry starts
from templates/day when the diary has one, whether it comes from T, o, the
calendar or a capture. Templates are not entries: the calendar, the reminder
and "written today" leave them out.
C (or :cal) shows the diary by month: h/l a day, j/k a week, H/L a month, .
today, and Enter opens the day's entry or starts one dated that day. [d and
]d step to the entry before or after the open one by date, backdated ones
included. The agenda ends with what the diary holds from this day in earlier
years; a 29 February is remembered on the 28th in years without one.
New routes: GET /api/diary/days?month=, GET /api/diary/around/{id} and
GET /api/nodes/{id}/templates. POST /api/nodes takes template_id.
POST /api/capture takes a line of text and puts it on a card in an inbox,
or at the end of today's diary entry, starting the entry when there is none
yet. It answers a logged-in browser, and also a request that carries an
Authorization: Bearer token from :token add, so a phone shortcut, a script
or curl can capture with no tab open.
A token is nwc_ and 32 random bytes. It is shown once, copied, and stored
only as its SHA-256, the way a share key is. Only the capture route reads
the header. The Session extractor never does, so a token opens nothing
else. Wrong tokens go through the login's backoff on a throttle of their
own. A removed token is refused from the next request on, because each
capture looks the hash up again.
A card lands where :card puts one outside the board: the inbox of the space
named by "space", or else of the first space with a board. That rule now
lives server-side as boards::inbox_of. The first line of the text becomes
the title and the rest becomes the notes. Diary text becomes "- HH:MM text"
in WORKSPACE_TIMEZONE, appended to the first document written today at the
top of the diary. That is the entry :today opens. The append keeps a
revision and indexes links like any other save.
:token lists, adds and removes tokens, and completes their names after rm.
The message that shows a new token stays in the status bar instead of
fading, because it cannot be shown again.
A document links to another by its path, `[the plan](/n/work/plan)`, or
now by its title, `[[Plan]]` or `[[Plan|the plan]]`. What each document
links to is kept in a new `links` table, rebuilt whenever its text is
written: from the browser, over the mount, or by an import. A title is
looked for in the linking document's space first, then in every other.
Renaming or moving a document, or a folder above it, rewrites the links
to it in every document that has one, in the same transaction: the path
to the new one, a title to the new name, with the label left as it was.
Each rewritten document keeps a revision of what it said before. A link
to something in the trash keeps its row, so it follows the thing when it
is restored somewhere else. A document created, or renamed, under a title
that a link was waiting for is found by that link from then on.
The reader draws `[[titles]]` as links, and a title no document has yet
as a dimmed one that offers to write that document. `gf` follows either
from the editor, and `[[` there completes titles from the search. `gb`
and `:backlinks` list the documents that link to the open one, with the
line each does it on; the line under the title counts them. A share page
shows these links as plain text, as it does every /n/ link.
The documents written before this are indexed once, when the server
starts and finds the table empty.
Every write of a document's text keeps the text it replaces, in the same
transaction. Autosave writes every few seconds, so an ordinary save keeps
one at most every ten minutes; `:w!`, a save over the SFTP mount and a
restore always keep one, because each may undo something. A rename alone,
or a document that was empty, keeps nothing.
`gh` or `:history` lists the open document's earlier versions, the newest
first, and shows the highlighted one as a line diff against the text as it
is now. Enter brings it back, and what it replaces becomes a version of
its own, so a restore is undone the same way.
The daily sweep forgets versions older than WORKSPACE_REVISION_DAYS (90)
but never the newest 20 of a document, and keeps an upload as long as a
version still embeds it. Versions stay out of the export, the mount and
the readable mirror; a document in the trash shows none.
`x`, `dd`, `:delete` and `:rmspace` no longer end anything. What they
delete is moved, whole, under one hidden root, and `u` puts the last of it
back where it was. `:trash` lists everything deleted, the last first:
Enter or `r` restores, `x` deletes one for good and `:trash purge` empties
it, each asking first. Something whose folder has gone since comes back to
the root of its space; something whose space is in the trash too waits for
the space. The daily sweep purges what has waited WORKSPACE_TRASH_DAYS (30),
and only then are its files deleted.
While it waits, a trashed node is out of the tree, the search, the agenda,
the mails, the SFTP mount, the export and the readable files/ view, and a
shared document answers 404 until it comes back with the same link. `rm`
over the mount trashes too; a rename onto a taken name still replaces what
was there, so editor saves do not fill the trash.
`F` on the board puts a letter or two over every card in view, as it does
over links in the reader, and typing one puts the cursor on that card;
Enter then opens its notes as before.
Hints are pinned where their targets are when they appear, so `F` pressed
while a pane was still sliding in — the board just opened, a space just
switched to — put them where the targets were going to be. They now wait
for the pane to settle.
`zl` / `zh` scroll the table, code block or formula at the top of the
view a few columns right or left, and `zL` / `zH` by half its width, as
vim scrolls a long line. It is the first one too wide for the column that
reaches down past the top of the reading view, so it is the one being
read. A CSV's table in the file pane answers the same keys. Before, those
only scrolled with the pointer or a trackpad.
In the agenda, `D` asks for the highlighted card's due date on the command
line (`:due` now works there as well as on the board), and `H` / `L` send
the card to the list left or right of the one it is in, on its own board.
`L` into the last list is done, as on the board.
The command line now rises above an open overlay, and while it is up the
overlay's own keys stay out of its way, so typing a date does not walk the
agenda.
`L` or Shift-Enter on a row opens it in a tile of its own; Enter and `l`
still open it in the focused tile. Right of the list the tiles share the
space as hyprland's dwindle does: two side by side, a third splits the
newer one, and so on. Each keeps its own scroll position and its own
editor, and the keys act on the focused one. A document already open in a
tile is focused there rather than opened twice, so two drafts of it can
never overwrite each other.
Alt-h/j/k/l moves the focus to the tile that way, out of the editor too,
and Alt-h past the leftmost tile focuses the list as before. With Shift
the tile itself moves, swapping places with its neighbour. `gt` / `gT` and
`:bn` / `:bp` cycle the tiles, `:ls` lists them, `:b <n or name>` focuses
one, `:close` (Alt-q) closes one, asking first when its draft could not be
written, and `:only` closes the rest. `:b` was the board's alias; `:board`
is spelled out now. `:next` / `:prev` still walk the list. `q` on a
document closes its tile when there are several. `f` fullscreens the
focused tile.
Each space remembers its tiles, as it remembered its one document. A
card's notes open in a tile beside whatever was being read, and `q` there
takes the tile away again on the way back to the board. Under 768px only
the focused tile is shown. The status line says which tile has focus.
`n` / `N` move to the next / previous search result and open it beside the
results, so the next `n` goes on from there; a folder is only highlighted,
as going into it would end the search. Past the last result it starts
again at the first and says so, as vim's `wrapscan` does. Clearing the
search is left to `:noh` / `:clear`, as in vim, rather than to `n`.
After `g`, `d`, `]` or `[` the status line shows the key waiting for its
second half, like vim's `showcmd`, and forgets it after a second.
`↑` / `↓` (and `Ctrl-p` / `Ctrl-n`) walk the lines run before, newest
first, narrowed by what is already typed: `:se` then `↑` finds the last
`:set …`. Commands and `/` searches keep separate histories in
localStorage, a hundred lines each. Passwords never get here: they are
typed into the vault prompt, not the line.
Tab moves to the next match and Shift-Tab to the one before, with the match
marked in the list. Once a name is complete, Tab completes the argument:
the folders listed here plus `..` and `/` for `:cd` and `:mv`, space names
for `:space` and `:rmspace`, the options of `:set`, `:due`'s usual dates and
`:backup`'s subcommands. `:mv /` now moves to the root of the space, as
`:cd /` goes there.
While a search is live, `Ctrl-n` / `Ctrl-p` (and Tab) move the list cursor
through the results without leaving the line, and Enter opens the one lit
rather than only closing the line. Ctrl-P's search does the same.
After a command the focus goes back where it was, or to the editor while
writing, instead of being left on <body> for the window handler to catch.
`gx` opens the file under the cursor in a tab of its own, as the browser
shows it. `:download` (`:dl`) downloads it, after asking for the vault when
the file is sealed; on the open document it is `:export`. `:source` turns a
markdown file between rendered and as written, which before had only a
button. A sealed file asks for the vault before `gx` instead of after, so
the browser does not block the late tab as a popup.
The media list gets a cursor: `j`/`k` walk it, `i` or `Enter` embeds the
highlighted upload at the cursor while writing, `y` copies its address and
`x` deletes it. Deleting asks through the `[y/N]` prompt, which names what
would break, in place of the browser's confirm(): that one was the last
modal on the page and needed the pointer to dismiss. A footer lists the
keys, as the agenda's does.
`F` puts a letter or two over every link and button in view, vimium-style,
and typing one does what a click on it would. `f` was already fullscreen, so
the hints live on the other key. The labels start from the home row and are
all one length, so a label is done the moment it is typed. Esc, a key that
matches no label, or anything that scrolls the page puts them away. In the
file pane they reach the unlock and keystore buttons too.
A workspace link (`/n/…`) opens in place, as a click on it already did. Any
other link gets a tab of its own instead of taking the workspace with it.
In the editor, `gf` follows the link under the cursor, or the next one on
the line. A workspace link saves the text and opens that document. `gx`
opens the link in a new tab, as vim hands it to the browser. Both read
markdown links, embeds, `<…>` autolinks and bare addresses.
`]]` and `[[` jump to the next and previous heading of the open document,
in the reader and in the editor, where vim's section motions mean nothing
useful in markdown. Past the last heading they go on to the end, as vim's
do. A heading a jump has just parked at the top counts as the one being
read, so pressing again moves on.
`gO` (or `:outline`, `:toc`) lists the headings in an overlay that opens on
the section being read or the one the cursor is in: `j`/`k` walk it,
`Enter` jumps there. It is the margin's outline for when the pane is too
narrow to show one, and for hands that stay on the keys. In the editor the
list comes from the `#` lines of the text, code fences left out, and a pick
moves the cursor; in the reader it comes from the rendered headings,
markdown files included.
A keystore or encrypted key asks for its password as soon as it is
opened, asks again after a wrong one, and Enter on the file pane asks
once more after Escape — the button was the only way in before. Enter on
a sealed file opens the vault the same way.
`:name` on its own, while writing, moves up into the title, and Enter,
Escape, Down or Tab there goes back into the text; the title could not be
reached from the editor at all. `R` starts the command line from the
current name — of the row, the open document, the card or the list —
instead of an empty one.
`s` on a shared document and `y` both ask y/N before they cut readers
off: y is what a vim hand presses to yank, and it retired the live link
without a word.
:backup login <username> shows Proton's third-party disclosure (and a
warning when no session key file seals the stored session), asks for
the password, then for a two-factor code when the account has one. If
Proton answers with a human check, its own verify.proton.me page opens
in a sheet; the token it posts back resumes the login. :backup logout
forgets the session and ends it on Proton's side where Proton answers.
The mirror picks up a new session within a minute, without a restart.
The login is split into steps in backup::proton (start, second_factor,
verified), and a half-done login waits in memory for five minutes at
most. Every step goes through its own throttle, so the workspace cannot
hammer Proton into locking the account. FIDO2-only accounts are told to
add an authenticator app. The CLI runs over the same steps and points
to :backup login when Proton wants a human check. The CSP lets
verify.proton.me into a frame and nothing else.
:passwd used to wrap the same master key under the new password, so an
old diary.db (a pre-* snapshot, an old revision on Proton Drive) and the
old password still opened every sealed file. It now makes a new master
key. The old password opens nothing from that moment; the old master
key is kept, sealed under the new one, in two new vault columns, only
until every sealed file has been re-sealed.
A background worker does that while the vault is open, without holding
it open. Each file gets a new id with its file key wrapped under the new
master key; its records are copied as they are. The move goes through
the same repointing as :encrypt, so the old id is put on backup_retired
and the mirror purges it from Drive. Which files are left is read off
their headers, so a lock or a restart halfway only pauses the work until
the next unlock. Once none is left, the old key is dropped.
Readers take a Keyring that tries the current key, then the retired
one. A second :passwd is refused until the first has finished. :vault
shows the progress. Every sealed file is uploaded to Proton once more.
The share page used to put the key in the API path, and every embedded
picture or video carried it in its src, so a reverse proxy's access log
held whole links. The page now sends the key in the body of
POST /api/share/{token}. The answer sets a diary_share cookie
(__Secure- over HTTPS) scoped to /api/share/{token}/, HttpOnly,
SameSite=Strict, good for twelve hours, and signed over the key's hash.
Media is served from /api/share/{token}/media/{id} only to a browser
holding that cookie, so minting a new key or unsharing voids it.
The old /api/share/{token}/{key} routes are gone; links themselves are
unchanged, since the key was always in their fragment.
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.
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.
:passwd re-wraps the same master key, so a database copy from before the
change — a pre-migration snapshot, a Proton Drive revision — still opens
every sealed file with the old password. Say so, until a real rekey
exists.
Since e4e3e2b every registered SSH key could read sealed files over the
mount while the vault was open in the browser. A key is a standing
credential on another machine, so it now needs vault access, given and
taken per key with :sshkey vault <name> on|off (PATCH /api/ssh-keys/{id},
migration 0016). Existing keys start without it. The access is asked on
every read of a sealed file, so taking it away cuts off a read under way,
as locking does. That includes a write handle opened over a sealed file:
its .part holds the decrypted bytes and could be read back through it.
A mount no longer holds the vault open: it takes the key through the new
Vault::peek_key, which does not count as use. Opening sealed files over
and over from an indexer kept the idle lock from ever firing.
The rest of the audit's findings on this side:
- An unfinished upload's .part is removed when it is dropped, so a sealed
file's plaintext does not sit in uploads/ after a client goes away.
- A deleted key can no longer write, setstat or close-and-save through a
session that was already open.
- A session holds at most 256 handles and 64 MiB in memory, the server at
most 32 connections; keepalives drop a peer that stopped answering.
- A sealed download in the browser ends when the vault locks, the way a
read over the mount already did.
- Certificate inspection refuses a key or keystore that asks for more
than ten million rounds, or scrypt beyond 2^20 and r = 8, before running
the derivation: the file chooses those numbers, and the work could not
be stopped once begun.
The server no longer starts with WORKSPACE_SECRET or WORKSPACE_PASSWORD
left at the value .env.example ships with. The secret was only checked
for length, and the public placeholder passed: anyone who had read the
repository could sign a session cookie for themselves. A secret under
32 characters is warned about.
With WORKSPACE_SECURE_COOKIE=1 the cookie is named __Host-diary_session,
which the browser keeps to this host and to HTTPS and which no sibling
subdomain can plant; turning it on logs everyone out once. Responses
then carry Strict-Transport-Security for a year. The traefik example
turns it on.
The password is compared as an HMAC under the secret, so the time a
comparison takes no longer depends on the configured password's length.
Every response also carries Cross-Origin-Opener-Policy and
Cross-Origin-Resource-Policy same-origin and a Permissions-Policy that
turns camera, microphone, location, payment and USB off; API answers
are no-store unless they say otherwise.
m now adds the row under the cursor to a set of marks and steps on, so
mmm picks up three; M takes the whole folder, Esc or :unmark puts it all
back. Marks stay while the list walks, so they can be gathered from
several folders. p, :mv and Shift+digit move the whole set.
With a pointer, Ctrl- or Cmd-click marks a row and Shift-click every row
from the cursor to it. Rows drag onto a folder row, a crumb or a space
pill; a marked row carries every marked node with it.
POST /api/nodes/move takes { ids, parent_id } and moves them all or
none: every node is checked before any moves, sealing into a sealed
folder happens before the one transaction that moves them, and two of a
name get slugs of their own. The single move goes the same way.
Decrypted a record at a time as it is read, from any offset, and sealed
again when it is written into or over, in a sealed folder or not. Shut,
the vault refuses the open and cuts off reads already under way. Reads
over the mount do not count as use, so a mount alone does not keep the
vault open.
$x^2$ inline, $$…$$ for display, and a ```math block, the way GitHub
writes them, in documents, shared pages and .md files alike. KaTeX loads
on first use; until then the math shows as its source. Only the woff2
fonts are bundled.
`:encrypt` on a folder sealed the files in it once; whatever arrived
afterwards was plaintext. A folder or space that is encrypted now
carries `sealed` (0015), and so does every folder under it, so "is
this place sealed?" is one column of the parent. What lands there is
sealed on the way in, or refused with 423 while the vault is shut:
- uploads, with or without `?encrypt`, encrypted as they stream
- imports, sealed where they lie before any row names them
- SFTP writes, sealed out of their `.part`, and refused at the open
when the vault is shut
- files and folders moved in, through the web or SFTP: seal::enter
seals them first and the move happens only if that worked
Triggers make it hold for any path: a folder made inside a sealed one
is sealed, and a file node whose media is not encrypted cannot be
inserted into or moved into a sealed folder (a 400, not a 500).
Decrypting the sealed folder lifts the flag on its subtree and opens
its files; a file or folder inside it cannot be decrypted on its own.
An empty folder can be sealed. Documents are not files and stay as
they are.
The list shows a lock on sealed folders, the touch menu offers to
unseal one, and an upload or import into one asks for the vault
password first. Tests cover sealing, inheritance, the trigger, moves
with the vault open and shut, unsealing, an SFTP write and an import.
The file pane decided what to show by MIME type, and uploads keep a
type only when it cannot execute, so a .md, a .json or a .pem arrived
as application/octet-stream and got "no preview". It now goes by name
first (preview.ts), and a file with a name it does not know has its
first 8 KiB looked at: no NUL bytes and valid UTF-8 means text.
- text and code: the first 256 KiB with line numbers, as before
- markdown: rendered through the same sanitiser as documents, with a
source toggle
- CSV/TSV: a table of the first 500 rows, quotes respected
- PDF: the browser's own viewer in a frame. Only a PDF is served
without the sandbox CSP (Chrome's viewer will not start under it),
with frame-ancestors 'self' and X-Frame-Options SAMEORIGIN; the app
CSP allows same-origin frames.
- certificates, keys, keystores: POST /api/nodes/{id}/inspect reads
PEM (chains, keys, requests, public keys), DER and PKCS#12 on the
server, in pure Rust, including the RC2/3DES keystores that
openssl -legacy and older keychains write. It answers with subject,
issuer, validity, key, names, usage and fingerprints, and pairs each
private key with its certificate by public key. A password is asked
for only when needed, used for that one answer and never stored;
no key material is ever sent back. Sealed files are read through the
vault, and only while it is open.
The tests read throwaway fixtures under src/routes/testdata, made with
the openssl commands listed beside them; .gitignore lets their .pem
files past a global *.pem rule.
README: the API table lists every route, twelve of which were missing;
an SFTP section says how to turn the mount on and register a key; the
keys table and the command list cover what was only in the code
(digits, Tab, w, f, Alt-h/l, Ctrl-D/U, m/p, :today, :edit, :next and
the rest, :search!, :set clipboard, :logout!). Three claims that were
wrong are fixed: there is no board button above the list, R does not
rename a space, and Esc/q close the document.
Help gains the agenda keys and the board's gg/G and r. :t no longer
means :today, which read as the agenda's key.
make check runs cargo fmt --check, after the one formatting commit
before this, which .git-blame-ignore-revs names (git config
blame.ignoreRevsFile .git-blame-ignore-revs). Cargo.toml states
rust-version 1.96, the floor the Proton crates set, and the build is
checked on it. make build installs the frontend from the lockfile.
A test reads the route table out of routes/mod.rs and asks every route
without a cookie: all but the login, the health check and the share
links must answer 401, including any route added later.
PUT /api/nodes/{id} takes base_updated_at and answers 409 when the node
was saved since; the editor sends it, so a save from another tab, the
phone or the SFTP mount is no longer overwritten in silence. :w! and
:wq! now force the write.
An autosave the server could not take is kept and retried with backoff,
and at once on the online event; network failures say "the server
cannot be reached" rather than "Failed to fetch". At boot that is no
longer shown as a login screen. A session that expires mid-edit shows
the login with a note, and logging back in resumes the open draft.
Folder listings and the media list are read page by page, so more than
500 children are no longer cut off. Search results carry the space and
folder they sit in, and the overview's recent list includes files.
Embedding uploads report progress, like uploads into a folder.
On touch devices vim is off by default. Each row has an actions sheet
(rename, move, share, seal, delete), folders start with a .. row, and
the dock gains today's entry, upload, search all spaces, drop for a
marked node, help, and the board's list, reorder, rename and delete.
The agenda gets a done button per card and a button for today's entry.
Sharing a folder or a file is refused in the client. :mv .. in a search
moves up from the match's own folder. Files dropped outside the list or
editor no longer navigate the tab away. The public share view renders
/n/ links as plain text, and tells a network error from a dead link.
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.
The mount listed documents and folders only. File nodes now appear
under their own name, with a later file that shares it told apart by its
id as in the mirror's files/ view. They are read from uploads/ a piece at
a time, and anything that is not a .md is streamed into a .part on disk,
up to the upload limit, and becomes a file node on close. A copy that
used to vanish at the end of the session is now kept. Writing over a
file gives it new bytes under a new id, rename and remove work as they
do for documents, and a dotted temp file renamed onto a real name is
kept as that file, which is how rsync and most editors save. Sealed
files are listed and refuse to open.
Closing a document that was saved in the browser while the handle was
open no longer overwrites that save: the SFTP write lands beside it as
<name>-conflict.md. The session's key is checked again whenever a file
or a directory is opened, so deleting a key ends a live mount. Adding or
removing a key now triggers a backup, like every other write.
The idle timeout was checked only when the key was next asked for, so a
vault nobody touched kept its key in memory indefinitely while status
reported it locked. A ticker now drops the key once it is idle, and
status() expires it too. Vault passwords are Zeroizing from the request
body to Argon2.
Re-sealing a file rewrote links with a case-sensitive replace, and only
in documents linked through node_media. Links are now matched by regex,
whatever their case, in every document and card body that names the id.
A folder re-seal carries on past a file that fails and reports how many
failed. Two re-seals of one file no longer both commit.
:share reports how many sealed files the document embeds, since a share
link never serves them, and :encrypt reports how many shared documents
embed what it just sealed. Vault status counts sealed files, so :export!
asks for the password first rather than navigating into a 423 page. A
sealed embed that fails to load while the vault is shut becomes a button
that unlocks it and puts the embed back.
An export used to lose what plain files cannot carry: dates, names as
typed, the boards. It now writes .workspace.json beside the tree (format
1), and an import that finds one restores them. The markdown stays
plain, so the archive still reads without the application.
POST /api/import (:import! / in the UI) imports at the root: each
top-level directory becomes a space, or merges into the space already
there by that name, with its board and cards and the diary role. With a
manifest, media/ comes back as embedded files rather than a folder, a
file whose name ends in .md stays a file, and empty folders come back.
Export claims every path in one set, folders, then documents, then
files, so a file named notes.md beside a document notes no longer makes
a duplicate entry that failed the whole zip. Links to folders are
rewritten like links to documents, in one regex pass per kind instead
of a replace per file and document. A sealed file that will not open
now fails the export instead of being left out silently. The temporary
zip is made in the data directory, not in /tmp.
Mails the relay refused ten times kept sent_at NULL and stayed in the
50-row window send_pending reads, so fifty of them stopped every later
mail for good. They are now left out of that query and kept only for
mail-status.
A card mail is checked against its card before it is sent and dropped
when the card has been finished, deleted, re-dated or put on a board
that was switched off. Due-card mails and the digest skip spaces whose
board is off, as the agenda already did. The reminder needs a diary
space to exist.
Mails that were sent or given up on are pruned after 90 days. A card's
key is kept while the card exists, because an overdue card is asked
about every day and the key is what stops a second mail.
Node update, delete and move each ran as separate statements, with the
slug chosen outside the write that took it. They are now one transaction
each, opened with BEGIN IMMEDIATE so a read-then-write cannot fail with
SQLITE_BUSY halfway. Creating a node, a space or a file does the same.
A delete is one function, delete_subtree, used by the API and the SFTP
mount alike: SFTP deletes used to skip the media cleanup and orphan the
files a document embedded. Rows go in the transaction, bytes after the
commit. Deleting a media row that backs a file in the tree is refused;
the media browser marks those files and stops counting them as
documents that embed them.
A space can no longer be moved into a folder. The diary is found by a
role column (migration 0013) instead of its slug, so renaming it no
longer breaks the reminder or the agenda, and it cannot be deleted. The
same migration adds the unique index root slugs never had.
A card is done exactly when it is in the last list: a done patch now
moves the card, a card made in the done list is stamped, and adding or
deleting lists settles the board. Seeding a fresh board is atomic, and
a card links only to a document.
Uploads are fsynced before the rename, and zip entries are written aside
and renamed like any other upload. A sweep at start and once a day
removes leftover .part files, files no row knows, and uploads nothing
has embedded for a week.
Before a new build migrates the database it writes a VACUUM INTO copy,
diary.db.pre-<version>, since migrations only go forward.
DELETE /api/media/{id} joined the raw, percent-decoded id onto uploads/,
so ..%2Fdiary.db deleted the database. Ids are now parsed as uuids before
they reach the filesystem, and a file is only removed when its row was.
Import panicked on a link like %aé: percent_decode sliced the string
between the bytes of a character. Hex digits are now read as bytes. The
SFTP write_at offset is checked before it is cast, so an offset near
u64::MAX no longer wraps past the size limit and panics the session.
The global body limit was the upload ceiling, about a gigabyte, and
applied to the login too. Only the upload routes take that much now;
other bodies stop at 16 MiB and the login at 16 KiB.
Writes a browser says came from another origin are refused, which
SameSite=Lax alone does not cover for sibling subdomains or for
multipart forms. Logout only locks the vault for a caller who is
logged in, so a cross-site form can no longer shut it.
Login and vault attempts go through one at a time, so a burst of
parallel guesses cannot all pass the throttle before the first failure
is recorded, and parallel Argon2 runs cannot pile up.
Sealed files are served with no-store instead of a year of cache, so
their plaintext does not outlive :lock in the browser. The SFTP host key
is created 0600 rather than narrowed after being written.
SIGTERM is handled: as PID 1 in the container it was ignored, and every
docker stop ended in SIGKILL. Requests get eight seconds to finish, the
pool is closed so the WAL is checkpointed, and compose runs tini.
WORKSPACE_SESSION_KEY_FILE names a file holding a 32-byte key, meant to
be a Docker secret. When it is set, proton-session.json is written as
AES-256-GCM ciphertext, so a copy of the data volume alone no longer
carries a usable session. That is what the OS keyring gives a native
host, for a container that has no Secret Service.
A plain session found while a key is set is sealed the first time it is
read. A sealed file without its key, or a malformed key file, is an
error at startup rather than a quiet fall back to plain JSON.
The stored session used to hold the mailbox password, which on a
single-password account is the login password. It now holds the per-key
passphrases derived from it at login, which unlock the keys but cannot
log in, change the password or turn 2FA off. Login also checks them
against the account's keys, so a wrong mailbox password fails there
rather than at the first backup.
A session stored by an older build is upgraded on the next start: its
key salts are already known, so the passphrases derive locally and the
file is rewritten without the password.
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.
data/uploads/<uuid> is what a restore needs but nobody can find a PDF
in it. files/<space>/<folder>/<name> holds the same bytes as the tree
shows them. Renames and moves become renames and moves on Drive, gone
files go to Drive's trash, emptied folders follow, and clashing names
get the later node's id. Failures there are logged, not fatal: the
restorable copy in data/ is already up when the view runs.