From 49b2056215e3da9890929a8f7bc5678d4ca189cd Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 18 Sep 2026 14:53:22 +0200 Subject: [PATCH] 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. --- .env.example | 61 +- Cargo.lock | 102 ++- Cargo.toml | 10 +- Dockerfile | 10 +- Makefile | 4 +- README.md | 209 +++-- compose.override.yml.example | 25 +- compose.yml | 10 +- migrations/0006_nodes.sql | 103 +++ migrations/0007_boards.sql | 45 + migrations/0008_notifications.sql | 37 + src/auth.rs | 16 +- src/backup/mod.rs | 20 +- src/backup/proton.rs | 21 +- src/backup/session.rs | 6 +- src/backup/sync.rs | 21 +- src/cli.rs | 114 ++- src/config.rs | 250 +++++- src/mail/mod.rs | 234 ++++++ src/main.rs | 25 +- src/notify/mod.rs | 546 ++++++++++++ src/routes/backup.rs | 2 +- src/routes/boards.rs | 553 +++++++++++++ src/routes/entries.rs | 307 ------- src/routes/export.rs | 190 +++-- src/routes/import.rs | 652 +++++++++++++++ src/routes/media.rs | 77 +- src/routes/mod.rs | 26 +- src/routes/nodes.rs | 775 ++++++++++++++++++ src/routes/session.rs | 16 +- src/routes/share.rs | 56 +- src/state.rs | 4 +- src/throttle.rs | 9 +- web/index.html | 2 +- web/package.json | 2 +- web/src/App.svelte | 193 ++++- web/src/lib/Board.svelte | 199 +++++ web/src/lib/CommandLine.svelte | 4 +- .../{EntryPane.svelte => DocumentPane.svelte} | 57 +- web/src/lib/Editor.svelte | 38 +- web/src/lib/Help.svelte | 52 +- web/src/lib/Login.svelte | 8 +- web/src/lib/MediaBrowser.svelte | 32 +- web/src/lib/Shared.svelte | 22 +- web/src/lib/Sidebar.svelte | 60 +- web/src/lib/SpaceBar.svelte | 78 ++ web/src/lib/StatusBar.svelte | 17 +- web/src/lib/api.ts | 171 +++- web/src/lib/commands.ts | 520 ++++++++---- web/src/lib/store.svelte.ts | 468 +++++++++-- 50 files changed, 5487 insertions(+), 972 deletions(-) create mode 100644 migrations/0006_nodes.sql create mode 100644 migrations/0007_boards.sql create mode 100644 migrations/0008_notifications.sql create mode 100644 src/mail/mod.rs create mode 100644 src/notify/mod.rs create mode 100644 src/routes/boards.rs delete mode 100644 src/routes/entries.rs create mode 100644 src/routes/import.rs create mode 100644 src/routes/nodes.rs create mode 100644 web/src/lib/Board.svelte rename web/src/lib/{EntryPane.svelte => DocumentPane.svelte} (66%) create mode 100644 web/src/lib/SpaceBar.svelte diff --git a/.env.example b/.env.example index 06c6936..5fc2477 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,76 @@ +# Every setting is read as WORKSPACE_. The old DIARY_ spelling is +# still accepted, so a deployment configured before the rename keeps running. + # The one and only account. Both are checked in constant time on login. -DIARY_USER=narl -DIARY_PASSWORD=change-me +WORKSPACE_USER=narl +WORKSPACE_PASSWORD=change-me # Signs session cookies. Rotate it to log every device out. 32+ random chars. # Generate one with: openssl rand -base64 32 -DIARY_SECRET=replace-with-32-random-characters +WORKSPACE_SECRET=replace-with-32-random-characters # Where the SQLite database and uploaded media live. -DIARY_DATA_DIR=./data +WORKSPACE_DATA_DIR=./data # Address to listen on. Put a TLS terminating reverse proxy in front of this. -DIARY_BIND=127.0.0.1:4242 +WORKSPACE_BIND=127.0.0.1:4242 # How long a login lasts, in days. -DIARY_SESSION_DAYS=30 +WORKSPACE_SESSION_DAYS=30 # Largest single upload, in megabytes. -DIARY_MAX_UPLOAD_MB=64 +WORKSPACE_MAX_UPLOAD_MB=64 # Set to 1 once the app is served over HTTPS, so the cookie gets `Secure`. -DIARY_SECURE_COOKIE=0 +WORKSPACE_SECURE_COOKIE=0 # --- Proton Drive backup ----------------------------------------------------- -# Off until `narl-diary proton-login` stores a session under DIARY_DATA_DIR. +# Off until `narl-workspace proton-login` stores a session under WORKSPACE_DATA_DIR. # These only shape how often the mirror runs. # The device the server registers in Proton Drive; its sync root is the folder # the diary is mirrored into. -DIARY_PROTON_DEVICE=narl-diary +WORKSPACE_PROTON_DEVICE=narl-workspace # Backstop between runs, in minutes. 0 leaves it change-driven only. -DIARY_BACKUP_INTERVAL_MIN=60 +WORKSPACE_BACKUP_INTERVAL_MIN=60 # How long the diary must be quiet after a change before it is mirrored, so a # writing session uploads once rather than after every save. -DIARY_BACKUP_DEBOUNCE_SEC=300 +WORKSPACE_BACKUP_DEBOUNCE_SEC=300 # Set to 1 to let a deletion here trash the mirrored file there. Off by # default: a backup that forgets on command is one accident from being none. -DIARY_BACKUP_PRUNE=0 +WORKSPACE_BACKUP_PRUNE=0 + +# --- Notification mail ------------------------------------------------------- +# Off until WORKSPACE_SMTP_URL is set. Nothing is even queued while it is unset. + +# The relay. `smtps://host:465` is implicit TLS, `smtp://host:587?tls=required` +# is STARTTLS, and a bare `smtp://host:1025` is plain — which only belongs on a +# local mail catcher. Credentials may live in the URL or in the two variables +# below. +# WORKSPACE_SMTP_URL=smtps://mail.example.org:465 +# WORKSPACE_SMTP_USER= +# WORKSPACE_SMTP_PASSWORD= + +# Where the mail comes from and goes to. FROM defaults to TO. +# WORKSPACE_MAIL_FROM=workspace@example.org +# WORKSPACE_MAIL_TO=you@example.org + +# "Did I write today?" and "is this due today?" are local-day questions, and the +# answer changes with the timezone rather than with a fixed offset. +WORKSPACE_TIMEZONE=Europe/Berlin + +# When to ask about the diary, if nothing was written in it that day. `off` +# turns it into no reminder at all. +WORKSPACE_REMINDER_AT=20:00 + +# When to mail about cards that are due today or already overdue. +WORKSPACE_CARD_DUE_AT=08:00 + +# Weekday and time of the weekly digest. +WORKSPACE_DIGEST_AT=sun 18:00 + +# Which of the four kinds may be sent. Drop one from the list to switch it off. +WORKSPACE_MAIL_KINDS=reminder,card_due,digest,login diff --git a/Cargo.lock b/Cargo.lock index ffed970..5e4a8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -653,6 +653,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1275,6 +1285,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1730,6 +1756,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.5.0" @@ -2137,6 +2174,34 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -2324,16 +2389,19 @@ dependencies = [ ] [[package]] -name = "narl-diary" +name = "narl-workspace" version = "0.1.0" dependencies = [ "anyhow", "axum", "base64 0.22.1", + "chrono", + "chrono-tz", "dotenvy", "hex", "hmac 0.12.1", "keyring", + "lettre", "mime_guess", "proton-drive-rs", "proton-sdk", @@ -2718,6 +2786,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2976,6 +3062,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "6.0.0" @@ -3296,7 +3388,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3675,6 +3769,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 31fcb78..e305d66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "narl-diary" +name = "narl-workspace" version = "0.1.0" edition = "2021" description = "A terminal-themed personal diary for the browser" @@ -32,6 +32,14 @@ tempfile = "3.27.0" # container deployment has no session bus and falls back to the session file. keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"], optional = true } +# Mail. rustls rather than native-tls: the container has no OpenSSL, and the +# Proton client already pulls rustls in. +lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "pool", "builder", "hostname", "tokio1-rustls-tls"] } +# "did I write today?" and "is this due today?" are local-day questions, and DST +# rules out doing them with a fixed offset. +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +chrono-tz = "0.10" + proton-drive-rs = "0.6.2" proton-sdk = "0.6.2" rpassword = "7" diff --git a/Dockerfile b/Dockerfile index c3f5487..1576bf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ COPY --from=web /web/dist/ ./web/dist/ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo build --release --locked \ - && cp target/release/narl-diary /usr/local/bin/narl-diary + && cp target/release/narl-workspace /usr/local/bin/narl-workspace # 3. Runtime. SQLite is compiled into the binary; the only thing the image has # to supply is a trust store. The Proton Drive client verifies TLS against @@ -32,11 +32,11 @@ RUN apt-get update \ && useradd --system --uid 10001 --create-home --home-dir /home/diary diary \ && mkdir -p /data \ && chown diary:diary /data -COPY --from=build /usr/local/bin/narl-diary /usr/local/bin/narl-diary +COPY --from=build /usr/local/bin/narl-workspace /usr/local/bin/narl-workspace USER diary WORKDIR /home/diary VOLUME ["/data"] -ENV DIARY_BIND=0.0.0.0:4242 \ - DIARY_DATA_DIR=/data +ENV WORKSPACE_BIND=0.0.0.0:4242 \ + WORKSPACE_DATA_DIR=/data EXPOSE 4242 -ENTRYPOINT ["/usr/local/bin/narl-diary"] +ENTRYPOINT ["/usr/local/bin/narl-workspace"] diff --git a/Makefile b/Makefile index ca2d2f4..4fc7259 100644 --- a/Makefile +++ b/Makefile @@ -7,13 +7,13 @@ dev-api: dev-web: cd web && bun run dev -# Everything, as one self-contained release binary at target/release/narl-diary. +# Everything, as one self-contained release binary at target/release/narl-workspace. build: cd web && bun install && bun run build cargo build --release run: build - ./target/release/narl-diary + ./target/release/narl-workspace check: cargo clippy --all-targets -- -D warnings diff --git a/README.md b/README.md index e96bb18..879c9d8 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ -# ~/diary +# ~/workspace -A private, terminal-themed diary that runs in the browser. One user, one SQLite -file, one binary. +A private, terminal-themed workspace that runs in the browser: a diary, the +notes for work and for uni, and a board per space. One user, one SQLite file, +one binary. It was called `narl-diary` until it grew everything but the diary. - **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend. -- Markdown entries with full-text search (SQLite FTS5). +- Spaces (`diary`, `work`, `uni`, …) with folders nested to any depth inside them. +- Markdown documents with full-text search across a whole space (SQLite FTS5). +- Import a folder — or a zip of one — as a tree of documents, links and all. +- A kanban board per space, so what is open at work is not mixed up with uni. - Drag, drop or paste images, video and audio straight into an entry. -- Share any entry behind an unguessable link — revocable at any time. +- Share any document behind an unguessable link — revocable at any time. - Backs itself up to Proton Drive, end-to-end encrypted, as a registered device. +- Mails you — a reminder when the diary is empty, due cards, a weekly digest. - Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on mobile. - The whole frontend is embedded in the release binary, so deploying is copying @@ -16,16 +21,20 @@ file, one binary. ## Quick start ```sh -cp .env.example .env # set DIARY_USER, DIARY_PASSWORD and DIARY_SECRET +cp .env.example .env # set WORKSPACE_USER, WORKSPACE_PASSWORD and WORKSPACE_SECRET make build # builds the frontend, then the release binary -./target/release/narl-diary +./target/release/narl-workspace ``` Then open . -`DIARY_SECRET` signs the session cookie; generate one with +Every setting is read as `WORKSPACE_`, with the older `DIARY_` +spelling still accepted behind it, so a server configured before the rename +keeps running untouched. + +`WORKSPACE_SECRET` signs the session cookie; generate one with `openssl rand -base64 32`. Changing it logs every device out. The database and -uploads live under `DIARY_DATA_DIR` (`./data` by default) — that directory is +uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is the entire backup, and it is exactly what the Proton Drive mirror copies. ### Development @@ -47,9 +56,11 @@ The desktop UI is modal. In the browse pane: | `j` / `k` | move down / up | | `gg` / `G` | first / last entry | | `Enter`, `l` | open the highlighted entry | -| `o` | new entry, straight into insert mode | +| `o` | a new document here, straight into insert mode | +| `O` | a new folder here | | `i`, `a` | edit the open entry | -| `Esc`, `h`, `q` | back out to the list | +| `Esc`, `q` | back out to the document | +| `h` | up one level in the tree | | `/` | full-text search (`n` clears it) | | `s` / `y` | toggle sharing / mint a new share link | | `x`, `dd` | delete the entry | @@ -59,46 +70,95 @@ The desktop UI is modal. In the browse pane: Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are -`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:title`, `:share`, `:link`, +`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`, `:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and -`:set novim`. +`:set novim`. The tree has its own: `:space [name]` switches (`:space! ` +makes one), `:mkdir ` makes a folder, `:cd ` walks it and +`:mv ` moves what is selected, and `:import` uploads a folder into +the one being browsed (`:import!` takes a `.zip` instead). The board has +`:board`, `:card ` (`:card!` links the open document to it), +`:due <yyyy-mm-dd|->`, `:done` and `:list <name>` (`:list!` deletes one). On a touch device modal editing is turned off and the same actions are buttons. +## Boards + +`:board` swaps the tree for the board of the space being browsed, and swaps it +back. Every space has one except the diary — a journal has no backlog — and +`:set board` / `:set noboard` changes that for the space you are in. A new +board starts with `backlog`, `doing` and `done`; `:list <name>` adds columns. + +| key | action | +| --- | --- | +| `h` / `l` | to the list on the left / right | +| `j` / `k` | down / up the cards of that list | +| `J` / `K` | move the card down / up its list | +| `H` / `L` | move the card to the list on the left / right | +| `Enter` | open the document the card links to | +| `t`, `Space` | tick the card off, or un-tick it | +| `o` | a new card on this list | +| `x` | delete the card | +| `q`, `Esc` | back to the tree | + +A card can point at a document: `:card!` while reading one makes a card for it, +and `Enter` on that card opens it again. The link is deliberately loose — +deleting the document leaves the card standing, because the work is still open +once its notes are gone. + ## Not losing things Every change is written to `localStorage` as it is typed and to the server a couple of seconds after typing stops, so `:w` is a habit rather than a -necessity. If a tab dies mid-entry, opening that entry again restores the draft -and says so — `:e!` throws it away, the way vim handles a swap file. +necessity. If a tab dies mid-document, opening it again restores the draft and +says so — `:e!` throws it away, the way vim handles a swap file. -`:export` downloads the open entry as markdown. `:export!` downloads the whole -diary as a zip: one markdown file per entry named by its day, every embedded -file under its real name, and relative links between the two, so the archive -reads in any markdown viewer without this application. +`:export` downloads the open document as markdown. `:export!` downloads the +whole workspace as a zip: the tree as real directories, one markdown file per +document, every embedded file under its real name, and relative links between +them, so the archive reads in any markdown viewer without this application. + +## Importing a folder + +`:import` picks a folder and uploads it whole — every file carries the path it +had inside, and the tree is rebuilt on the other side: directories become +folders, `.md` and `.markdown` files become documents (titled by their first +heading), anything else becomes an attached file. `:import!` does the same with +a zip, which is the shape `:export!` writes, so a workspace can be poured back +into another one. + +Links between the imported files are rewritten as they land: a link to a +markdown file becomes the document's address, a link to an image becomes its +media URL, and anything pointing outside the upload — a URL, a file that was not +uploaded — is left exactly as it was written. Absolute paths, `..` segments and +anything a zip smuggles in are refused, and the whole upload is bounded by +`WORKSPACE_MAX_UPLOAD_MB`. + +A document has a real address, `/n/<space>/<folder>/<slug>`, which is what those +rewritten links point at and what the address bar shows, so any document can be +linked to from outside — behind the login, unlike a share link. ## Backing up to Proton Drive The server can mirror itself into Proton Drive, where it appears as a device — its own sync root, alongside the desktop clients — rather than as a folder dropped in *My Files*. Everything is encrypted client-side before it leaves the -machine, by the same Rust SDK the Linux client uses, so Proton stores a diary it -cannot read. +machine, by the same Rust SDK the Linux client uses, so Proton stores a +workspace it cannot read. It is a third-party client: it identifies itself to Proton as -`external-drive-narl_diary@<version>-alpha` and says so before it asks for +`external-drive-narl_workspace@<version>-alpha` and says so before it asks for account details. It carries no Proton branding and is not supported by Proton. Set it up once, interactively, because SRP and 2FA need a human: ```sh -narl-diary proton-login # or, in Docker: -docker compose exec -it diary narl-diary proton-login +narl-workspace proton-login # or, in Docker: +docker compose exec -it workspace narl-workspace proton-login ``` That stores a session — tokens, the mailbox password needed to rebuild the key chain, and the account key salts — as a `0600` file next to the database. It -sits on the same volume as the diary it protects, and it is enough to read the +sits on the same volume as the workspace it protects, and it is enough to read the account, so the volume is the thing to keep private. Built with `--features keyring` on a host that has a Secret Service, the session goes to the OS keyring instead and the file is only a fallback; the container has no @@ -108,16 +168,17 @@ Afterwards the server resumes on its own and nothing prompts again. Refresh tokens are single-use, so every rotation is written back immediately — which is also why two servers must not share one session file. -The mirror is one-way and change-driven: a write marks the diary dirty, and once -it has been quiet for `DIARY_BACKUP_DEBOUNCE_SEC` the mirror runs, with -`DIARY_BACKUP_INTERVAL_MIN` as a backstop. An hour of writing is one backup, not +The mirror is one-way and change-driven: a write marks the workspace dirty, and once +it has been quiet for `WORKSPACE_BACKUP_DEBOUNCE_SEC` the mirror runs, with +`WORKSPACE_BACKUP_INTERVAL_MIN` as a backstop. An hour of writing is one backup, not sixty. The device folder ends up as a copy of the data directory: ```text -narl-diary/ the device — Proton allows only folders in a device root - data/ the diary's data directory, copied +narl-workspace/ the device — Proton allows only folders in a device root + data/ the data directory, copied RESTORE.txt what this is, and how to put it back diary.db a VACUUM INTO snapshot — consistent, no write-ahead log + (the file keeps the name it was created with) uploads/<uuid> every uploaded file, under the name the database knows ``` @@ -128,19 +189,58 @@ back into an empty data directory — no tool in between, which is the point. | command | what it does | | --- | --- | -| `narl-diary proton-login` | log in and enable backups | -| `narl-diary proton-status` | account, device, schedule, how much is mirrored | -| `narl-diary backup-now` | mirror once and exit — for cron, or for nerves | -| `narl-diary proton-logout` | forget the session; the mirror stays where it is | +| `narl-workspace proton-login` | log in and enable backups | +| `narl-workspace proton-status` | account, device, schedule, how much is mirrored | +| `narl-workspace backup-now` | mirror once and exit — for cron, or for nerves | +| `narl-workspace proton-logout` | forget the session; the mirror stays where it is | -From inside the diary, `:backup` says when the last one finished and `:backup!` +From inside the workspace, `:backup` says when the last one finished and `:backup!` runs one now. A failed backup is loud: it is reported by `:backup`, and the next tick retries it. -Deleting an entry does not delete it from the mirror unless `DIARY_BACKUP_PRUNE` +Deleting an entry does not delete it from the mirror unless `WORKSPACE_BACKUP_PRUNE` is on. A backup that forgets on command is one accident away from being no backup at all. + +## Mail + +Off until `WORKSPACE_SMTP_URL` is set, and then four kinds of mail, all plain +text and all addressed to `WORKSPACE_MAIL_TO`: + +| kind | when | +| --- | --- | +| `reminder` | at `WORKSPACE_REMINDER_AT`, if nothing was written in the diary space that day | +| `card_due` | at `WORKSPACE_CARD_DUE_AT`, for every card due that day, and again once it is overdue | +| `digest` | at `WORKSPACE_DIGEST_AT`: what was written per space, what was finished, what is still open | +| `login` | a browser signed in that has not signed in before, or a run of wrong passwords | + +`WORKSPACE_MAIL_KINDS` is the list of kinds that may be sent; drop one to switch +it off. Every "today" is asked in `WORKSPACE_TIMEZONE`, because whether an entry +was written today depends on where you are, and a day is not always 24 hours +long where the clocks change. + +Nothing is sent straight from the code that decides a mail is due. It goes into +an outbox table under a key that names the occurrence — `reminder:2026-09-18`, +`card_due:41:2026-09-20:overdue` — and a tick a minute drains it. A restart +cannot resend yesterday's reminder, because the row is already there; a relay +that refuses cannot lose one, because the row is still there and is retried with +a widening gap, up to an hour apart. + +The login mail says *a new browser*, not *a new person*: the device is +remembered as a SHA-256 of its `User-Agent`, which is honest about what it can +tell and needs no proxy header to be trusted for it. + +```sh +narl-workspace mail-test # one mail, straight out, bypassing the outbox +narl-workspace mail-status # what is configured, what is queued, what failed +``` + +SMTP settings are wrong the first time, always. `compose.override.yml.example` +carries a [mailpit](https://mailpit.axllent.org/) service for exactly that: +point `WORKSPACE_SMTP_URL` at `smtp://mailpit:1025` and read the +reminders at <http://127.0.0.1:8025> before involving a real relay. + ## Sharing `:share` mints two 192-bit random secrets and copies @@ -171,20 +271,30 @@ Everything except the two share routes requires the session cookie. | --- | --- | --- | | `POST` | `/api/login`, `/api/logout` | session | | `GET` | `/api/me` | current user | -| `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create | -| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete | -| `GET` | `/api/export` | every entry and file, as a zip | +| `GET`/`POST` | `/api/spaces` | the spaces / create one | +| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create | +| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) | +| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | +| `POST` | `/api/spaces/{id}/lists` | add a list | +| `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards | +| `POST` | `/api/lists/{id}/cards` | add a card | +| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, done, link) / delete | +| `POST` | `/api/cards/{id}/move` | to another list and place in it | +| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings | +| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node | +| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` | +| `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link | +| `GET` | `/api/export` | every document and file, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | -| `POST`/`DELETE` | `/api/entries/{id}/share` | mint or rotate / revoke a share link | | `GET`/`POST` | `/api/media` | list / upload (multipart) | | `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file | -| `GET` | `/api/share/{token}/{key}` | public: read a shared entry | -| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared entry | +| `GET` | `/api/share/{token}/{key}` | public: read a shared document | +| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document | ## Deploying -Put a TLS-terminating reverse proxy in front of it, point it at `DIARY_BIND`, -and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no +Put a TLS-terminating reverse proxy in front of it, point it at `WORKSPACE_BIND`, +and set `WORKSPACE_SECURE_COOKIE=1`. Nothing else is needed: no database server, no runtime dependencies, no build tools on the host. The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`, @@ -199,18 +309,21 @@ is handed back as an opaque download. ### Docker ```sh -cp .env.example .env # set DIARY_USER, DIARY_PASSWORD, DIARY_SECRET +cp .env.example .env # set WORKSPACE_USER, WORKSPACE_PASSWORD, WORKSPACE_SECRET docker compose up -d --build ``` The image builds the frontend with bun and the binary with cargo, then ships only the binary on `debian:trixie-slim` (~86 MB), running as an unprivileged -user. `DIARY_BIND` and `DIARY_DATA_DIR` are forced in `compose.yml`, so the +user. `WORKSPACE_BIND` and `WORKSPACE_DATA_DIR` are forced in `compose.yml`, so the values in `.env` do not have to change; everything else is read from `.env`. -Database and uploads live in the `diary-data` volume and survive recreation. +Database and uploads live in the `diary-data` volume — named for what it held +when it was made — and survive recreation. Keep the project name if you rename +the directory (`COMPOSE_PROJECT_NAME=narl-diary`), or compose will look for the +volume under a new name and find an empty one. By default the port is published on `127.0.0.1:4242` only. To serve it through traefik instead, copy `compose.override.yml.example` to `compose.override.yml` and adjust the host — it joins the external `narl` network, drops the published -port, and routes to container port 4242. Set `DIARY_SECURE_COOKIE=1` in `.env` +port, and routes to container port 4242. Set `WORKSPACE_SECURE_COOKIE=1` in `.env` once it is served over HTTPS. diff --git a/compose.override.yml.example b/compose.override.yml.example index 7fdd7f7..fea40da 100644 --- a/compose.override.yml.example +++ b/compose.override.yml.example @@ -1,6 +1,6 @@ # Copy to compose.override.yml on the host that runs traefik. services: - diary: + workspace: ports: !override [] networks: - narl @@ -8,10 +8,25 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=narl" - - "traefik.http.routers.diary.rule=Host(`diary.narl.io`)" - - "traefik.http.routers.diary.entrypoints=websecure" - - "traefik.http.routers.diary.tls.certresolver=https_resolver" - - "traefik.http.services.diary.loadbalancer.server.port=4242" + - "traefik.http.routers.workspace.rule=Host(`workspace.narl.io`)" + - "traefik.http.routers.workspace.entrypoints=websecure" + - "traefik.http.routers.workspace.tls.certresolver=https_resolver" + - "traefik.http.services.workspace.loadbalancer.server.port=4242" + + # Mail, locally. Mailpit accepts everything on 1025 and shows it on 8025, so + # the reminders can be read before a real relay is ever involved: + # + # WORKSPACE_SMTP_URL=smtp://mailpit:1025 + # WORKSPACE_MAIL_TO=you@example.org + # + # Nothing it receives leaves the host, which is the point of testing here. + mailpit: + image: axllent/mailpit:latest + restart: unless-stopped + networks: + - internal_net + ports: + - "127.0.0.1:8025:8025" networks: narl: diff --git a/compose.yml b/compose.yml index 372c1cd..a40a2cc 100644 --- a/compose.yml +++ b/compose.yml @@ -1,16 +1,18 @@ services: - diary: + workspace: build: . - image: narl-diary:latest + image: narl-workspace:latest restart: unless-stopped env_file: - .env environment: # These win over anything in .env: inside the container the app must # listen on all interfaces and keep its state on the volume. - DIARY_BIND: 0.0.0.0:4242 - DIARY_DATA_DIR: /data + WORKSPACE_BIND: 0.0.0.0:4242 + WORKSPACE_DATA_DIR: /data volumes: + # Named for what it held when it was made. Renaming it would mean moving + # the database into a new volume by hand, which is no kind of upgrade. - diary-data:/data networks: - internal_net diff --git a/migrations/0006_nodes.sql b/migrations/0006_nodes.sql new file mode 100644 index 0000000..e843085 --- /dev/null +++ b/migrations/0006_nodes.sql @@ -0,0 +1,103 @@ +-- The diary grows sub-structures. A flat list of dated entries becomes a tree: +-- top-level spaces (diary, work, uni, ...), folders inside them to any depth, +-- and documents as the leaves. One recursive table holds all three, so a space +-- is simply a node without a parent and nesting costs nothing extra. +-- +-- Ids are carried over unchanged. Share tokens already handed out, the +-- entry/media links and the `diary:draft:<id>` keys browsers hold in +-- localStorage all key on the entry id, and none of them can be rewritten from +-- here. The `diary` space therefore takes an id above every existing entry. + +CREATE TABLE nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER REFERENCES nodes (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('space', 'folder', 'document')), + -- A document's title, a folder's or space's name. + name TEXT NOT NULL DEFAULT '', + -- URL-safe, unique among siblings: the path segment in /n/<space>/<slug>. + slug TEXT NOT NULL, + -- Documents only; folders and spaces keep it empty. + body TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + -- Spaces only. The diary is a journal, not a project, so it has no board. + has_board INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + share_token TEXT UNIQUE, + share_key_hash TEXT +); + +CREATE UNIQUE INDEX idx_nodes_parent_slug ON nodes (parent_id, slug); +CREATE INDEX idx_nodes_parent ON nodes (parent_id, position, created_at DESC); +CREATE INDEX idx_nodes_share_token ON nodes (share_token); + +-- Same shape and same reasoning as entry_media in 0002: a file may be embedded +-- by more than one document, and deleting one of them must not take the file +-- away from the others. +CREATE TABLE node_media ( + node_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE, + media_id TEXT NOT NULL REFERENCES media (id) ON DELETE CASCADE, + PRIMARY KEY (node_id, media_id) +) WITHOUT ROWID; + +CREATE INDEX idx_node_media_media_id ON node_media (media_id); + +-- The three spaces to start from. Their ids sit above every entry id so the +-- documents below can keep theirs. +INSERT INTO nodes (id, parent_id, kind, name, slug, position, has_board, created_at, updated_at) +VALUES + ((SELECT COALESCE(MAX(id), 0) FROM entries) + 1, NULL, 'space', 'diary', 'diary', 0, 0, + strftime('%s', 'now'), strftime('%s', 'now')), + ((SELECT COALESCE(MAX(id), 0) FROM entries) + 2, NULL, 'space', 'work', 'work', 1, 1, + strftime('%s', 'now'), strftime('%s', 'now')), + ((SELECT COALESCE(MAX(id), 0) FROM entries) + 3, NULL, 'space', 'uni', 'uni', 2, 1, + strftime('%s', 'now'), strftime('%s', 'now')); + +-- Every entry becomes a document in the diary space, id intact. The slug +-- carries the day the entry is about and its id, which reads well in a URL and +-- cannot collide with a second entry written on the same day. +INSERT INTO nodes (id, parent_id, kind, name, slug, body, position, + created_at, updated_at, share_token, share_key_hash) +SELECT e.id, + (SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary'), + 'document', + e.title, + date(e.created_at, 'unixepoch') || '-' || e.id, + e.body, + 0, + e.created_at, + e.updated_at, + e.share_token, + e.share_key_hash +FROM entries e; + +INSERT INTO node_media (node_id, media_id) +SELECT entry_id, media_id FROM entry_media; + +-- Search follows. Folder and space names are indexed alongside document bodies, +-- because "where did I put that folder" is the same question as "where did I +-- write that sentence". +CREATE VIRTUAL TABLE nodes_fts USING fts5 ( + name, body, content = 'nodes', content_rowid = 'id', tokenize = 'unicode61' +); + +CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts (rowid, name, body) VALUES (new.id, new.name, new.body); +END; +CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts (nodes_fts, rowid, name, body) VALUES ('delete', old.id, old.name, old.body); +END; +CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN + INSERT INTO nodes_fts (nodes_fts, rowid, name, body) VALUES ('delete', old.id, old.name, old.body); + INSERT INTO nodes_fts (rowid, name, body) VALUES (new.id, new.name, new.body); +END; + +-- The rows above were inserted before the triggers existed. +INSERT INTO nodes_fts (nodes_fts) VALUES ('rebuild'); + +DROP TRIGGER entries_ai; +DROP TRIGGER entries_ad; +DROP TRIGGER entries_au; +DROP TABLE entries_fts; +DROP TABLE entry_media; +DROP TABLE entries; diff --git a/migrations/0007_boards.sql b/migrations/0007_boards.sql new file mode 100644 index 0000000..ba0451c --- /dev/null +++ b/migrations/0007_boards.sql @@ -0,0 +1,45 @@ +-- A board per space, so "what am I working on" can be asked per space instead +-- of across everything at once. There is no board table: a space *is* its +-- board, and `nodes.has_board` decides whether the view exists. The diary is a +-- journal rather than a project and keeps that flag off. + +CREATE TABLE board_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- A space. Dropping the space drops its board with it. + space_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_board_lists_space ON board_lists (space_id, position); + +CREATE TABLE cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL REFERENCES board_lists (id) ON DELETE CASCADE, + -- The document this card is about, if any. Deleting that document must not + -- delete the card: the work is still open, only its notes are gone. + node_id INTEGER REFERENCES nodes (id) ON DELETE SET NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + due_at INTEGER, + done_at INTEGER, + position INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX idx_cards_list ON cards (list_id, position); +CREATE INDEX idx_cards_node ON cards (node_id); +-- The reminder mails in the next phase ask "what is due", which is a question +-- about every board at once. +CREATE INDEX idx_cards_due ON cards (due_at) WHERE due_at IS NOT NULL AND done_at IS NULL; + +-- The three columns every board starts with, for the spaces that already exist. +INSERT INTO board_lists (space_id, name, position, created_at) +SELECT n.id, l.name, l.position, strftime('%s', 'now') +FROM nodes n +CROSS JOIN (SELECT 'backlog' AS name, 0 AS position + UNION ALL SELECT 'doing', 1 + UNION ALL SELECT 'done', 2) l +WHERE n.parent_id IS NULL AND n.kind = 'space' AND n.has_board <> 0; diff --git a/migrations/0008_notifications.sql b/migrations/0008_notifications.sql new file mode 100644 index 0000000..8da2e41 --- /dev/null +++ b/migrations/0008_notifications.sql @@ -0,0 +1,37 @@ +-- Mail is sent through an outbox rather than straight from the code that +-- decides a mail is due. Two reasons, both of which have bitten every reminder +-- that skips this step: a restart must not send yesterday's reminder again, and +-- a relay that is down for an hour must not lose the mail it refused. +-- +-- `dedupe_key` carries the whole idea. It spells out *which* occurrence this is +-- ("reminder:2026-09-18", "card_due:41:2026-09-20:overdue"), so deciding +-- whether a mail was already sent is an insert that either takes or does not. + +CREATE TABLE notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + dedupe_key TEXT NOT NULL UNIQUE, + subject TEXT NOT NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL, + sent_at INTEGER, + attempts INTEGER NOT NULL DEFAULT 0, + -- When the last attempt was made, which is what the backoff is measured + -- from; a count alone cannot say whether it is time to try again. + last_attempt_at INTEGER, + last_error TEXT +); + +-- The sweep asks one question every minute: what is still unsent? +CREATE INDEX idx_notifications_pending ON notifications (created_at) WHERE sent_at IS NULL; + +-- Which browsers have logged in. The hash is over the User-Agent, so this can +-- honestly say "a browser you have not used before", not "someone else" — and +-- it needs no proxy header to be trusted for it. +CREATE TABLE login_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_hash TEXT NOT NULL UNIQUE, + user_agent TEXT NOT NULL, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); diff --git a/src/auth.rs b/src/auth.rs index d519e93..f1efc25 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -9,6 +9,8 @@ use subtle::ConstantTimeEq; use crate::{config::Config, error::AppError, state::AppState}; +// Kept from before the rename: the name is never seen, and changing it would +// sign every browser out to no purpose. pub const COOKIE_NAME: &str = "diary_session"; type HmacSha256 = Hmac<Sha256>; @@ -20,7 +22,7 @@ fn sign(secret: &[u8], payload: &str) -> String { } /// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no -/// server-side session store: rotating `DIARY_SECRET` invalidates every session. +/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session. pub fn issue_token(config: &Config) -> String { let expires = crate::now() + config.session_days * 86_400; let payload = B64.encode(expires.to_string()); @@ -98,7 +100,7 @@ impl FromRequestParts<AppState> for Session { #[cfg(test)] mod tests { use super::{credentials_match, issue_token, verify_token}; - use crate::config::{BackupConfig, Config}; + use crate::config::{BackupConfig, Config, MailConfig}; fn config(session_days: i64) -> Config { Config { @@ -116,6 +118,16 @@ mod tests { debounce: std::time::Duration::from_secs(0), prune: false, }, + mail: MailConfig { + url: None, + from: String::new(), + to: String::new(), + timezone: chrono_tz::UTC, + reminder_at: None, + card_due_at: None, + digest_at: None, + kinds: Vec::new(), + }, } } diff --git a/src/backup/mod.rs b/src/backup/mod.rs index ba2a948..b43346c 100644 --- a/src/backup/mod.rs +++ b/src/backup/mod.rs @@ -1,16 +1,16 @@ //! Automatic backup of the whole data directory to Proton Drive. //! //! The server registers itself as a Drive *device* — a sync root with its own -//! share — and keeps that folder equal to `DIARY_DATA_DIR`. It is deliberately +//! share — and keeps that folder equal to `WORKSPACE_DATA_DIR`. It is deliberately //! one-way: this is a backup, not a two-master sync, so nothing that happens in -//! Proton Drive can ever reach back and change the diary. +//! Proton Drive can ever reach back and change the workspace. //! -//! It is dormant until someone runs `narl-diary proton-login` once. Nothing +//! It is dormant until someone runs `narl-workspace proton-login` once. Nothing //! else in the application depends on it, and a Proton outage costs log lines, //! never a write. //! //! Runs are change-driven with a timer as a backstop: entry and media routes -//! call [`Backup::signal`], the loop waits for the diary to fall quiet, and +//! call [`Backup::signal`], the loop waits for the workspace to fall quiet, and //! mirrors once. An hour of writing is one backup, not sixty. pub mod proton; @@ -72,7 +72,7 @@ impl Backup { config, store, wake: Notify::new(), - // The diary may have been written to while this process was down, + // The workspace may have been written to while this process was down, // and a restart is the cheapest moment to find out. dirty: AtomicBool::new(true), status: Mutex::new(Status { @@ -168,7 +168,7 @@ impl Backup { /// Nothing is written to the device's own root, because Proton refuses it — /// a device root holds folders, and a file there is answered with 422 /// "Cannot create file at the root of a device". `data/` is that folder, - /// and it is also the honest name for what it holds: the diary's data + /// and it is also the honest name for what it holds: the workspace's data /// directory, copied. /// /// Remembering them is the point. Resolving either by name means listing a @@ -278,7 +278,7 @@ impl Backup { pub fn spawn(backup: Arc<Backup>) { if backup.store.load().ok().flatten().is_none() { tracing::info!( - "Proton Drive backups are not configured — run `narl-diary proton-login` to enable them" + "Proton Drive backups are not configured — run `narl-workspace proton-login` to enable them" ); return; } @@ -301,7 +301,7 @@ pub fn spawn(backup: Arc<Backup>) { } } - // Let the diary fall quiet. Every further change restarts the wait, + // Let the workspace fall quiet. Every further change restarts the wait, // so a writing session is mirrored once, when it is over. loop { tokio::select! { @@ -315,11 +315,11 @@ pub fn spawn(backup: Arc<Backup>) { }); } -/// One-shot mirror for `narl-diary backup-now`, without starting a server. +/// One-shot mirror for `narl-workspace backup-now`, without starting a server. pub async fn run_once(db: SqlitePool, config: Arc<Config>) -> Result<Summary> { let backup = Backup::new(db, config); backup .run_now() .await? - .context("no Proton session stored — run `narl-diary proton-login` first") + .context("no Proton session stored — run `narl-workspace proton-login` first") } diff --git a/src/backup/proton.rs b/src/backup/proton.rs index be71fd1..9700a46 100644 --- a/src/backup/proton.rs +++ b/src/backup/proton.rs @@ -8,7 +8,7 @@ use proton_sdk::session::ProtonApiSession; use super::session::{SessionStore, StoredSession}; /// What Proton asks a third-party client to tell anyone it takes credentials -/// from. Shown by `narl-diary proton-login` before it asks for anything. +/// from. Shown by `narl-workspace proton-login` before it asks for anything. pub const DISCLOSURE: &str = "This is a third-party application not officially supported by Proton."; @@ -20,10 +20,10 @@ pub const DISCLOSURE: &str = /// application — a header that misrepresents its client, or imitates a /// first-party Proton one, is a rule violation, not a cosmetic detail, and a /// malformed one is answered with a 422 "unusual activity" that no login -/// survives. So: `narl_diary` is the application, the version is the crate's +/// survives. So: `narl_workspace` is the application, the version is the crate's /// own, and the channel follows it — a `0.x` release is not a stable one, and /// saying otherwise would be the same misrepresentation in miniature. -const APP_NAME: &str = "narl_diary"; +const APP_NAME: &str = "narl_workspace"; fn app_version() -> String { let version = env!("CARGO_PKG_VERSION"); @@ -34,11 +34,8 @@ fn app_version() -> String { /// The identification headers. Overridable only for the case the shape itself /// changes on Proton's side; the honest default is the one above. fn client_config() -> ProtonClientConfiguration { - let version = std::env::var("DIARY_PROTON_APP_VERSION") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(app_version); - let agent = format!("narl-diary/{}", env!("CARGO_PKG_VERSION")); + let version = crate::config::var("PROTON_APP_VERSION").unwrap_or_else(app_version); + let agent = format!("narl-workspace/{}", env!("CARGO_PKG_VERSION")); ProtonClientConfiguration::new(version).with_user_agent(agent) } @@ -95,7 +92,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> { if stored.key_salts.is_empty() { return Err(anyhow!( - "the stored Proton session has no key salts — run `narl-diary proton-login` again" + "the stored Proton session has no key salts — run `narl-workspace proton-login` again" )); } @@ -119,7 +116,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> { stored.mailbox_password.clone().into_bytes(), stored.key_salts.clone(), ) - // A diary is mostly small files; one atomic request beats the + // A workspace is mostly small files; one atomic request beats the // draft/block/commit dance for anything that fits in a single block. .with_small_file_upload(true); @@ -128,7 +125,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> { /// The device this server owns, registered on first use. /// -/// A device is a sync root with its own share, which is what makes the diary +/// A device is a sync root with its own share, which is what makes the workspace /// appear in Proton Drive as a machine rather than as a folder someone dropped /// in My Files — and keeps it out of the way of everything else in the account. pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Device> { @@ -138,7 +135,7 @@ pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Dev .context("could not list the account's devices")?; // A device whose name failed to decrypt is not ours to claim: adopting it - // would mirror the diary into a stranger of a folder. + // would mirror the workspace into a stranger of a folder. if let Some(device) = devices .into_iter() .find(|d| d.name.as_deref().map(|n| n == name).unwrap_or(false)) diff --git a/src/backup/session.rs b/src/backup/session.rs index 3525ca6..85e4506 100644 --- a/src/backup/session.rs +++ b/src/backup/session.rs @@ -85,9 +85,9 @@ impl StoredSession { /// Where a session is kept. /// /// The OS keyring is the right home for this and is what the desktop client -/// uses — but it is a Secret Service on D-Bus, and the diary's supported +/// uses — but it is a Secret Service on D-Bus, and the workspace's supported /// deployment is a container, where there is neither. So the default is a -/// `0600` file next to the database: on the same volume as the diary it is +/// `0600` file next to the database: on the same volume as the workspace it is /// protecting, and no weaker than the thing it sits beside. Build with /// `--features keyring` on a host that has a session bus to use the keyring /// instead, with the file as the fallback when it cannot be reached. @@ -174,6 +174,8 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { } #[cfg(feature = "keyring")] +// Names an entry that may already exist in the OS keyring; renaming it would +// lose the session it points at. const KEYRING_SERVICE: &str = "narl-diary"; #[cfg(feature = "keyring")] const KEYRING_USER: &str = "proton-session"; diff --git a/src/backup/sync.rs b/src/backup/sync.rs index 9e34741..1f14d10 100644 --- a/src/backup/sync.rs +++ b/src/backup/sync.rs @@ -1,17 +1,17 @@ -//! The mirror itself: make the device folder look like `DIARY_DATA_DIR`. +//! The mirror itself: make the device folder look like `WORKSPACE_DATA_DIR`. //! //! The remote layout is deliberately the local one, not a rendering of it: //! //! ```text -//! narl-diary/ the device's sync root — folders only, per Proton -//! data/ the diary's data directory, copied +//! narl-workspace/ the device's sync root — folders only, per Proton +//! data/ the workspace's data directory, copied //! RESTORE.txt what this is and how to put it back //! diary.db a consistent snapshot, one revision per change //! uploads/<uuid> every uploaded file, under the name the database knows //! ``` //! //! so restoring is copying two things into an empty data directory, with no -//! tool in between. The readable-anywhere view of the diary is what `:export!` +//! tool in between. The readable-anywhere view of the workspace is what `:export!` //! is for; this is the copy that can be *restored*. use std::path::{Path, PathBuf}; @@ -34,17 +34,18 @@ pub struct Summary { } const RESTORE: &str = "\ -This is a Proton Drive mirror of a ~/diary server. It is the contents of the +This is a Proton Drive mirror of a ~/workspace server. It is the contents of the server's data directory, one folder down from the device root because Proton does not allow files directly in it. - diary.db the whole diary: entries, media metadata, share tokens + diary.db everything written: documents, boards, media metadata, + share tokens (the file keeps its original name) uploads/<uuid> every uploaded file; diary.db gives each one its real name -To restore, stop the diary, empty its data directory, and copy both back: +To restore, stop the workspace, empty its data directory, and copy both back: - DIARY_DATA_DIR/diary.db - DIARY_DATA_DIR/uploads/ + WORKSPACE_DATA_DIR/diary.db + WORKSPACE_DATA_DIR/uploads/ The database is a snapshot taken with `VACUUM INTO`, so it is internally consistent and carries no write-ahead log: it can be opened directly. @@ -357,7 +358,7 @@ pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> { } /// Streaming sha256 of a file, on the blocking pool: the database snapshot is -/// as large as the diary, and photographs are larger. +/// as large as the workspace, and photographs are larger. async fn hash_file(path: PathBuf) -> Result<String> { tokio::task::spawn_blocking(move || -> Result<String> { let mut file = std::fs::File::open(&path) diff --git a/src/cli.rs b/src/cli.rs index 1805038..b29d904 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,24 +5,29 @@ //! stored session, so the server itself never prompts for anything. In Docker: //! //! ```sh -//! docker compose exec -it diary narl-diary proton-login +//! docker compose exec -it workspace narl-workspace proton-login //! ``` -use std::io::{IsTerminal, Write}; +use std::{ + io::{IsTerminal, Write}, + sync::Arc, +}; use anyhow::{bail, Context, Result}; -use sqlx::SqlitePool; +use sqlx::{Row, SqlitePool}; -use crate::{backup, config::Config}; +use crate::{backup, config::Config, notify}; pub const USAGE: &str = "\ -narl-diary — a terminal-themed personal diary +narl-workspace — a terminal-themed personal workspace - narl-diary serve the diary (the default) - narl-diary proton-login log in to Proton Drive and enable backups - narl-diary proton-status show whether backups are configured - narl-diary proton-logout forget the stored Proton session - narl-diary backup-now mirror to Proton Drive once and exit + narl-workspace serve the workspace (the default) + narl-workspace proton-login log in to Proton Drive and enable backups + narl-workspace proton-status show whether backups are configured + narl-workspace proton-logout forget the stored Proton session + narl-workspace backup-now mirror to Proton Drive once and exit + narl-workspace mail-test send one mail through the configured relay + narl-workspace mail-status show what mail is configured and what is queued "; pub async fn proton_login(config: &Config) -> Result<()> { @@ -58,7 +63,7 @@ pub async fn proton_login(config: &Config) -> Result<()> { .await?; println!("Logged in. The session is stored at {}.", store.path().display()); - println!("Backups start with the next server start, or run `narl-diary backup-now`."); + println!("Backups start with the next server start, or run `narl-workspace backup-now`."); Ok(()) } @@ -76,7 +81,7 @@ pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> { let store = backup::session::SessionStore::new(config.proton_session_path()); let Some(stored) = store.load()? else { println!("Proton Drive backups are not configured."); - println!("Run `narl-diary proton-login` to enable them."); + println!("Run `narl-workspace proton-login` to enable them."); return Ok(()); }; @@ -127,3 +132,88 @@ fn prompt(label: &str) -> Result<String> { } Ok(line) } + +/// One mail, straight out. SMTP credentials are wrong the first time, always, +/// and finding that out from a reminder that quietly never arrived is worse +/// than finding it out here. +pub async fn mail_test(db: SqlitePool, config: Arc<Config>) -> Result<()> { + let notifier = notify::Notifier::new(db, config)?; + notifier + .send_test() + .await + .context("the test mail could not be sent")?; + println!("Sent. If it does not arrive, the relay accepted it and something after that did not."); + Ok(()) +} + +pub async fn mail_status(db: &SqlitePool, config: &Config) -> Result<()> { + let mail = &config.mail; + let Some(url) = mail.url.as_deref() else { + println!("Mail is off. Set WORKSPACE_SMTP_URL to enable notifications."); + return Ok(()); + }; + + // The URL may carry a password; only the part that identifies the relay is + // worth printing, and printing the rest into a terminal log is not. + let relay = url.split('@').next_back().unwrap_or(url); + println!("Relay: {relay}"); + println!("From: {}", mail.from); + println!("To: {}", mail.to); + println!("Timezone: {}", mail.timezone); + match mail.reminder_at { + Some(at) if mail.sends("reminder") => { + println!("Reminder: {:02}:{:02} when nothing was written", at.hour, at.minute) + } + _ => println!("Reminder: off"), + } + match mail.card_due_at { + Some(at) if mail.sends("card_due") => { + println!("Cards: {:02}:{:02} for due and overdue cards", at.hour, at.minute) + } + _ => println!("Cards: off"), + } + match mail.digest_at { + Some((day, at)) if mail.sends("digest") => println!( + "Digest: {} {:02}:{:02}", + notify::weekday_name(day), + at.hour, + at.minute + ), + _ => println!("Digest: off"), + } + println!("Logins: {}", if mail.sends("login") { "on" } else { "off" }); + + let row = sqlx::query( + "SELECT count(*) AS queued, + sum(CASE WHEN sent_at IS NULL THEN 1 ELSE 0 END) AS pending, + max(sent_at) AS last_sent + FROM notifications", + ) + .fetch_one(db) + .await?; + let queued: i64 = row.get("queued"); + let pending: Option<i64> = row.get("pending"); + let last_sent: Option<i64> = row.get("last_sent"); + println!("Outbox: {queued} mails, {} unsent", pending.unwrap_or(0)); + match last_sent { + Some(at) => println!( + "Last out: {}", + crate::notify::local_stamp(config.mail.timezone, at) + ), + None => println!("Last out: never"), + } + + let failed = sqlx::query( + "SELECT dedupe_key, attempts, last_error FROM notifications + WHERE sent_at IS NULL AND last_error IS NOT NULL ORDER BY created_at LIMIT 5", + ) + .fetch_all(db) + .await?; + for row in failed { + let key: String = row.get("dedupe_key"); + let attempts: i64 = row.get("attempts"); + let error: String = row.get("last_error"); + println!(" {key}: {attempts} attempt(s), {error}"); + } + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 05ce612..4684314 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,8 @@ use std::{net::SocketAddr, path::PathBuf, time::Duration}; use anyhow::{Context, Result}; +use chrono::Weekday; +use chrono_tz::Tz; /// Runtime configuration. Everything comes from the environment (`.env` is /// loaded on startup), because this is a deliberately single-user application. @@ -15,10 +17,100 @@ pub struct Config { pub max_upload_bytes: usize, pub secure_cookie: bool, pub backup: BackupConfig, + pub mail: MailConfig, +} + +/// Time of day in the configured timezone, as `HH:MM`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeOfDay { + pub hour: u32, + pub minute: u32, +} + +/// Notification mail. Dormant until `WORKSPACE_SMTP_URL` is set — the same +/// dormant-until-configured shape the Proton mirror has, because a workspace +/// that refuses to start over an unconfigured mail relay would be a bad trade. +#[derive(Debug, Clone)] +pub struct MailConfig { + /// `smtp://host:587` or `smtps://user:pass@host:465`. `None` turns every + /// notification off, including the queue that would otherwise pile up. + pub url: Option<String>, + pub from: String, + pub to: String, + /// The timezone every "today" in the notifier is asked in. + pub timezone: Tz, + /// When to ask about the diary, if no document was written that day. + pub reminder_at: Option<TimeOfDay>, + /// When to mail about cards that are due or overdue. + pub card_due_at: Option<TimeOfDay>, + /// Weekday and time of the digest. + pub digest_at: Option<(Weekday, TimeOfDay)>, + /// The kinds that are allowed to be sent: `reminder`, `card_due`, `digest`, + /// `login`. Listing them explicitly is how a single kind is switched off. + pub kinds: Vec<String>, +} + +impl MailConfig { + /// Whether mail is configured at all. + pub fn enabled(&self) -> bool { + self.url.is_some() + } + + pub fn sends(&self, kind: &str) -> bool { + self.enabled() && self.kinds.iter().any(|k| k == kind) + } +} + +/// `20:00`, or `off` for nothing at all. +fn parse_time(key: &str, raw: &str) -> Result<Option<TimeOfDay>> { + if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") { + return Ok(None); + } + let (hour, minute) = raw + .split_once(':') + .with_context(|| format!("{key} must look like 20:00, or be `off`"))?; + let time = TimeOfDay { + hour: hour.trim().parse().with_context(|| format!("{key}: {hour:?} is not an hour"))?, + minute: minute + .trim() + .parse() + .with_context(|| format!("{key}: {minute:?} is not a minute"))?, + }; + if time.hour > 23 || time.minute > 59 { + anyhow::bail!("{key} must be between 00:00 and 23:59"); + } + Ok(Some(time)) +} + +fn parse_weekday(raw: &str) -> Option<Weekday> { + match raw.to_ascii_lowercase().as_str() { + "mon" | "monday" => Some(Weekday::Mon), + "tue" | "tuesday" => Some(Weekday::Tue), + "wed" | "wednesday" => Some(Weekday::Wed), + "thu" | "thursday" => Some(Weekday::Thu), + "fri" | "friday" => Some(Weekday::Fri), + "sat" | "saturday" => Some(Weekday::Sat), + "sun" | "sunday" => Some(Weekday::Sun), + _ => None, + } +} + +/// `sun 18:00`, or `off`. +fn parse_digest(raw: &str) -> Result<Option<(Weekday, TimeOfDay)>> { + let key = "WORKSPACE_DIGEST_AT"; + let Some((day, time)) = raw.split_once(char::is_whitespace) else { + if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") { + return Ok(None); + } + anyhow::bail!("{key} must look like `sun 18:00`, or be `off`"); + }; + let day = parse_weekday(day.trim()) + .with_context(|| format!("{key}: {day:?} is not a weekday like `sun`"))?; + Ok(parse_time(key, time.trim())?.map(|time| (day, time))) } /// The Proton Drive mirror. Dormant until a session has been stored by -/// `narl-diary proton-login`; these values only shape how often it runs. +/// `narl-workspace proton-login`; these values only shape how often it runs. #[derive(Debug, Clone)] pub struct BackupConfig { /// Name of the Drive device (its sync-root folder) this server owns. @@ -26,7 +118,7 @@ pub struct BackupConfig { /// Backstop between runs. `None` disables the timer, leaving the mirror to /// whatever `POST /api/backup` asks for. pub interval: Option<Duration>, - /// How long the diary must be quiet after a change before it is mirrored, + /// How long the workspace must be quiet after a change before it is mirrored, /// so a writing session is uploaded once rather than after every keystroke. pub debounce: Duration, /// Whether a file deleted here is trashed there. Off by default: a backup @@ -34,51 +126,60 @@ pub struct BackupConfig { pub prune: bool, } -fn var(key: &str) -> Option<String> { - std::env::var(key).ok().filter(|v| !v.trim().is_empty()) +/// Look a setting up in the environment. +/// +/// `WORKSPACE_*` is the name to use. `DIARY_*` is still read behind it, because +/// this application was called narl-diary until it grew spaces and the running +/// deployment's `.env` still says so; a rename that logs the owner out of their +/// own server would be a poor trade for a tidier prefix. +pub fn var(key: &str) -> Option<String> { + std::env::var(format!("WORKSPACE_{key}")) + .or_else(|_| std::env::var(format!("DIARY_{key}"))) + .ok() + .filter(|v| !v.trim().is_empty()) } impl Config { pub fn from_env() -> Result<Self> { - let username = var("DIARY_USER").context("DIARY_USER must be set (see .env.example)")?; + let username = var("USER").context("WORKSPACE_USER must be set (see .env.example)")?; let password = - var("DIARY_PASSWORD").context("DIARY_PASSWORD must be set (see .env.example)")?; - let secret = var("DIARY_SECRET") - .context("DIARY_SECRET must be set (see .env.example)")? + var("PASSWORD").context("WORKSPACE_PASSWORD must be set (see .env.example)")?; + let secret = var("SECRET") + .context("WORKSPACE_SECRET must be set (see .env.example)")? .into_bytes(); if secret.len() < 16 { - anyhow::bail!("DIARY_SECRET must be at least 16 characters"); + anyhow::bail!("WORKSPACE_SECRET must be at least 16 characters"); } if password == "change-me" { tracing::warn!( - "DIARY_PASSWORD is still the example value from .env.example — \ - anyone who can reach this server can read the diary" + "WORKSPACE_PASSWORD is still the example value from .env.example — \ + anyone who can reach this server can read the workspace" ); } - let bind = var("DIARY_BIND") + let bind = var("BIND") .unwrap_or_else(|| "127.0.0.1:4242".to_string()) .parse() - .context("DIARY_BIND must look like 127.0.0.1:4242")?; + .context("WORKSPACE_BIND must look like 127.0.0.1:4242")?; - let data_dir = PathBuf::from(var("DIARY_DATA_DIR").unwrap_or_else(|| "./data".into())); + let data_dir = PathBuf::from(var("DATA_DIR").unwrap_or_else(|| "./data".into())); - let session_days = var("DIARY_SESSION_DAYS") + let session_days = var("SESSION_DAYS") .map(|v| v.parse()) .transpose() - .context("DIARY_SESSION_DAYS must be a number")? + .context("WORKSPACE_SESSION_DAYS must be a number")? .unwrap_or(30); - let max_upload_bytes = var("DIARY_MAX_UPLOAD_MB") + let max_upload_bytes = var("MAX_UPLOAD_MB") .map(|v| v.parse::<usize>()) .transpose() - .context("DIARY_MAX_UPLOAD_MB must be a number")? + .context("WORKSPACE_MAX_UPLOAD_MB must be a number")? .unwrap_or(64) * 1024 * 1024; - let secure_cookie = var("DIARY_SECURE_COOKIE") + let secure_cookie = var("SECURE_COOKIE") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); @@ -92,6 +193,7 @@ impl Config { max_upload_bytes, secure_cookie, backup: BackupConfig::from_env()?, + mail: MailConfig::from_env()?, }) } @@ -106,26 +208,83 @@ impl Config { } } +impl MailConfig { + fn from_env() -> Result<Self> { + let url = var("SMTP_URL"); + + // Both addresses are the owner's: this mails the workspace to its writer. + let to = var("MAIL_TO").unwrap_or_default(); + let from = var("MAIL_FROM").unwrap_or_else(|| to.clone()); + if url.is_some() && (from.is_empty() || to.is_empty()) { + anyhow::bail!("WORKSPACE_SMTP_URL is set, so WORKSPACE_MAIL_TO must be set too"); + } + + let timezone = var("TIMEZONE") + .unwrap_or_else(|| "Europe/Berlin".to_string()) + .parse::<Tz>() + .map_err(|e| anyhow::anyhow!("WORKSPACE_TIMEZONE: {e}"))?; + + let reminder_at = parse_time( + "WORKSPACE_REMINDER_AT", + &var("REMINDER_AT").unwrap_or_else(|| "20:00".into()), + )?; + let card_due_at = parse_time( + "WORKSPACE_CARD_DUE_AT", + &var("CARD_DUE_AT").unwrap_or_else(|| "08:00".into()), + )?; + let digest_at = parse_digest(&var("DIGEST_AT").unwrap_or_else(|| "sun 18:00".into()))?; + + let kinds = var("MAIL_KINDS") + .unwrap_or_else(|| "reminder,card_due,digest,login".into()) + .split(',') + .map(|k| k.trim().to_ascii_lowercase()) + .filter(|k| !k.is_empty() && k != "off" && k != "none") + .collect::<Vec<_>>(); + if let Some(unknown) = kinds + .iter() + .find(|k| !["reminder", "card_due", "digest", "login"].contains(&k.as_str())) + { + anyhow::bail!( + "WORKSPACE_MAIL_KINDS: {unknown:?} is not one of reminder, card_due, digest, login" + ); + } + + Ok(Self { + url, + from, + to, + timezone, + reminder_at, + card_due_at, + digest_at, + kinds, + }) + } +} + impl BackupConfig { fn from_env() -> Result<Self> { - let device_name = var("DIARY_PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string()); + // The default keeps the old name on purpose: it names a device that + // already exists in Proton Drive, and a new name there would mean a + // second device and a fresh upload of everything in it. + let device_name = var("PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string()); - let minutes = var("DIARY_BACKUP_INTERVAL_MIN") + let minutes = var("BACKUP_INTERVAL_MIN") .map(|v| v.parse::<u64>()) .transpose() - .context("DIARY_BACKUP_INTERVAL_MIN must be a number of minutes")? + .context("WORKSPACE_BACKUP_INTERVAL_MIN must be a number of minutes")? .unwrap_or(60); let interval = (minutes > 0).then(|| Duration::from_secs(minutes * 60)); let debounce = Duration::from_secs( - var("DIARY_BACKUP_DEBOUNCE_SEC") + var("BACKUP_DEBOUNCE_SEC") .map(|v| v.parse::<u64>()) .transpose() - .context("DIARY_BACKUP_DEBOUNCE_SEC must be a number of seconds")? + .context("WORKSPACE_BACKUP_DEBOUNCE_SEC must be a number of seconds")? .unwrap_or(300), ); - let prune = var("DIARY_BACKUP_PRUNE") + let prune = var("BACKUP_PRUNE") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); @@ -137,3 +296,44 @@ impl BackupConfig { }) } } + +#[cfg(test)] +mod tests { + use super::{parse_digest, parse_time, TimeOfDay}; + use chrono::Weekday; + + #[test] + fn a_time_of_day_is_read_as_written() { + assert_eq!( + parse_time("T", "20:00").unwrap(), + Some(TimeOfDay { hour: 20, minute: 0 }) + ); + assert_eq!( + parse_time("T", "07:45").unwrap(), + Some(TimeOfDay { hour: 7, minute: 45 }) + ); + assert_eq!(parse_time("T", "off").unwrap(), None); + } + + #[test] + fn a_time_that_does_not_exist_is_refused_rather_than_rounded() { + for raw in ["24:00", "20:60", "8", "eight", "20:00:00"] { + assert!(parse_time("T", raw).is_err(), "{raw:?}"); + } + } + + #[test] + fn a_digest_is_a_weekday_and_a_time() { + assert_eq!( + parse_digest("sun 18:00").unwrap(), + Some((Weekday::Sun, TimeOfDay { hour: 18, minute: 0 })) + ); + assert_eq!( + parse_digest("Monday 09:30").unwrap(), + Some((Weekday::Mon, TimeOfDay { hour: 9, minute: 30 })) + ); + assert_eq!(parse_digest("off").unwrap(), None); + assert!(parse_digest("someday 18:00").is_err()); + assert!(parse_digest("sun").is_err()); + } +} diff --git a/src/mail/mod.rs b/src/mail/mod.rs new file mode 100644 index 0000000..515f7ac --- /dev/null +++ b/src/mail/mod.rs @@ -0,0 +1,234 @@ +//! The outbox and the SMTP transport. +//! +//! Nothing in the application sends a mail directly. A mail is *enqueued* with +//! a `dedupe_key` that names the occurrence it belongs to, and the sweep in +//! [`crate::notify`] hands whatever is unsent to the relay. The split buys the +//! two properties a reminder needs: a restart cannot resend, because the row is +//! already there, and a relay that refuses cannot lose, because the row is +//! still there. +//! +//! Mail is dormant until `WORKSPACE_SMTP_URL` is set. Unconfigured, nothing is even +//! enqueued — an outbox nobody drains would only grow. + +use anyhow::{Context, Result}; +use lettre::{ + message::Mailbox, transport::smtp::authentication::Credentials, AsyncSmtpTransport, + AsyncTransport, Message, Tokio1Executor, +}; +use sqlx::{Row, SqlitePool}; + +use crate::config::{self, MailConfig}; + +/// Mails are plain text and short. Nothing here is worth an HTML part, and a +/// diary reminder that renders as a newsletter would be faintly absurd. +pub struct Mailer { + transport: AsyncSmtpTransport<Tokio1Executor>, + from: Mailbox, + to: Mailbox, +} + +/// How often a refused mail is retried before it is left alone: roughly a day +/// of doubling. A relay that has said no ten times is misconfigured, not busy, +/// and the row keeps its last error for whoever goes looking. +const MAX_ATTEMPTS: i64 = 10; +const FIRST_RETRY_SECS: i64 = 60; +const MAX_RETRY_SECS: i64 = 3600; + +impl Mailer { + /// `Ok(None)` when no SMTP URL is configured — that is a state, not a fault. + pub fn new(config: &MailConfig) -> Result<Option<Self>> { + let Some(url) = config.url.as_deref() else { + return Ok(None); + }; + + // lettre reads the scheme, the port and any credentials out of the URL: + // `smtps://` is implicit TLS, `smtp://host?tls=required` is STARTTLS, + // and a bare `smtp://` is plain, for a local catcher and nothing else. + let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url) + .context("WORKSPACE_SMTP_URL is not a usable SMTP URL")?; + + // A URL with credentials has already set them; this only covers the + // relays that want them and a URL that keeps them out of the string. + let transport = match (config::var("SMTP_USER"), config::var("SMTP_PASSWORD")) { + (Some(user), Some(password)) => { + transport.credentials(Credentials::new(user, password)) + } + _ => transport, + } + .build(); + + Ok(Some(Self { + transport, + from: config + .from + .parse() + .context("WORKSPACE_MAIL_FROM is not an email address")?, + to: config + .to + .parse() + .context("WORKSPACE_MAIL_TO is not an email address")?, + })) + } + + pub async fn send(&self, subject: &str, body: &str) -> Result<()> { + let message = Message::builder() + .from(self.from.clone()) + .to(self.to.clone()) + .subject(subject) + .header(lettre::message::header::ContentType::TEXT_PLAIN) + .body(body.to_string()) + .context("could not build the message")?; + + self.transport + .send(message) + .await + .context("the SMTP relay refused the message")?; + Ok(()) + } +} + +/// Put a mail in the outbox. Returns whether this call is the one that created +/// it: a `dedupe_key` that is already there means the occurrence was handled, +/// whether or not the mail has left yet. +pub async fn enqueue( + db: &SqlitePool, + kind: &str, + dedupe_key: &str, + subject: &str, + body: &str, +) -> Result<bool> { + let result = sqlx::query( + "INSERT OR IGNORE INTO notifications (kind, dedupe_key, subject, body, created_at) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(kind) + .bind(dedupe_key) + .bind(subject) + .bind(body) + .bind(crate::now()) + .execute(db) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Whether a mail that has already failed `attempts` times may be tried again. +/// 1m, 2m, 4m… up to an hour, measured from the last attempt rather than from +/// the count, because the count alone cannot say how long ago that was. +fn retry_due(attempts: i64, last_attempt_at: Option<i64>, now: i64) -> bool { + let Some(last) = last_attempt_at else { + return true; + }; + if attempts >= MAX_ATTEMPTS { + return false; + } + let wait = FIRST_RETRY_SECS + .checked_shl(attempts.max(1) as u32 - 1) + .unwrap_or(MAX_RETRY_SECS) + .min(MAX_RETRY_SECS); + now - last >= wait +} + +/// Send everything the outbox is holding. Failures are recorded on the row and +/// retried later; one bad message does not block the ones behind it. +pub async fn send_pending(db: &SqlitePool, mailer: &Mailer) -> Result<usize> { + let rows = sqlx::query( + "SELECT id, subject, body, attempts, last_attempt_at FROM notifications + WHERE sent_at IS NULL ORDER BY created_at LIMIT 50", + ) + .fetch_all(db) + .await?; + + let now = crate::now(); + let mut sent = 0; + + for row in rows { + let id: i64 = row.get("id"); + let attempts: i64 = row.get("attempts"); + if !retry_due(attempts, row.get("last_attempt_at"), now) { + continue; + } + + let subject: String = row.get("subject"); + let body: String = row.get("body"); + match mailer.send(&subject, &body).await { + Ok(()) => { + sqlx::query( + "UPDATE notifications + SET sent_at = ?, attempts = attempts + 1, last_attempt_at = ?, last_error = NULL + WHERE id = ?", + ) + .bind(crate::now()) + .bind(crate::now()) + .bind(id) + .execute(db) + .await?; + sent += 1; + } + Err(e) => { + let message = format!("{e:#}"); + tracing::warn!(id, error = %message, "could not send a notification"); + sqlx::query( + "UPDATE notifications + SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? + WHERE id = ?", + ) + .bind(crate::now()) + .bind(&message) + .bind(id) + .execute(db) + .await?; + } + } + } + + Ok(sent) +} + +#[cfg(test)] +mod tests { + use super::{retry_due, MAX_ATTEMPTS}; + + #[test] + fn a_mail_that_has_never_been_tried_goes_out_at_once() { + assert!(retry_due(0, None, 1_000)); + } + + #[test] + fn a_refused_mail_waits_longer_every_time() { + assert!(!retry_due(1, Some(1_000), 1_030)); + assert!(retry_due(1, Some(1_000), 1_060)); + // Four failures in, the wait is eight minutes. + assert!(!retry_due(4, Some(1_000), 1_000 + 7 * 60)); + assert!(retry_due(4, Some(1_000), 1_000 + 8 * 60)); + // And it never grows past an hour. + assert!(retry_due(9, Some(1_000), 1_000 + 3_600)); + } + + #[test] + fn a_mail_the_relay_keeps_refusing_is_eventually_left_alone() { + assert!(!retry_due(MAX_ATTEMPTS, Some(1_000), 1_000 + 86_400)); + } + + /// The property the whole outbox rests on: the tick asks about the same + /// occurrence once a minute, all day, and only the first ask queues a mail. + #[tokio::test] + async fn an_occurrence_is_only_queued_once() { + let db = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("an in-memory database"); + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("migrations apply"); + + let queue = || super::enqueue(&db, "reminder", "reminder:2026-09-18", "subject", "body"); + assert!(queue().await.expect("the first ask queues")); + assert!(!queue().await.expect("the second does not")); + + let queued: i64 = sqlx::query_scalar("SELECT count(*) FROM notifications") + .fetch_one(&db) + .await + .expect("counting works"); + assert_eq!(queued, 1); + } +} diff --git a/src/main.rs b/src/main.rs index f4b42eb..0664b0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ mod backup; mod cli; mod config; mod error; +mod mail; +mod notify; mod routes; mod state; mod static_files; @@ -38,10 +40,11 @@ async fn main() -> Result<()> { tracing_subscriber::registry() // rPGP warns on every key it re-serialises differently to how Proton // wrote it — a cosmetic packet-header difference, once per unlock, that - // otherwise buries the backup's own log lines. `DIARY_LOG` overrides + // otherwise buries the backup's own log lines. `WORKSPACE_LOG` overrides // all of this when something needs looking at. .with( - EnvFilter::try_from_env("DIARY_LOG") + EnvFilter::try_from_env("WORKSPACE_LOG") + .or_else(|_| EnvFilter::try_from_env("DIARY_LOG")) .unwrap_or_else(|_| EnvFilter::new("info,pgp=error")), ) .with(tracing_subscriber::fmt::layer()) @@ -72,6 +75,14 @@ async fn main() -> Result<()> { let db = open_db(&config).await?; return cli::proton_status(&db, &config).await; } + Some("mail-test") => { + let db = open_db(&config).await?; + return cli::mail_test(db, Arc::new(config)).await; + } + Some("mail-status") => { + let db = open_db(&config).await?; + return cli::mail_status(&db, &config).await; + } Some("backup-now") => { let db = open_db(&config).await?; let summary = backup::run_once(db, Arc::new(config)).await?; @@ -94,16 +105,20 @@ async fn main() -> Result<()> { let backup = backup::Backup::new(db.clone(), Arc::clone(&config)); backup::spawn(Arc::clone(&backup)); + let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?; + notify::spawn(Arc::clone(¬ify)); + let state = AppState { db, config, login_throttle: Arc::default(), backup, + notify, }; serve(bind, max_upload, state).await } -/// Open the diary database and bring it up to date. Both the server and the +/// Open the workspace database and bring it up to date. Both the server and the /// subcommands need it, and both need the same pragmas. async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> { let db = SqlitePoolOptions::new() @@ -121,7 +136,7 @@ async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> { .foreign_keys(true), ) .await - .context("could not open the diary database")?; + .context("could not open the workspace database")?; sqlx::migrate!("./migrations") .run(&db) @@ -145,7 +160,7 @@ async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) - .await .with_context(|| format!("could not bind {bind}"))?; - tracing::info!("narl-diary listening on http://{bind}"); + tracing::info!("narl-workspace listening on http://{bind}"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) diff --git a/src/notify/mod.rs b/src/notify/mod.rs new file mode 100644 index 0000000..213d947 --- /dev/null +++ b/src/notify/mod.rs @@ -0,0 +1,546 @@ +//! What is worth a mail, and when. +//! +//! One task, one tick a minute, four questions asked on it: was anything +//! written in the diary today, is a card due or overdue, is it time for the +//! weekly digest, and — from the login route rather than the tick — has a +//! browser signed in that has not signed in before. +//! +//! Every question is answered in the configured timezone. "Today" is a local +//! day, and DST means a local day is not always 86400 seconds long, so the +//! boundaries are computed rather than rounded to. +//! +//! Nothing is sent from here. Everything found is enqueued into the outbox in +//! [`crate::mail`] under a key that names the occurrence, so asking twice — +//! which the tick does, once a minute, all day — costs one insert that does +//! nothing. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use chrono::{Datelike, Duration as ChronoDuration, NaiveDate, TimeZone, Utc, Weekday}; +use chrono_tz::Tz; +use sha2::{Digest, Sha256}; +use sqlx::{Row, SqlitePool}; + +use crate::{ + config::{Config, TimeOfDay}, + mail::{self, Mailer}, +}; + +pub struct Notifier { + db: SqlitePool, + config: Arc<Config>, + /// `None` when no SMTP URL is configured; then nothing is enqueued either. + mailer: Option<Mailer>, +} + +impl Notifier { + pub fn new(db: SqlitePool, config: Arc<Config>) -> Result<Arc<Self>> { + let mailer = Mailer::new(&config.mail)?; + Ok(Arc::new(Self { db, config, mailer })) + } + + pub fn enabled(&self) -> bool { + self.mailer.is_some() + } + + /// One pass: find what is due, then drain the outbox. + pub async fn tick(&self) -> Result<()> { + let Some(mailer) = self.mailer.as_ref() else { + return Ok(()); + }; + if let Err(e) = self.sweep(crate::now()).await { + // A failing sweep must not stop the outbox from draining: the mails + // already in it are the ones that matter most. + tracing::error!(error = %format!("{e:#}"), "the notification sweep failed"); + } + mail::send_pending(&self.db, mailer).await?; + Ok(()) + } + + /// Everything the clock decides, at the instant `now`. + pub async fn sweep(&self, now: i64) -> Result<()> { + let tz = self.config.mail.timezone; + let today = local_day(tz, now); + + if self.config.mail.sends("reminder") { + if let Some(at) = self.config.mail.reminder_at { + if time_reached(tz, now, today, at) { + self.sweep_reminder(today).await?; + } + } + } + + if self.config.mail.sends("card_due") { + if let Some(at) = self.config.mail.card_due_at { + if time_reached(tz, now, today, at) { + self.sweep_cards(today).await?; + } + } + } + + if self.config.mail.sends("digest") { + if let Some((day, at)) = self.config.mail.digest_at { + if today.weekday() == day && time_reached(tz, now, today, at) { + self.sweep_digest(today).await?; + } + } + } + + Ok(()) + } + + /// "You have not written today." Asked once the reminder time has passed, + /// and only if the diary space gained no document that local day. + async fn sweep_reminder(&self, today: NaiveDate) -> Result<()> { + let (start, end) = day_bounds(self.config.mail.timezone, today); + let written: i64 = sqlx::query_scalar( + "WITH RECURSIVE tree (id) AS ( + SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary' + UNION ALL + SELECT n.id FROM nodes n JOIN tree t ON n.parent_id = t.id + ) + SELECT count(*) FROM nodes + WHERE kind = 'document' AND id IN (SELECT id FROM tree) + AND created_at >= ? AND created_at < ?", + ) + .bind(start) + .bind(end) + .fetch_one(&self.db) + .await?; + + if written > 0 { + return Ok(()); + } + + let subject = format!("workspace — nothing written on {today}"); + let body = format!( + "No entry in the diary for {today}.\n\n\ + Open the diary, press o, and write the day down while it is still today.\n" + ); + self.enqueue("reminder", &format!("reminder:{today}"), &subject, &body) + .await + } + + /// Cards whose due date has arrived, and cards that have slipped past it. + /// The due date is part of the key, so moving a card's date asks again. + async fn sweep_cards(&self, today: NaiveDate) -> Result<()> { + let tz = self.config.mail.timezone; + let (_, end_of_today) = day_bounds(tz, today); + + let rows = sqlx::query( + "SELECT c.id, c.title, c.due_at, l.name AS list_name, s.name AS space_name + FROM cards c + JOIN board_lists l ON l.id = c.list_id + JOIN nodes s ON s.id = l.space_id + WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ? + ORDER BY c.due_at", + ) + .bind(end_of_today) + .fetch_all(&self.db) + .await?; + + for row in rows { + let id: i64 = row.get("id"); + let title: String = row.get("title"); + let space: String = row.get("space_name"); + let list: String = row.get("list_name"); + let due = local_day(tz, row.get("due_at")); + + let (stage, subject, opening) = if due < today { + let days = (today - due).num_days(); + ( + "overdue", + format!("{space} — overdue: {title}"), + format!("\"{title}\" was due on {due}, {days} day(s) ago."), + ) + } else { + ( + "due", + format!("{space} — due today: {title}"), + format!("\"{title}\" is due today, {due}."), + ) + }; + + let body = format!("{opening}\n\nSpace: {space}\nList: {list}\n"); + self.enqueue( + "card_due", + &format!("card_due:{id}:{due}:{stage}"), + &subject, + &body, + ) + .await?; + } + + Ok(()) + } + + /// The week just gone: what was written, what was finished, what is open. + async fn sweep_digest(&self, today: NaiveDate) -> Result<()> { + let tz = self.config.mail.timezone; + let (_, end) = day_bounds(tz, today); + let (start, _) = day_bounds(tz, today - ChronoDuration::days(6)); + + let written = sqlx::query( + "WITH RECURSIVE tree (space_id, id) AS ( + SELECT id, id FROM nodes WHERE parent_id IS NULL AND kind = 'space' + UNION ALL + SELECT t.space_id, n.id FROM nodes n JOIN tree t ON n.parent_id = t.id + ) + SELECT s.name AS space_name, count(*) AS written + FROM nodes n + JOIN tree t ON t.id = n.id + JOIN nodes s ON s.id = t.space_id + WHERE n.kind = 'document' AND n.created_at >= ? AND n.created_at < ? + GROUP BY s.id ORDER BY s.position", + ) + .bind(start) + .bind(end) + .fetch_all(&self.db) + .await?; + + let finished = sqlx::query( + "SELECT s.name AS space_name, c.title AS title + FROM cards c + JOIN board_lists l ON l.id = c.list_id + JOIN nodes s ON s.id = l.space_id + WHERE c.done_at >= ? AND c.done_at < ? + ORDER BY s.position, c.done_at", + ) + .bind(start) + .bind(end) + .fetch_all(&self.db) + .await?; + + let open = sqlx::query( + "SELECT s.name AS space_name, c.title AS title, c.due_at AS due_at + FROM cards c + JOIN board_lists l ON l.id = c.list_id + JOIN nodes s ON s.id = l.space_id + WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ? + ORDER BY c.due_at", + ) + .bind(end) + .fetch_all(&self.db) + .await?; + + let mut body = format!("The week to {today}.\n\nWritten\n"); + if written.is_empty() { + body.push_str(" nothing\n"); + } + for row in &written { + let space: String = row.get("space_name"); + let count: i64 = row.get("written"); + body.push_str(&format!(" {space}: {count} document(s)\n")); + } + + body.push_str("\nFinished\n"); + if finished.is_empty() { + body.push_str(" nothing\n"); + } + for row in &finished { + let space: String = row.get("space_name"); + let title: String = row.get("title"); + body.push_str(&format!(" {space}: {title}\n")); + } + + body.push_str("\nStill open, due or overdue\n"); + if open.is_empty() { + body.push_str(" nothing\n"); + } + for row in &open { + let space: String = row.get("space_name"); + let title: String = row.get("title"); + let due = local_day(tz, row.get("due_at")); + body.push_str(&format!(" {space}: {title} (due {due})\n")); + } + + let week = iso_week_key(today); + self.enqueue( + "digest", + &format!("digest:{week}"), + &format!("workspace — week {week}"), + &body, + ) + .await + } + + /// A login happened. The browser is remembered by a hash of its User-Agent, + /// and one that has not been seen before is worth a mail. + /// + /// That is all the hash can honestly claim: a browser, not a person. It + /// needs no proxy header to be trusted, which is the reason to prefer it + /// over an address that a reverse proxy could be lying about. + pub async fn note_login(&self, user_agent: &str) { + if !self.config.mail.sends("login") { + return; + } + if let Err(e) = self.record_login(user_agent).await { + tracing::error!(error = %format!("{e:#}"), "could not record a login"); + } + } + + async fn record_login(&self, user_agent: &str) -> Result<()> { + let hash = device_hash(user_agent); + let now = crate::now(); + let inserted = sqlx::query( + "INSERT INTO login_events (device_hash, user_agent, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (device_hash) DO UPDATE SET last_seen_at = excluded.last_seen_at", + ) + .bind(&hash) + .bind(user_agent) + .bind(now) + .bind(now) + .execute(&self.db) + .await? + .rows_affected(); + + // The upsert touches a row either way, so "new" is asked separately. + let first_seen: i64 = sqlx::query_scalar( + "SELECT first_seen_at FROM login_events WHERE device_hash = ?", + ) + .bind(&hash) + .fetch_one(&self.db) + .await?; + if inserted == 0 || first_seen != now { + return Ok(()); + } + + let when = local_stamp(self.config.mail.timezone, now); + self.enqueue( + "login", + &format!("login:{hash}"), + "workspace — signed in from a new browser", + &format!( + "A browser that has not signed in before signed in at {when}.\n\n\ + User-Agent: {user_agent}\n\n\ + If that was not you, change WORKSPACE_PASSWORD and rotate WORKSPACE_SECRET — \ + rotating the secret signs every device out.\n" + ), + ) + .await + } + + /// A run of wrong passwords. Enqueued at most once an hour, because the + /// point is to notice an attempt, not to be mailed by the attacker. + pub async fn note_failed_logins(&self, failures: u32) { + if !self.config.mail.sends("login") { + return; + } + let hour = local_stamp(self.config.mail.timezone, crate::now()); + let hour = &hour[..13.min(hour.len())]; + let outcome = self + .enqueue( + "login", + &format!("login_failed:{hour}"), + "workspace — repeated failed logins", + &format!( + "{failures} failed login attempts. The login is being answered more \ + slowly with every one of them.\n" + ), + ) + .await; + if let Err(e) = outcome { + tracing::error!(error = %format!("{e:#}"), "could not record failed logins"); + } + } + + async fn enqueue(&self, kind: &str, key: &str, subject: &str, body: &str) -> Result<()> { + if mail::enqueue(&self.db, kind, key, subject, body).await? { + tracing::info!(kind, key, "queued a notification"); + } + Ok(()) + } + + /// One mail, sent straight out, for `narl-workspace mail-test`. It skips the + /// outbox on purpose: the question it answers is whether the relay works, + /// and a queued row would answer it later or not at all. + pub async fn send_test(&self) -> Result<()> { + let mailer = self + .mailer + .as_ref() + .context("WORKSPACE_SMTP_URL is not set — mail is off")?; + let when = local_stamp(self.config.mail.timezone, crate::now()); + mailer + .send( + "workspace — test", + &format!("This is narl-workspace checking its SMTP settings at {when}.\n"), + ) + .await + } +} + +/// Run the notifier for as long as the server lives. +pub fn spawn(notifier: Arc<Notifier>) { + if !notifier.enabled() { + tracing::info!("mail is off — set WORKSPACE_SMTP_URL to enable notifications"); + return; + } + + tokio::spawn(async move { + // A minute is fine-grained enough for a configuration written as HH:MM + // and coarse enough to be free. + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + ticker.tick().await; + if let Err(e) = notifier.tick().await { + tracing::error!(error = %format!("{e:#}"), "the notification tick failed"); + } + } + }); +} + +/// SHA-256 of the User-Agent, hex. Not a secret — a stable name for a browser. +fn device_hash(user_agent: &str) -> String { + let digest = Sha256::digest(user_agent.as_bytes()); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +/// The local date an instant falls on. +fn local_day(tz: Tz, at: i64) -> NaiveDate { + Utc.timestamp_opt(at, 0) + .single() + .unwrap_or_else(Utc::now) + .with_timezone(&tz) + .date_naive() +} + +/// The instant a local wall-clock time happens at. +/// +/// DST makes this a question with two awkward answers: the hour that occurs +/// twice, where the earlier one is meant, and the hour that does not occur at +/// all, where the clock skipping past it counts as the time having passed. +fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 { + let Some(naive) = day.and_hms_opt(hour, minute, 0) else { + return i64::MAX; + }; + match tz.from_local_datetime(&naive).earliest() { + Some(at) => at.timestamp(), + // The wall clock never showed this time; the moment it jumped over it + // is the honest answer. + None => tz + .from_local_datetime(&(naive + ChronoDuration::hours(1))) + .earliest() + .map(|at| at.timestamp()) + .unwrap_or(i64::MAX), + } +} + +/// Start (inclusive) and end (exclusive) of a local day, in Unix seconds. +fn day_bounds(tz: Tz, day: NaiveDate) -> (i64, i64) { + ( + local_instant(tz, day, 0, 0), + local_instant(tz, day + ChronoDuration::days(1), 0, 0), + ) +} + +/// Whether `at` has already come round on the local day `day`. +fn time_reached(tz: Tz, now: i64, day: NaiveDate, at: TimeOfDay) -> bool { + now >= local_instant(tz, day, at.hour, at.minute) +} + +/// `2026-W38`. The digest belongs to a week, and the ISO week is the one that +/// does not change its mind about which year the first days of January are in. +fn iso_week_key(day: NaiveDate) -> String { + let week = day.iso_week(); + format!("{}-W{:02}", week.year(), week.week()) +} + +/// `2026-09-18 20:00`, in the configured timezone — for the body of a mail, +/// where a Unix timestamp would be no use to a reader. +pub fn local_stamp(tz: Tz, at: i64) -> String { + Utc.timestamp_opt(at, 0) + .single() + .unwrap_or_else(Utc::now) + .with_timezone(&tz) + .format("%Y-%m-%d %H:%M %Z") + .to_string() +} + +/// The weekday name the digest configuration uses, for the status output. +pub fn weekday_name(day: Weekday) -> &'static str { + match day { + Weekday::Mon => "Monday", + Weekday::Tue => "Tuesday", + Weekday::Wed => "Wednesday", + Weekday::Thu => "Thursday", + Weekday::Fri => "Friday", + Weekday::Sat => "Saturday", + Weekday::Sun => "Sunday", + } +} + +#[cfg(test)] +mod tests { + use super::{day_bounds, device_hash, iso_week_key, local_day, time_reached}; + use crate::config::TimeOfDay; + use chrono::NaiveDate; + use chrono_tz::Europe::Berlin; + + fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).expect("a real date") + } + + const AT: TimeOfDay = TimeOfDay { + hour: 20, + minute: 0, + }; + + #[test] + fn a_local_day_is_not_the_utc_day_it_overlaps() { + // 22:30 UTC on the 17th is already the 18th in Berlin. + let at = day(2026, 9, 17) + .and_hms_opt(22, 30, 0) + .unwrap() + .and_utc() + .timestamp(); + assert_eq!(local_day(Berlin, at), day(2026, 9, 18)); + } + + #[test] + fn the_reminder_waits_for_the_local_evening() { + let today = day(2026, 9, 18); + let (start, end) = day_bounds(Berlin, today); + assert_eq!(end - start, 86_400); + // 19:59 local is 17:59 UTC in summer time. + assert!(!time_reached(Berlin, start + 19 * 3600 + 59 * 60, today, AT)); + assert!(time_reached(Berlin, start + 20 * 3600, today, AT)); + } + + #[test] + fn the_day_the_clocks_go_forward_is_an_hour_short() { + // 29 March 2026: 02:00 never happens in Berlin. + let (start, end) = day_bounds(Berlin, day(2026, 3, 29)); + assert_eq!(end - start, 82_800); + // And the evening still arrives, an hour earlier in UTC than usual. + assert!(time_reached( + Berlin, + start + 19 * 3600, + day(2026, 3, 29), + AT + )); + } + + #[test] + fn the_day_the_clocks_go_back_is_an_hour_long() { + let (start, end) = day_bounds(Berlin, day(2026, 10, 25)); + assert_eq!(end - start, 90_000); + } + + #[test] + fn a_week_key_names_the_iso_week() { + assert_eq!(iso_week_key(day(2026, 9, 18)), "2026-W38"); + // The first days of January belong to the week that holds most of them. + assert_eq!(iso_week_key(day(2027, 1, 1)), "2026-W53"); + } + + #[test] + fn a_device_hash_is_stable_and_says_nothing_by_itself() { + let hash = device_hash("Mozilla/5.0"); + assert_eq!(hash.len(), 64); + assert_eq!(hash, device_hash("Mozilla/5.0")); + assert_ne!(hash, device_hash("Mozilla/5.1")); + } +} diff --git a/src/routes/backup.rs b/src/routes/backup.rs index 81fdab6..9229fe2 100644 --- a/src/routes/backup.rs +++ b/src/routes/backup.rs @@ -8,7 +8,7 @@ pub async fn status(_: Session, State(state): State<AppState>) -> AppResult<Json Ok(Json(state.backup.status())) } -/// Mirror now rather than when the diary next falls quiet. Answers with the +/// Mirror now rather than when the workspace next falls quiet. Answers with the /// status the run produced, so `:backup` can report what it did. pub async fn run(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> { state.backup.run_now().await?; diff --git a/src/routes/boards.rs b/src/routes/boards.rs new file mode 100644 index 0000000..d88980b --- /dev/null +++ b/src/routes/boards.rs @@ -0,0 +1,553 @@ +//! Kanban boards, one per space. +//! +//! The question a board answers here is "what am I working on in *this* part of +//! my life", so there is exactly one board per space and no board table: a +//! space with `has_board` set has a board, and the lists hang off the space +//! node directly. That keeps the deployment one binary and one SQLite file, and +//! the Proton mirror keeps covering everything without a second service. +//! +//! Cards may point at a document. The link is deliberately weak — deleting the +//! document sets `node_id` to NULL rather than deleting the card, because the +//! work is still open once its notes are gone. + +use axum::{ + extract::{Path, State}, + Json, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{Row, SqlitePool}; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + routes::nodes::SPACE, + state::AppState, +}; + +/// What a board starts with. Three columns is the smallest arrangement that +/// still says something: not started, in hand, finished. +const DEFAULT_LISTS: [&str; 3] = ["backlog", "doing", "done"]; + +#[derive(Serialize)] +pub struct Board { + pub space_id: i64, + pub space_name: String, + pub lists: Vec<BoardList>, +} + +#[derive(Serialize)] +pub struct BoardList { + pub id: i64, + pub name: String, + pub position: i64, + pub cards: Vec<Card>, +} + +#[derive(Serialize)] +pub struct Card { + pub id: i64, + pub list_id: i64, + pub title: String, + pub body: String, + pub node_id: Option<i64>, + /// The linked document's name, so a card can say what it points at without + /// the client reading every document on the board. + pub node_name: Option<String>, + pub due_at: Option<i64>, + pub done_at: Option<i64>, + pub position: i64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Deserialize)] +pub struct ListInput { + pub name: String, +} + +#[derive(Deserialize)] +pub struct CardInput { + pub title: String, + #[serde(default)] + pub body: String, + /// The document the card is about, if it has one. + #[serde(default)] + pub node_id: Option<i64>, + #[serde(default)] + pub due_at: Option<i64>, +} + +/// Every field is optional: the client sends what changed. `null` and "absent" +/// are different for the two that can be cleared, so they are doubly wrapped — +/// `"due_at": null` drops the date, leaving it out keeps it. +#[derive(Deserialize)] +pub struct CardPatch { + #[serde(default)] + pub title: Option<String>, + #[serde(default)] + pub body: Option<String>, + #[serde(default, deserialize_with = "double_option")] + pub node_id: Option<Option<i64>>, + #[serde(default, deserialize_with = "double_option")] + pub due_at: Option<Option<i64>>, + pub done: Option<bool>, +} + +fn double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Option::deserialize(deserializer).map(Some) +} + +#[derive(Deserialize)] +pub struct MoveInput { + pub list_id: i64, + /// Where among the cards of that list it lands. Past the end means last. + #[serde(default)] + pub position: Option<i64>, +} + +/// `ids` with `card` taken out and put back at `to`. Pulled out of the move +/// handler because off-by-one in a reorder is silent: the card lands next to +/// where it was asked to go and nobody notices until the board reads wrong. +fn reordered(ids: &[i64], card: i64, to: usize) -> Vec<i64> { + let mut rest: Vec<i64> = ids.iter().copied().filter(|id| *id != card).collect(); + rest.insert(to.min(rest.len()), card); + rest +} + +fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Card { + Card { + id: row.get("id"), + list_id: row.get("list_id"), + title: row.get("title"), + body: row.get("body"), + node_id: row.get("node_id"), + node_name: row.get("node_name"), + due_at: row.get("due_at"), + done_at: row.get("done_at"), + position: row.get("position"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } +} + +/// The space behind an id, refusing anything that is not a space with a board. +async fn board_space(db: &SqlitePool, space_id: i64) -> AppResult<String> { + let row = sqlx::query("SELECT kind, name, has_board FROM nodes WHERE id = ?1") + .bind(space_id) + .fetch_optional(db) + .await? + .ok_or(AppError::NotFound)?; + + if row.get::<String, _>("kind") != SPACE { + return Err(AppError::BadRequest("only a space has a board".into())); + } + if row.get::<i64, _>("has_board") == 0 { + // The diary is the case this exists for: a journal has no backlog. + return Err(AppError::BadRequest( + "this space has no board — :set board turns one on".into(), + )); + } + Ok(row.get("name")) +} + +/// The space a list belongs to, and the same board check along the way. +async fn list_space(db: &SqlitePool, list_id: i64) -> AppResult<i64> { + let space_id: i64 = sqlx::query("SELECT space_id FROM board_lists WHERE id = ?1") + .bind(list_id) + .fetch_optional(db) + .await? + .ok_or(AppError::NotFound)? + .get("space_id"); + board_space(db, space_id).await?; + Ok(space_id) +} + +/// Seed a board that has no lists yet. A space created before this feature +/// existed — or one whose board was switched on later — gets its columns the +/// first time the board is looked at, which is the only moment it matters. +async fn ensure_lists(db: &SqlitePool, space_id: i64) -> AppResult<()> { + let existing: i64 = sqlx::query("SELECT COUNT(*) AS n FROM board_lists WHERE space_id = ?1") + .bind(space_id) + .fetch_one(db) + .await? + .get("n"); + if existing > 0 { + return Ok(()); + } + + let now = crate::now(); + for (position, name) in DEFAULT_LISTS.iter().enumerate() { + sqlx::query( + "INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)", + ) + .bind(space_id) + .bind(name) + .bind(position as i64) + .bind(now) + .execute(db) + .await?; + } + Ok(()) +} + +pub async fn board( + _: Session, + State(state): State<AppState>, + Path(space_id): Path<i64>, +) -> AppResult<Json<Board>> { + let space_name = board_space(&state.db, space_id).await?; + ensure_lists(&state.db, space_id).await?; + load(&state, space_id, space_name).await.map(Json) +} + +async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult<Board> { + let list_rows = sqlx::query( + "SELECT id, name, position FROM board_lists WHERE space_id = ?1 ORDER BY position, id", + ) + .bind(space_id) + .fetch_all(&state.db) + .await?; + + // One query for every card on the board rather than one per list: a board + // is small, and the round trips are what would be felt. + let card_rows = sqlx::query( + "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, + c.position, c.created_at, c.updated_at, + (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name + FROM cards c + JOIN board_lists l ON l.id = c.list_id + WHERE l.space_id = ?1 + ORDER BY c.position, c.id", + ) + .bind(space_id) + .fetch_all(&state.db) + .await?; + + let lists = list_rows + .iter() + .map(|row| { + let id: i64 = row.get("id"); + BoardList { + id, + name: row.get("name"), + position: row.get("position"), + cards: card_rows + .iter() + .filter(|card| card.get::<i64, _>("list_id") == id) + .map(row_to_card) + .collect(), + } + }) + .collect(); + + Ok(Board { + space_id, + space_name, + lists, + }) +} + +pub async fn create_list( + _: Session, + State(state): State<AppState>, + Path(space_id): Path<i64>, + Json(input): Json<ListInput>, +) -> AppResult<Json<Board>> { + let space_name = board_space(&state.db, space_id).await?; + let name = input.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("a list needs a name".into())); + } + + let position: i64 = sqlx::query( + "SELECT COALESCE(MAX(position), -1) + 1 AS next FROM board_lists WHERE space_id = ?1", + ) + .bind(space_id) + .fetch_one(&state.db) + .await? + .get("next"); + + sqlx::query( + "INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)", + ) + .bind(space_id) + .bind(name) + .bind(position) + .bind(crate::now()) + .execute(&state.db) + .await?; + + state.backup.signal(); + load(&state, space_id, space_name).await.map(Json) +} + +pub async fn rename_list( + _: Session, + State(state): State<AppState>, + Path(list_id): Path<i64>, + Json(input): Json<ListInput>, +) -> AppResult<Json<Board>> { + let space_id = list_space(&state.db, list_id).await?; + let name = input.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("a list needs a name".into())); + } + + sqlx::query("UPDATE board_lists SET name = ?1 WHERE id = ?2") + .bind(name) + .bind(list_id) + .execute(&state.db) + .await?; + + state.backup.signal(); + let space_name = board_space(&state.db, space_id).await?; + load(&state, space_id, space_name).await.map(Json) +} + +/// Delete a list and the cards on it. A board must keep one list, or there is +/// nowhere left to put a card. +pub async fn remove_list( + _: Session, + State(state): State<AppState>, + Path(list_id): Path<i64>, +) -> AppResult<Json<Board>> { + let space_id = list_space(&state.db, list_id).await?; + + let remaining: i64 = sqlx::query("SELECT COUNT(*) AS n FROM board_lists WHERE space_id = ?1") + .bind(space_id) + .fetch_one(&state.db) + .await? + .get("n"); + if remaining <= 1 { + return Err(AppError::BadRequest( + "a board needs at least one list".into(), + )); + } + + sqlx::query("DELETE FROM board_lists WHERE id = ?1") + .bind(list_id) + .execute(&state.db) + .await?; + + state.backup.signal(); + let space_name = board_space(&state.db, space_id).await?; + load(&state, space_id, space_name).await.map(Json) +} + +pub async fn create_card( + _: Session, + State(state): State<AppState>, + Path(list_id): Path<i64>, + Json(input): Json<CardInput>, +) -> AppResult<Json<Card>> { + list_space(&state.db, list_id).await?; + let title = input.title.trim(); + if title.is_empty() { + return Err(AppError::BadRequest("a card needs a title".into())); + } + + let position: i64 = sqlx::query( + "SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1", + ) + .bind(list_id) + .fetch_one(&state.db) + .await? + .get("next"); + + let now = crate::now(); + let id: i64 = sqlx::query( + "INSERT INTO cards (list_id, node_id, title, body, due_at, position, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) + RETURNING id", + ) + .bind(list_id) + .bind(input.node_id) + .bind(title) + .bind(&input.body) + .bind(input.due_at) + .bind(position) + .bind(now) + .fetch_one(&state.db) + .await? + .get("id"); + + state.backup.signal(); + card(&state.db, id).await.map(Json) +} + +pub async fn update_card( + _: Session, + State(state): State<AppState>, + Path(card_id): Path<i64>, + Json(patch): Json<CardPatch>, +) -> AppResult<Json<Card>> { + let current = card(&state.db, card_id).await?; + let now = crate::now(); + + let title = match patch.title { + Some(title) if title.trim().is_empty() => { + return Err(AppError::BadRequest("a card needs a title".into())) + } + Some(title) => title.trim().to_string(), + None => current.title, + }; + // `done` is a flag on the way in and a timestamp in the table: when it was + // finished is worth more than that it was, and the weekly digest needs it. + let done_at = match patch.done { + Some(true) => current.done_at.or(Some(now)), + Some(false) => None, + None => current.done_at, + }; + + sqlx::query( + "UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, done_at = ?5, + updated_at = ?6 + WHERE id = ?7", + ) + .bind(&title) + .bind(patch.body.unwrap_or(current.body)) + .bind(patch.node_id.unwrap_or(current.node_id)) + .bind(patch.due_at.unwrap_or(current.due_at)) + .bind(done_at) + .bind(now) + .bind(card_id) + .execute(&state.db) + .await?; + + state.backup.signal(); + card(&state.db, card_id).await.map(Json) +} + +pub async fn remove_card( + _: Session, + State(state): State<AppState>, + Path(card_id): Path<i64>, +) -> AppResult<Json<serde_json::Value>> { + let affected = sqlx::query("DELETE FROM cards WHERE id = ?1") + .bind(card_id) + .execute(&state.db) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound); + } + + state.backup.signal(); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +/// Move a card to a list and a place in it. Positions are rewritten densely +/// afterwards, so they stay the small numbers the board reads them as instead +/// of drifting into gaps nobody can reason about. +pub async fn move_card( + _: Session, + State(state): State<AppState>, + Path(card_id): Path<i64>, + Json(input): Json<MoveInput>, +) -> AppResult<Json<Card>> { + let current = card(&state.db, card_id).await?; + let from = list_space(&state.db, current.list_id).await?; + let to = list_space(&state.db, input.list_id).await?; + if from != to { + // Boards are per space on purpose; a card that could cross would make + // "what am I working on in work" a lie. + return Err(AppError::BadRequest( + "a card cannot move to another space's board".into(), + )); + } + + let mut tx = state.db.begin().await?; + + sqlx::query("UPDATE cards SET list_id = ?1, updated_at = ?2 WHERE id = ?3") + .bind(input.list_id) + .bind(crate::now()) + .bind(card_id) + .execute(&mut *tx) + .await?; + + let ids: Vec<i64> = sqlx::query("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id") + .bind(input.list_id) + .fetch_all(&mut *tx) + .await? + .iter() + .map(|row| row.get("id")) + .collect(); + + let target = input.position.unwrap_or(i64::MAX).max(0) as usize; + for (position, id) in reordered(&ids, card_id, target).iter().enumerate() { + sqlx::query("UPDATE cards SET position = ?1 WHERE id = ?2") + .bind(position as i64) + .bind(id) + .execute(&mut *tx) + .await?; + } + + // The list it came from keeps a hole where the card was. Harmless for + // ordering, but it would grow with every move. + let left: Vec<i64> = sqlx::query("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id") + .bind(current.list_id) + .fetch_all(&mut *tx) + .await? + .iter() + .map(|row| row.get("id")) + .collect(); + for (position, id) in left.iter().enumerate() { + sqlx::query("UPDATE cards SET position = ?1 WHERE id = ?2") + .bind(position as i64) + .bind(id) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + state.backup.signal(); + card(&state.db, card_id).await.map(Json) +} + +async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> { + let row = sqlx::query( + "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, + c.position, c.created_at, c.updated_at, + (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name + FROM cards c WHERE c.id = ?1", + ) + .bind(id) + .fetch_optional(db) + .await? + .ok_or(AppError::NotFound)?; + + Ok(row_to_card(&row)) +} + +#[cfg(test)] +mod tests { + use super::reordered; + + #[test] + fn a_card_lands_where_it_was_asked_to() { + let ids = [1, 2, 3, 4]; + assert_eq!(reordered(&ids, 4, 0), vec![4, 1, 2, 3]); + assert_eq!(reordered(&ids, 1, 2), vec![2, 3, 1, 4]); + // Past the end is the end, not an error: dragging below the last card + // is how a card is sent to the bottom. + assert_eq!(reordered(&ids, 1, 99), vec![2, 3, 4, 1]); + } + + #[test] + fn a_card_from_another_list_is_inserted_rather_than_moved() { + // The card is not in `ids` yet — the usual case when it comes from the + // list next door. + assert_eq!(reordered(&[1, 2], 9, 1), vec![1, 9, 2]); + assert_eq!(reordered(&[], 9, 0), vec![9]); + } + + #[test] + fn a_card_kept_in_place_does_not_drift() { + let ids = [1, 2, 3]; + assert_eq!(reordered(&ids, 2, 1), vec![1, 2, 3]); + } +} diff --git a/src/routes/entries.rs b/src/routes/entries.rs deleted file mode 100644 index 125edd3..0000000 --- a/src/routes/entries.rs +++ /dev/null @@ -1,307 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - Json, -}; -use serde::{Deserialize, Serialize}; -use sqlx::Row; - -use crate::{ - auth::Session, - error::{AppError, AppResult}, - routes::media, - state::AppState, -}; - -#[derive(Serialize)] -pub struct EntrySummary { - pub id: i64, - pub title: String, - pub excerpt: String, - pub created_at: i64, - pub updated_at: i64, - pub shared: bool, - pub share_token: Option<String>, -} - -#[derive(Serialize)] -pub struct Entry { - pub id: i64, - pub title: String, - pub body: String, - /// The same preview the list shows, so a client that has just saved can - /// patch its list row without re-reading the whole list. - pub excerpt: String, - pub created_at: i64, - pub updated_at: i64, - pub shared: bool, - pub share_token: Option<String>, -} - -#[derive(Deserialize)] -pub struct ListQuery { - #[serde(default)] - pub q: Option<String>, - #[serde(default)] - pub limit: Option<i64>, - #[serde(default)] - pub offset: Option<i64>, -} - -#[derive(Deserialize)] -pub struct EntryInput { - #[serde(default)] - pub title: String, - #[serde(default)] - pub body: String, - /// Optional override so an entry can be backdated to the day it is about. - #[serde(default)] - pub created_at: Option<i64>, -} - -/// How much of a body the list query reads. Comfortably more than an excerpt -/// needs, and a rounding error next to entries that run to thousands of words. -const HEAD_CHARS: usize = 1024; -const EXCERPT_CHARS: usize = 180; - -/// A short, plain-ish preview of the body for the entry list. `head` is only -/// the leading `HEAD_CHARS` of the entry, so truncation is inferred from its -/// length rather than measured against the whole body. -fn excerpt(head: &str) -> String { - let flat: String = head - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```")) - .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' '])) - .collect::<Vec<_>>() - .join(" "); - let mut out: String = flat.chars().take(EXCERPT_CHARS).collect(); - if flat.chars().count() > EXCERPT_CHARS || head.chars().count() >= HEAD_CHARS { - out.push('…'); - } - out -} - -/// Turn free text into an FTS5 prefix query, discarding anything that could be -/// read as FTS syntax. -fn fts_query(raw: &str) -> Option<String> { - let terms: Vec<String> = raw - .split_whitespace() - .map(|term| term.replace(|c: char| !c.is_alphanumeric(), " ")) - .flat_map(|term| { - term.split_whitespace() - .map(str::to_string) - .collect::<Vec<_>>() - }) - .map(|term| format!("\"{term}\"*")) - .collect(); - (!terms.is_empty()).then(|| terms.join(" AND ")) -} - -pub async fn list( - _: Session, - State(state): State<AppState>, - Query(params): Query<ListQuery>, -) -> AppResult<Json<Vec<EntrySummary>>> { - let limit = params.limit.unwrap_or(200).clamp(1, 1000); - let offset = params.offset.unwrap_or(0).max(0); - - let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); - - let rows = match search.and_then(fts_query) { - Some(query) => { - sqlx::query(&format!( - "SELECT e.id, e.title, substr(e.body, 1, {HEAD_CHARS}) AS body, - e.created_at, e.updated_at, e.share_token - FROM entries_fts - JOIN entries e ON e.id = entries_fts.rowid - WHERE entries_fts MATCH ?1 - ORDER BY rank - LIMIT ?2 OFFSET ?3" - )) - .bind(query) - .bind(limit) - .bind(offset) - .fetch_all(&state.db) - .await? - } - None => { - sqlx::query(&format!( - "SELECT id, title, substr(body, 1, {HEAD_CHARS}) AS body, - created_at, updated_at, share_token - FROM entries - ORDER BY created_at DESC, id DESC - LIMIT ?1 OFFSET ?2" - )) - .bind(limit) - .bind(offset) - .fetch_all(&state.db) - .await? - } - }; - - let entries = rows - .into_iter() - .map(|row| { - let head: String = row.get("body"); - let share_token: Option<String> = row.get("share_token"); - EntrySummary { - id: row.get("id"), - title: row.get("title"), - excerpt: excerpt(&head), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - shared: share_token.is_some(), - share_token, - } - }) - .collect(); - - Ok(Json(entries)) -} - -pub async fn get( - _: Session, - State(state): State<AppState>, - Path(id): Path<i64>, -) -> AppResult<Json<Entry>> { - load(&state, id).await.map(Json) -} - -pub async fn create( - _: Session, - State(state): State<AppState>, - Json(input): Json<EntryInput>, -) -> AppResult<Json<Entry>> { - let now = crate::now(); - let created_at = input.created_at.unwrap_or(now); - let id: i64 = sqlx::query( - "INSERT INTO entries (title, body, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4) - RETURNING id", - ) - .bind(&input.title) - .bind(&input.body) - .bind(created_at) - .bind(now) - .fetch_one(&state.db) - .await? - .get("id"); - - media::link_to_entry(&state.db, id, &input.body).await?; - state.backup.signal(); - load(&state, id).await.map(Json) -} - -pub async fn update( - _: Session, - State(state): State<AppState>, - Path(id): Path<i64>, - Json(input): Json<EntryInput>, -) -> AppResult<Json<Entry>> { - let affected = sqlx::query( - "UPDATE entries - SET title = ?1, body = ?2, updated_at = ?3, created_at = COALESCE(?4, created_at) - WHERE id = ?5", - ) - .bind(&input.title) - .bind(&input.body) - .bind(crate::now()) - .bind(input.created_at) - .bind(id) - .execute(&state.db) - .await? - .rows_affected(); - - if affected == 0 { - return Err(AppError::NotFound); - } - - media::link_to_entry(&state.db, id, &input.body).await?; - state.backup.signal(); - load(&state, id).await.map(Json) -} - -pub async fn remove( - _: Session, - State(state): State<AppState>, - Path(id): Path<i64>, -) -> AppResult<Json<serde_json::Value>> { - // Collected before the delete, which takes the entry_media rows with it. - // Files another entry also embeds are left alone. - let orphans = media::exclusive_media(&state.db, id).await?; - - let affected = sqlx::query("DELETE FROM entries WHERE id = ?1") - .bind(id) - .execute(&state.db) - .await? - .rows_affected(); - - if affected == 0 { - return Err(AppError::NotFound); - } - - for media_id in orphans { - media::delete_media(&state, &media_id).await?; - } - - state.backup.signal(); - Ok(Json(serde_json::json!({ "ok": true }))) -} - -async fn load(state: &AppState, id: i64) -> AppResult<Entry> { - let row = sqlx::query( - "SELECT id, title, body, created_at, updated_at, share_token FROM entries WHERE id = ?1", - ) - .bind(id) - .fetch_optional(&state.db) - .await? - .ok_or(AppError::NotFound)?; - - let share_token: Option<String> = row.get("share_token"); - let body: String = row.get("body"); - Ok(Entry { - id: row.get("id"), - title: row.get("title"), - excerpt: excerpt(&body.chars().take(HEAD_CHARS).collect::<String>()), - body, - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - shared: share_token.is_some(), - share_token, - }) -} - -#[cfg(test)] -mod tests { - use super::{excerpt, fts_query, EXCERPT_CHARS, HEAD_CHARS}; - - #[test] - fn strips_markdown_furniture() { - let body = "# Title\n\n![pic](/api/media/x)\n\n> a quote\n- a point"; - assert_eq!(excerpt(body), "Title a quote a point"); - } - - #[test] - fn marks_a_body_that_was_cut_short() { - assert!(!excerpt("short").ends_with('…')); - assert!(excerpt(&"word ".repeat(60)).ends_with('…')); - // A head at the read limit is truncated even when it flattens small. - assert!(excerpt(&"![x](/api/media/y)\n".repeat(HEAD_CHARS / 18)).ends_with('…')); - } - - #[test] - fn excerpt_never_exceeds_its_budget() { - let out = excerpt(&"a".repeat(HEAD_CHARS)); - assert_eq!(out.chars().count(), EXCERPT_CHARS + 1); - } - - #[test] - fn fts_query_discards_operators() { - // Anything that could be read as FTS syntax must not survive as syntax. - assert_eq!(fts_query("hello"), Some("\"hello\"*".into())); - assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into())); - assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into())); - assert_eq!(fts_query(" "), None); - assert_eq!(fts_query("***"), None); - } -} diff --git a/src/routes/export.rs b/src/routes/export.rs index 6b94dc8..78adfa4 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -1,4 +1,7 @@ -use std::io::{Seek, SeekFrom, Write}; +use std::{ + collections::HashMap, + io::{Seek, SeekFrom, Write}, +}; use axum::{ body::Body, @@ -12,31 +15,50 @@ use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; use crate::{auth::Session, error::AppResult, state::AppState}; -/// One entry, flattened out of the database ahead of the blocking zip work. -struct ExportEntry { +/// One node, flattened out of the database ahead of the blocking zip work. +struct ExportNode { id: i64, - title: String, + parent_id: Option<i64>, + kind: String, + name: String, + slug: String, body: String, - created_at: i64, } -/// `2026-09-05-first-light-12.md`. The id is always on the end, so two entries -/// written on the same day under the same title cannot collide. -fn entry_filename(entry: &ExportEntry) -> String { - let slug: String = entry - .title - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect(); - let slug = slug.trim_matches('-').split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-"); - let slug: String = slug.chars().take(60).collect(); - let day = day_string(entry.created_at); - if slug.is_empty() { - format!("{day}-entry-{}.md", entry.id) - } else { - format!("{day}-{slug}-{}.md", entry.id) +/// Where a document lands in the archive: the slugs of its ancestors as +/// directories, its own slug as the file. Slugs are already unique among +/// siblings, so the path is unique without adding the id to it. +fn document_path(nodes: &HashMap<i64, ExportNode>, node: &ExportNode) -> String { + let mut segments = vec![format!("{}.md", node.slug)]; + let mut parent = node.parent_id; + // Bounded by the number of nodes, so a parent chain that somehow loops + // cannot spin here. + for _ in 0..nodes.len() { + let Some(id) = parent else { break }; + let Some(ancestor) = nodes.get(&id) else { break }; + segments.push(ancestor.slug.clone()); + parent = ancestor.parent_id; } + segments.reverse(); + segments.join("/") +} + +/// A link from one archived document to another, relative to the first. The +/// application addresses documents as `/n/<space>/<slug>/...`, which means +/// nothing outside the application, so the archive turns those links back into +/// file paths — the shape they had before they were ever imported. +fn relative_link(from: &str, to: &str) -> String { + let from: Vec<&str> = from.split('/').collect(); + let to: Vec<&str> = to.split('/').collect(); + // The last segment of `from` is the file itself, not a directory. + let shared = from[..from.len() - 1] + .iter() + .zip(to[..to.len() - 1].iter()) + .take_while(|(a, b)| a == b) + .count(); + + let up = "../".repeat(from.len() - 1 - shared); + format!("{up}{}", to[shared..].join("/")) } /// `YYYY-MM-DD` in UTC, from a Unix timestamp, without pulling in a date crate. @@ -73,30 +95,36 @@ fn media_filename(id: &str, original: &str) -> String { } const README: &str = "\ -This is a complete copy of a ~/diary. +This is a complete copy of a ~/workspace. - entries/ one markdown file per entry, named by the day it is about - media/ every file the entries embed + <space>/ one directory per space, holding the same tree of folders and + documents that the workspace shows; one markdown file per document + media/ every file the documents embed -Embedded media is linked relatively, so the entries render correctly in any -markdown reader as long as the two folders stay next to each other. Nothing -here needs the diary application to read it. +Embedded media, and links from one document to another, are relative — so the +documents render and cross-link correctly in any markdown reader as long as the +tree stays intact. Nothing here needs the workspace application to read it. "; -/// Everything, as one zip. A diary you cannot get out of is not a diary you +/// Everything, as one zip. A workspace you cannot get out of is not one you /// can trust, so this is deliberately a plain archive of plain files. pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> { - let entries: Vec<ExportEntry> = sqlx::query( - "SELECT id, title, body, created_at FROM entries ORDER BY created_at, id", + let nodes: HashMap<i64, ExportNode> = sqlx::query( + "SELECT id, parent_id, kind, name, slug, body FROM nodes ORDER BY created_at, id", ) .fetch_all(&state.db) .await? .into_iter() - .map(|row| ExportEntry { - id: row.get("id"), - title: row.get("title"), - body: row.get("body"), - created_at: row.get("created_at"), + .map(|row| { + let node = ExportNode { + id: row.get("id"), + parent_id: row.get("parent_id"), + kind: row.get("kind"), + name: row.get("name"), + slug: row.get("slug"), + body: row.get("body"), + }; + (node.id, node) }) .collect(); @@ -127,18 +155,40 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp .map(|(id, filename)| (id.clone(), media_filename(id, filename))) .collect(); - for entry in &entries { - let mut body = entry.body.clone(); + // Every document by the address the application knows it as, so a link + // between two of them can be found and pointed at the file instead. + let addresses: HashMap<String, String> = nodes + .values() + .filter(|n| n.kind == "document") + .map(|n| { + let path = document_path(&nodes, n); + (format!("/n/{}", path.trim_end_matches(".md")), path) + }) + .collect(); + + for node in nodes.values().filter(|n| n.kind == "document") { + let path = document_path(&nodes, node); + // A document three directories deep needs three steps back up to + // reach the media folder next to the spaces. + let up = "../".repeat(path.matches('/').count()); + + let mut body = node.body.clone(); for (id, name) in &names { - body = body.replace(&format!("/api/media/{id}"), &format!("../media/{name}")); + body = body.replace(&format!("/api/media/{id}"), &format!("{up}media/{name}")); } - let document = if entry.title.trim().is_empty() { + for (address, target) in &addresses { + // Matching the `)` or `#` that ends a markdown link is what + // keeps `/n/work/a` from matching inside `/n/work/ab`. + body = body.replace(&format!("]({address})"), &format!("]({})", relative_link(&path, target))); + body = body.replace(&format!("]({address}#"), &format!("]({}#", relative_link(&path, target))); + } + let document = if node.name.trim().is_empty() { body } else { - format!("# {}\n\n{body}", entry.title.trim()) + format!("# {}\n\n{body}", node.name.trim()) }; - zip.start_file(format!("entries/{}", entry_filename(entry)), text)?; + zip.start_file(path, text)?; zip.write_all(document.as_bytes())?; } @@ -158,7 +208,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp .await??; let size = file.metadata()?.len(); - let name = format!("diary-{}.zip", day_string(crate::now())); + let name = format!("workspace-{}.zip", day_string(crate::now())); Ok(( [ @@ -177,7 +227,9 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp #[cfg(test)] mod tests { - use super::{day_string, entry_filename, media_filename, ExportEntry}; + use std::collections::HashMap; + + use super::{day_string, document_path, media_filename, relative_link, ExportNode}; #[test] fn renders_days_without_a_date_crate() { @@ -187,17 +239,45 @@ mod tests { assert_eq!(day_string(-1), "1969-12-31"); } - fn entry(title: &str, id: i64) -> ExportEntry { - ExportEntry { id, title: title.into(), body: String::new(), created_at: 0 } + fn tree(rows: &[(i64, Option<i64>, &str, &str)]) -> HashMap<i64, ExportNode> { + rows.iter() + .map(|(id, parent_id, kind, slug)| { + ( + *id, + ExportNode { + id: *id, + parent_id: *parent_id, + kind: (*kind).into(), + name: (*slug).into(), + slug: (*slug).into(), + body: String::new(), + }, + ) + }) + .collect() } #[test] - fn entry_names_are_readable_and_unique() { - assert_eq!(entry_filename(&entry("First Light", 12)), "1970-01-01-first-light-12.md"); - assert_eq!(entry_filename(&entry("", 3)), "1970-01-01-entry-3.md"); - assert_eq!(entry_filename(&entry("!!! ???", 4)), "1970-01-01-entry-4.md"); - // Same day, same title, different entries. - assert_ne!(entry_filename(&entry("a", 1)), entry_filename(&entry("a", 2))); + fn a_document_keeps_its_place_in_the_tree() { + let nodes = tree(&[ + (1, None, "space", "work"), + (2, Some(1), "folder", "einarbeiten"), + (3, Some(2), "document", "01-was-ist-eebus"), + (4, Some(1), "document", "notes"), + ]); + assert_eq!(document_path(&nodes, &nodes[&3]), "work/einarbeiten/01-was-ist-eebus.md"); + assert_eq!(document_path(&nodes, &nodes[&4]), "work/notes.md"); + } + + #[test] + fn a_broken_parent_chain_still_terminates() { + // A parent that is not in the map, and a cycle: neither may hang the + // export or walk off the end of the archive. + let orphan = tree(&[(3, Some(99), "document", "loose")]); + assert_eq!(document_path(&orphan, &orphan[&3]), "loose.md"); + + let cycle = tree(&[(1, Some(2), "folder", "a"), (2, Some(1), "document", "b")]); + assert!(document_path(&cycle, &cycle[&2]).ends_with("b.md")); } #[test] @@ -208,4 +288,12 @@ mod tests { assert_eq!(media_filename("abc", "photo.jpg"), "abc-photo.jpg"); assert_eq!(media_filename("abc", "..."), "abc"); } + + #[test] + fn links_between_documents_become_file_paths() { + assert_eq!(relative_link("uni/proj/readme.md", "uni/proj/sub/deep.md"), "sub/deep.md"); + assert_eq!(relative_link("uni/proj/sub/deep.md", "uni/proj/readme.md"), "../readme.md"); + assert_eq!(relative_link("work/a.md", "uni/b.md"), "../uni/b.md"); + assert_eq!(relative_link("work/a.md", "work/b.md"), "b.md"); + } } diff --git a/src/routes/import.rs b/src/routes/import.rs new file mode 100644 index 0000000..6d2def0 --- /dev/null +++ b/src/routes/import.rs @@ -0,0 +1,652 @@ +//! Importing a folder as a tree of documents. +//! +//! A directory of markdown notes is the format writing arrives in — a repo's +//! `docs/`, a folder of lecture notes, an export of this very diary. Storing it +//! as opaque files would make it unsearchable and uneditable, so the importer +//! reproduces the directory as nodes: directories become folders, markdown +//! becomes documents, everything else becomes media. The links between the +//! files are rewritten as they land, because a set of documents that can no +//! longer reach each other is a worse copy of what was uploaded. + +use std::collections::{BTreeSet, HashMap}; + +use axum::{ + extract::{Multipart, Path, State}, + Json, +}; +use regex::Regex; +use serde::Serialize; +use sqlx::Row; +use std::sync::LazyLock; +use uuid::Uuid; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + routes::{ + media, + nodes::{self, next_slug, slugify, DOCUMENT, FOLDER}, + }, + state::AppState, +}; + +/// Markdown inline links and images: `[text](target)`, `![alt](target "title")`, +/// `[text](<target with spaces>)`. Reference-style definitions are left alone — +/// they are rare in the wild and rewriting them wrongly is worse than not. +static LINK: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"(!?)\[([^\]\n]*)\]\(\s*<?([^)\s<>]*)>?([^)\n]*)\)").expect("static regex is valid") +}); + +/// What the client is told happened. Counts rather than a list of ids: the +/// status line has one line, and the tree is right there to look at. +#[derive(Serialize)] +pub struct ImportReport { + pub folders: usize, + pub documents: usize, + pub files: usize, + pub links: usize, + /// Paths that were refused, so a silently missing file is never a surprise. + pub skipped: Vec<String>, +} + +/// One file of the upload, with the path it had inside the folder. +struct Upload { + path: String, + data: Vec<u8>, + mime: String, +} + +/// Where a path inside the import ended up, as a URL a document body can use. +#[derive(Clone)] +enum Landed { + Node(String), + Media(String), +} + +/// A path that is safe to build a tree from: relative, no `.` or `..` segment, +/// no empty segment, no backslash separators smuggled in from Windows. +fn normalize_path(raw: &str) -> Option<String> { + let raw = raw.trim().replace('\\', "/"); + if raw.is_empty() || raw.starts_with('/') || raw.contains('\0') { + return None; + } + // A drive letter is an absolute path wearing a disguise. + if raw.len() > 1 && raw.as_bytes()[1] == b':' { + return None; + } + + let mut parts = Vec::new(); + for part in raw.split('/') { + match part { + "" | "." => continue, + ".." => return None, + _ => parts.push(part), + } + } + (!parts.is_empty()).then(|| parts.join("/")) +} + +/// The directory a path sits in, `""` for the top level. +fn parent_of(path: &str) -> &str { + path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("") +} + +fn file_name(path: &str) -> &str { + path.rsplit_once('/').map(|(_, name)| name).unwrap_or(path) +} + +fn is_markdown(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".md") || lower.ends_with(".markdown") +} + +/// The title of a document and the body left over. A markdown file usually +/// opens with the heading that names it; a node keeps its name in its own +/// column, so carrying the heading in the body too would show it twice and +/// export it twice. Without a heading the file name has to do. +fn split_title(body: &str, path: &str) -> (String, String) { + let mut lines = body.lines(); + let heading = lines + .next() + .and_then(|line| line.trim().strip_prefix("# ")) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(str::to_string); + + match heading { + Some(title) => (title, lines.collect::<Vec<_>>().join("\n").trim_start().to_string()), + None => { + let name = file_name(path); + let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem); + (stem.to_string(), body.to_string()) + } + } +} + +/// `%20` and friends, because a link to a file with a space in its name is +/// written escaped but the file arrives named with the space. +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&value[i + 1..i + 3], 16) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| value.to_string()) +} + +/// Split a link target into the part that names a file and the `#fragment` or +/// `?query` that follows it. `None` for anything that does not point inside the +/// import: a URL, an absolute path, a bare anchor, a mail address. +fn split_target(raw: &str) -> Option<(String, &str)> { + let raw = raw.trim(); + if raw.is_empty() || raw.starts_with('#') || raw.starts_with('/') || raw.contains("://") { + return None; + } + if raw.starts_with("mailto:") || raw.starts_with("tel:") || raw.starts_with("data:") { + return None; + } + + let cut = raw.find(['#', '?']).unwrap_or(raw.len()); + let (path, suffix) = raw.split_at(cut); + (!path.is_empty()).then(|| (percent_decode(path), suffix)) +} + +/// Resolve a relative link against the directory its document sits in. `None` +/// when it climbs out of the import — that file was not uploaded, so there is +/// nothing to point at and the link is better left as it was written. +fn join_relative(dir: &str, target: &str) -> Option<String> { + let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect(); + for part in target.split('/') { + match part { + "" | "." => continue, + ".." => { + parts.pop()?; + } + _ => parts.push(part), + } + } + (!parts.is_empty()).then(|| parts.join("/")) +} + +/// Point every link that names an imported file at where that file now lives. +/// Returns the new body and how many links moved; anything unresolvable is left +/// exactly as written, so a link to the wider internet survives untouched. +fn rewrite_links(body: &str, dir: &str, landed: &HashMap<String, Landed>) -> (String, usize) { + let mut count = 0; + + let out = LINK.replace_all(body, |caps: ®ex::Captures| { + let whole = caps[0].to_string(); + let Some((path, suffix)) = split_target(&caps[3]) else { + return whole; + }; + let Some(key) = join_relative(dir, &path) else { + return whole; + }; + + // A link written without the extension (`see [notes](notes)`) still + // names the markdown file next to it. + let target = landed + .get(&key) + .or_else(|| landed.get(&format!("{key}.md"))) + .or_else(|| landed.get(&format!("{key}.markdown"))); + + match target { + Some(Landed::Node(url)) => { + count += 1; + format!("{}[{}]({url}{suffix})", &caps[1], &caps[2]) + } + // A fragment means nothing to a file download, so it is dropped. + Some(Landed::Media(url)) => { + count += 1; + format!("{}[{}]({url})", &caps[1], &caps[2]) + } + None => whole, + } + }); + + (out.into_owned(), count) +} + +/// Read the multipart body into uploads. A field whose file name ends in `.zip` +/// is expanded, so dragging in an archive and picking a folder arrive the same +/// way — the exporter writes one of those archives. +async fn collect( + state: &AppState, + mut multipart: Multipart, + skipped: &mut Vec<String>, +) -> AppResult<Vec<Upload>> { + let mut uploads: Vec<Upload> = Vec::new(); + let mut total = 0usize; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("malformed upload: {e}")))? + { + let name = field + .file_name() + .map(str::to_string) + .unwrap_or_else(|| "upload".to_string()); + let content_type = field.content_type().map(str::to_string); + let data = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("could not read upload: {e}")))?; + + total += data.len(); + if total > state.config.max_upload_bytes { + return Err(AppError::BadRequest(format!( + "the upload is larger than the {} MB limit", + state.config.max_upload_bytes / 1024 / 1024 + ))); + } + + if name.to_ascii_lowercase().ends_with(".zip") { + expand_zip(&data, &mut uploads, skipped)?; + continue; + } + + let Some(path) = normalize_path(&name) else { + skipped.push(name); + continue; + }; + if data.is_empty() { + skipped.push(path); + continue; + } + + uploads.push(Upload { + mime: mime_of(&path, content_type), + path, + data: data.to_vec(), + }); + } + + Ok(uploads) +} + +fn mime_of(path: &str, declared: Option<String>) -> String { + let raw = declared.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| { + mime_guess::from_path(path) + .first_or_octet_stream() + .to_string() + }); + media::sanitize_mime(&raw) +} + +fn expand_zip(data: &[u8], uploads: &mut Vec<Upload>, skipped: &mut Vec<String>) -> AppResult<()> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data)) + .map_err(|e| AppError::BadRequest(format!("not a readable zip: {e}")))?; + + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|e| AppError::BadRequest(format!("damaged zip entry: {e}")))?; + if file.is_dir() { + continue; + } + + let raw = file.name().to_string(); + // `enclosed_name` is the zip crate's own refusal of absolute paths, + // `..` and symlink-style escapes; `normalize_path` then re-checks what + // the tree builder relies on. + let name = file + .enclosed_name() + .map(|path| path.to_string_lossy().to_string()) + .and_then(|path| normalize_path(&path)); + let Some(path) = name else { + skipped.push(raw); + continue; + }; + + let mut data = Vec::new(); + std::io::copy(&mut file, &mut data)?; + if data.is_empty() { + skipped.push(path); + continue; + } + + uploads.push(Upload { + mime: mime_of(&path, None), + path, + data, + }); + } + + Ok(()) +} + +/// `POST /api/nodes/{id}/import` — a folder, or a zip of one, becomes a tree +/// under the given space or folder. +pub async fn import( + _: Session, + State(state): State<AppState>, + Path(id): Path<i64>, + multipart: Multipart, +) -> AppResult<Json<ImportReport>> { + let target = nodes::load(&state, id).await?; + if target.kind == DOCUMENT { + return Err(AppError::BadRequest( + "a folder can only be imported into a space or a folder".into(), + )); + } + + let mut skipped = Vec::new(); + let uploads = collect(&state, multipart, &mut skipped).await?; + if uploads.is_empty() { + return Err(AppError::BadRequest( + "nothing importable in that upload".into(), + )); + } + + // The files land on disk before the transaction so a failed import writes + // nothing to the database; the orphans are swept up on the way out. + let mut written: Vec<String> = Vec::new(); + let result = build(&state, &target, uploads, skipped, &mut written).await; + if result.is_err() { + for media_id in &written { + let _ = tokio::fs::remove_file(state.config.uploads_dir().join(media_id)).await; + } + } + + let report = result?; + state.backup.signal(); + Ok(Json(report)) +} + +async fn build( + state: &AppState, + target: &nodes::Node, + mut uploads: Vec<Upload>, + skipped: Vec<String>, + written: &mut Vec<String>, +) -> AppResult<ImportReport> { + // The path of the target in slugs, which every rewritten link starts with. + let prefix = target + .path + .iter() + .map(|crumb| crumb.slug.as_str()) + .collect::<Vec<_>>() + .join("/"); + + // Sorted so a parent directory is always created before its children, and + // so two runs of the same folder produce the same slugs. + uploads.sort_by(|a, b| a.path.cmp(&b.path)); + uploads.dedup_by(|a, b| a.path == b.path); + + let mut directories: BTreeSet<String> = BTreeSet::new(); + for upload in &uploads { + let mut dir = parent_of(&upload.path); + while !dir.is_empty() { + directories.insert(dir.to_string()); + dir = parent_of(dir); + } + } + + let now = crate::now(); + let mut tx = state.db.begin().await?; + + // Slugs already taken under each parent, extended as the tree is built — + // the nodes being created are not visible to a query until the commit. + let mut siblings: HashMap<i64, BTreeSet<String>> = HashMap::new(); + let existing = sqlx::query("SELECT slug FROM nodes WHERE parent_id IS ?1") + .bind(target.id) + .fetch_all(&mut *tx) + .await?; + siblings.insert( + target.id, + existing.iter().map(|row| row.get("slug")).collect(), + ); + + let mut dir_ids: HashMap<String, i64> = HashMap::from([(String::new(), target.id)]); + let mut dir_paths: HashMap<String, String> = HashMap::from([(String::new(), prefix)]); + let mut landed: HashMap<String, Landed> = HashMap::new(); + + for dir in &directories { + let parent = dir_ids[parent_of(dir)]; + let name = file_name(dir); + let taken = siblings.entry(parent).or_default(); + let slug = next_slug(taken, &slugify(name))?; + taken.insert(slug.clone()); + + let id: i64 = sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5) + RETURNING id", + ) + .bind(parent) + .bind(FOLDER) + .bind(name) + .bind(&slug) + .bind(now) + .fetch_one(&mut *tx) + .await? + .get("id"); + + let path = format!("{}/{slug}", dir_paths[parent_of(dir)]); + dir_ids.insert(dir.clone(), id); + dir_paths.insert(dir.clone(), path.clone()); + landed.insert(dir.clone(), Landed::Node(format!("/n/{path}"))); + } + + // Documents first, with their bodies as uploaded: the rewriting below needs + // to know where every document landed before it can point anything at one. + let mut documents: Vec<(i64, String, String)> = Vec::new(); + let mut files = 0usize; + + for upload in &uploads { + let dir = parent_of(&upload.path); + let parent = dir_ids[dir]; + + if is_markdown(&upload.path) { + let raw = String::from_utf8_lossy(&upload.data).to_string(); + let (name, body) = split_title(&raw, &upload.path); + // The slug follows the file name rather than the title, so a link + // someone wrote to `01-was-ist-eebus.md` keeps reading that way. + let stem = file_name(&upload.path); + let stem = stem.rsplit_once('.').map_or(stem, |(stem, _)| stem); + + let taken = siblings.entry(parent).or_default(); + let slug = next_slug(taken, &slugify(stem))?; + taken.insert(slug.clone()); + + let id: i64 = sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) + RETURNING id", + ) + .bind(parent) + .bind(DOCUMENT) + .bind(&name) + .bind(&slug) + .bind(&body) + .bind(now) + .fetch_one(&mut *tx) + .await? + .get("id"); + + let path = format!("{}/{slug}", dir_paths[dir]); + landed.insert(upload.path.clone(), Landed::Node(format!("/n/{path}"))); + documents.push((id, upload.path.clone(), body)); + } else { + let media_id = Uuid::new_v4().to_string(); + tokio::fs::write(state.config.uploads_dir().join(&media_id), &upload.data).await?; + written.push(media_id.clone()); + + sqlx::query( + "INSERT INTO media (id, filename, mime, size, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(&media_id) + .bind(file_name(&upload.path)) + .bind(&upload.mime) + .bind(upload.data.len() as i64) + .bind(now) + .execute(&mut *tx) + .await?; + + landed.insert( + upload.path.clone(), + Landed::Media(format!("/api/media/{media_id}")), + ); + files += 1; + } + } + + // Second pass: point the links at where things landed, and attach every + // file a document embeds to it the way a hand-written document does. + let mut links = 0usize; + let mut attached: BTreeSet<String> = BTreeSet::new(); + + for (id, path, body) in &documents { + let (rewritten, count) = rewrite_links(body, parent_of(path), &landed); + links += count; + + if rewritten != *body { + sqlx::query("UPDATE nodes SET body = ?1 WHERE id = ?2") + .bind(&rewritten) + .bind(id) + .execute(&mut *tx) + .await?; + } + + for capture in media::MEDIA_REF.captures_iter(&rewritten) { + let media_id = capture[1].to_lowercase(); + sqlx::query( + "INSERT OR IGNORE INTO node_media (node_id, media_id) + SELECT ?1, id FROM media WHERE id = ?2", + ) + .bind(id) + .bind(&media_id) + .execute(&mut *tx) + .await?; + attached.insert(media_id); + } + } + + // A file nobody links to still belongs to the folder it was uploaded in, + // or deleting that folder would leave it behind with no way back to it. + for upload in &uploads { + if let Some(Landed::Media(url)) = landed.get(&upload.path) { + let media_id = url.trim_start_matches("/api/media/").to_string(); + if attached.contains(&media_id) { + continue; + } + sqlx::query("INSERT OR IGNORE INTO node_media (node_id, media_id) VALUES (?1, ?2)") + .bind(dir_ids[parent_of(&upload.path)]) + .bind(&media_id) + .execute(&mut *tx) + .await?; + } + } + + tx.commit().await?; + + Ok(ImportReport { + folders: directories.len(), + documents: documents.len(), + files, + links, + skipped, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::{ + is_markdown, join_relative, normalize_path, rewrite_links, split_target, split_title, Landed, + }; + + #[test] + fn refuses_paths_that_escape_the_import() { + assert_eq!(normalize_path("docs/readme.md").as_deref(), Some("docs/readme.md")); + assert_eq!(normalize_path("./docs//readme.md").as_deref(), Some("docs/readme.md")); + assert_eq!(normalize_path("docs\\readme.md").as_deref(), Some("docs/readme.md")); + assert_eq!(normalize_path("../secrets"), None); + assert_eq!(normalize_path("docs/../../etc/passwd"), None); + assert_eq!(normalize_path("/etc/passwd"), None); + assert_eq!(normalize_path("C:/windows/system32"), None); + assert_eq!(normalize_path(" "), None); + } + + #[test] + fn a_title_comes_from_the_heading_then_the_file_name() { + assert_eq!( + split_title("# Was ist EEBUS?\n\ntext\n", "a/01.md"), + ("Was ist EEBUS?".into(), "text".into()) + ); + // No heading, or one further down: the body is left exactly as it came. + assert_eq!( + split_title("no heading", "a/01-intro.md"), + ("01-intro".into(), "no heading".into()) + ); + assert_eq!( + split_title("#nope\n\n# Real\n", "x.md"), + ("x".into(), "#nope\n\n# Real\n".into()) + ); + } + + #[test] + fn markdown_is_recognised_by_extension() { + assert!(is_markdown("a/README.MD")); + assert!(is_markdown("notes.markdown")); + assert!(!is_markdown("diagram.png")); + } + + #[test] + fn relative_links_resolve_against_their_document() { + assert_eq!(join_relative("docs", "img/a.png").as_deref(), Some("docs/img/a.png")); + assert_eq!(join_relative("docs/deep", "../a.md").as_deref(), Some("docs/a.md")); + assert_eq!(join_relative("", "a.md").as_deref(), Some("a.md")); + assert_eq!(join_relative("docs", "../../outside.md"), None); + } + + #[test] + fn external_targets_are_not_touched() { + assert!(split_target("https://example.org/x").is_none()); + assert!(split_target("#section").is_none()); + assert!(split_target("/api/media/x").is_none()); + assert!(split_target("mailto:someone@example.org").is_none()); + assert_eq!(split_target("a%20b.md#top"), Some(("a b.md".into(), "#top"))); + } + + #[test] + fn links_point_at_where_the_files_landed() { + let landed = HashMap::from([ + ("einarbeiten/01.md".to_string(), Landed::Node("/n/work/einarbeiten/01".into())), + ("einarbeiten/img/a.png".to_string(), Landed::Media("/api/media/uuid".into())), + ]); + + let body = "see [one](01.md#ziele) and ![pic](img/a.png), \ + plus [the web](https://example.org) and [gone](missing.md)"; + let (out, count) = rewrite_links(body, "einarbeiten", &landed); + + assert_eq!(count, 2); + assert!(out.contains("[one](/n/work/einarbeiten/01#ziele)"), "{out}"); + assert!(out.contains("![pic](/api/media/uuid)"), "{out}"); + assert!(out.contains("[the web](https://example.org)"), "{out}"); + assert!(out.contains("[gone](missing.md)"), "{out}"); + } + + #[test] + fn a_link_from_a_sibling_directory_still_resolves() { + let landed = HashMap::from([( + "a/one.md".to_string(), + Landed::Node("/n/work/a/one".into()), + )]); + let (out, count) = rewrite_links("[x](../a/one.md)", "b", &landed); + assert_eq!(count, 1); + assert_eq!(out, "[x](/n/work/a/one)"); + } +} diff --git a/src/routes/media.rs b/src/routes/media.rs index 9299aac..b1b6197 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -19,9 +19,9 @@ use crate::{ state::AppState, }; -/// Media referenced from an entry body always looks like `/api/media/<uuid>`, -/// which is how an entry claims ownership of the files it embeds. -static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| { +/// Media referenced from a document body always looks like `/api/media/<uuid>`, +/// which is how a document claims ownership of the files it embeds. +pub(crate) static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})") .expect("static regex is valid") }); @@ -30,7 +30,7 @@ static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| { /// browser is only ever told it is a type that cannot execute script. Anything /// else — `text/html`, `image/svg+xml`, an unrecognised type — is stored and /// served as an opaque download instead. -fn sanitize_mime(raw: &str) -> String { +pub(crate) fn sanitize_mime(raw: &str) -> String { let base = raw .split(';') .next() @@ -67,16 +67,16 @@ pub struct Media { pub size: i64, pub created_at: i64, pub url: String, - /// Every entry that embeds this file, which may be none or several. - pub entry_ids: Vec<i64>, + /// Every document that embeds this file, which may be none or several. + pub node_ids: Vec<i64>, } fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media { let id: String = row.get("id"); // `group_concat` gives "3,7" or NULL; queries that do not ask for it at all // (a fresh upload) get an empty list. - let entry_ids = row - .try_get::<Option<String>, _>("entry_ids") + let node_ids = row + .try_get::<Option<String>, _>("node_ids") .ok() .flatten() .map(|joined| joined.split(',').filter_map(|n| n.parse().ok()).collect()) @@ -89,7 +89,7 @@ fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media { mime: row.get("mime"), size: row.get("size"), created_at: row.get("created_at"), - entry_ids, + node_ids, } } @@ -164,8 +164,8 @@ pub async fn upload( pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> { let rows = sqlx::query( "SELECT m.id, m.filename, m.mime, m.size, m.created_at, - (SELECT group_concat(em.entry_id) - FROM entry_media em WHERE em.media_id = m.id) AS entry_ids + (SELECT group_concat(nm.node_id) + FROM node_media nm WHERE nm.media_id = m.id) AS node_ids FROM media m ORDER BY m.created_at DESC LIMIT 500", ) .fetch_all(&state.db) @@ -249,21 +249,21 @@ pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> { Ok(()) } -/// Attach every media file the body embeds to this entry, and detach the ones -/// it no longer mentions so they stop being reachable through its share link. -/// Other entries keep whatever they embed; this only rewrites one entry's row. -pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppResult<()> { +/// Attach every media file the body embeds to this document, and detach the +/// ones it no longer mentions so they stop being reachable through its share +/// link. Other documents keep whatever they embed; this rewrites one node's rows. +pub async fn link_to_node(db: &SqlitePool, node_id: i64, body: &str) -> AppResult<()> { let referenced: BTreeSet<String> = MEDIA_REF .captures_iter(body) .map(|c| c[1].to_lowercase()) .collect(); - // One transaction, so an entry is never momentarily attached to nothing — + // One transaction, so a document is never momentarily attached to nothing — // which would blank its images for anyone reading its share link. let mut tx = db.begin().await?; - sqlx::query("DELETE FROM entry_media WHERE entry_id = ?1") - .bind(entry_id) + sqlx::query("DELETE FROM node_media WHERE node_id = ?1") + .bind(node_id) .execute(&mut *tx) .await?; @@ -271,10 +271,10 @@ pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppRes // Selecting from `media` rather than binding the id directly means a // body that still mentions a since-deleted file saves fine. sqlx::query( - "INSERT OR IGNORE INTO entry_media (entry_id, media_id) + "INSERT OR IGNORE INTO node_media (node_id, media_id) SELECT ?1, id FROM media WHERE id = ?2", ) - .bind(entry_id) + .bind(node_id) .bind(id) .execute(&mut *tx) .await?; @@ -284,19 +284,32 @@ pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppRes Ok(()) } -/// The files this entry embeds that no other entry does — the ones that become -/// unreachable once it is gone. -pub async fn exclusive_media(db: &SqlitePool, entry_id: i64) -> AppResult<Vec<String>> { - let rows = sqlx::query( - "SELECT em.media_id AS id - FROM entry_media em - WHERE em.entry_id = ?1 +/// The files embedded somewhere in `node_ids` and nowhere outside it — the ones +/// that become unreachable once those nodes are gone. A whole subtree is asked +/// about at once, because deleting a folder deletes everything under it and a +/// file shared between two of its documents is still exclusive to the subtree. +pub async fn exclusive_media(db: &SqlitePool, node_ids: &[i64]) -> AppResult<Vec<String>> { + if node_ids.is_empty() { + return Ok(Vec::new()); + } + + // Ids are i64 straight out of the database, so there is nothing to bind: + // the list is built as literals to keep it one statement of any length. + let list = node_ids + .iter() + .map(i64::to_string) + .collect::<Vec<_>>() + .join(","); + + let rows = sqlx::query(&format!( + "SELECT DISTINCT nm.media_id AS id + FROM node_media nm + WHERE nm.node_id IN ({list}) AND NOT EXISTS ( - SELECT 1 FROM entry_media other - WHERE other.media_id = em.media_id AND other.entry_id <> ?1 - )", - ) - .bind(entry_id) + SELECT 1 FROM node_media other + WHERE other.media_id = nm.media_id AND other.node_id NOT IN ({list}) + )" + )) .fetch_all(db) .await?; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a954fc7..81f5afa 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,8 @@ pub mod backup; -pub mod entries; +pub mod boards; +pub mod nodes; pub mod export; +pub mod import; pub mod media; pub mod session; pub mod share; @@ -17,14 +19,24 @@ pub fn api_router() -> Router<AppState> { .route("/me", get(session::me)) .route("/login", post(session::login)) .route("/logout", post(session::logout)) - .route("/entries", get(entries::list).post(entries::create)) .route("/export", get(export::export)) .route("/backup", get(backup::status).post(backup::run)) - .route("/entries/{id}", get(entries::get)) - .route("/entries/{id}", put(entries::update)) - .route("/entries/{id}", delete(entries::remove)) - .route("/entries/{id}/share", post(share::enable)) - .route("/entries/{id}/share", delete(share::disable)) + .route("/spaces", get(nodes::spaces).post(nodes::create_space)) + .route("/spaces/{id}/board", get(boards::board)) + .route("/spaces/{id}/lists", post(boards::create_list)) + .route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list)) + .route("/lists/{id}/cards", post(boards::create_card)) + .route("/cards/{id}", put(boards::update_card).delete(boards::remove_card)) + .route("/cards/{id}/move", post(boards::move_card)) + .route("/nodes", get(nodes::list).post(nodes::create)) + .route("/resolve", get(nodes::resolve)) + .route("/nodes/{id}", get(nodes::get)) + .route("/nodes/{id}", put(nodes::update)) + .route("/nodes/{id}", delete(nodes::remove)) + .route("/nodes/{id}/move", post(nodes::move_node)) + .route("/nodes/{id}/import", post(import::import)) + .route("/nodes/{id}/share", post(share::enable)) + .route("/nodes/{id}/share", delete(share::disable)) .route("/media", post(media::upload).get(media::list)) .route("/media/{id}", get(media::serve).delete(media::remove)) .route("/share/{token}/{key}", get(share::read)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs new file mode 100644 index 0000000..20536f5 --- /dev/null +++ b/src/routes/nodes.rs @@ -0,0 +1,775 @@ +use std::collections::BTreeSet; + +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{Row, SqlitePool}; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + routes::media, + state::AppState, +}; + +/// What a node is. Spaces are the roots (`diary`, `work`, `uni`, ...), folders +/// group things inside them to any depth, documents are the leaves that hold +/// text. One table, one recursion, so a space can nest exactly like a folder. +pub const SPACE: &str = "space"; +pub const FOLDER: &str = "folder"; +pub const DOCUMENT: &str = "document"; + +#[derive(Serialize)] +pub struct NodeSummary { + pub id: i64, + pub parent_id: Option<i64>, + pub kind: String, + pub name: String, + pub slug: String, + pub excerpt: String, + pub position: i64, + pub has_board: bool, + pub child_count: i64, + pub created_at: i64, + pub updated_at: i64, + pub shared: bool, + pub share_token: Option<String>, +} + +#[derive(Serialize)] +pub struct Node { + pub id: i64, + pub parent_id: Option<i64>, + pub kind: String, + pub name: String, + pub slug: String, + pub body: String, + /// The same preview the list shows, so a client that has just saved can + /// patch its list row without re-reading the whole list. + pub excerpt: String, + pub position: i64, + pub has_board: bool, + pub child_count: i64, + pub created_at: i64, + pub updated_at: i64, + pub shared: bool, + pub share_token: Option<String>, + /// This node's ancestors, the space first and the node itself last — the + /// breadcrumb, and everything needed to build its /n/... path. + pub path: Vec<Crumb>, +} + +#[derive(Serialize)] +pub struct Crumb { + pub id: i64, + pub kind: String, + pub name: String, + pub slug: String, +} + +#[derive(Deserialize)] +pub struct ListQuery { + /// Children of this node. Omitted together with `q` means the spaces. + #[serde(default)] + pub parent: Option<i64>, + #[serde(default)] + pub q: Option<String>, + /// Confine a search to one space. + #[serde(default)] + pub space: Option<i64>, + #[serde(default)] + pub limit: Option<i64>, + #[serde(default)] + pub offset: Option<i64>, +} + +#[derive(Deserialize)] +pub struct CreateInput { + pub parent_id: i64, + /// `document` (the default) or `folder`. + #[serde(default)] + pub kind: Option<String>, + #[serde(default)] + pub name: String, + #[serde(default)] + pub body: String, + /// Optional override so a document can be backdated to the day it is about. + #[serde(default)] + pub created_at: Option<i64>, +} + +#[derive(Deserialize)] +pub struct UpdateInput { + #[serde(default)] + pub name: String, + #[serde(default)] + pub body: String, + #[serde(default)] + pub created_at: Option<i64>, + /// Spaces only, and only when it is sent at all. + #[serde(default)] + pub has_board: Option<bool>, +} + +#[derive(Deserialize)] +pub struct SpaceInput { + pub name: String, + /// Defaults to on: a space is usually a project, and the one place a board + /// makes no sense — the diary — already exists. + #[serde(default)] + pub has_board: Option<bool>, +} + +#[derive(Deserialize)] +pub struct MoveInput { + pub parent_id: i64, + #[serde(default)] + pub position: Option<i64>, +} + +/// How much of a body the list query reads. Comfortably more than an excerpt +/// needs, and a rounding error next to documents that run to thousands of words. +const HEAD_CHARS: usize = 1024; +const EXCERPT_CHARS: usize = 180; + +/// The columns every list query selects, so a row always converts the same way. +const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, + n.created_at, n.updated_at, n.share_token, + (SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count"; + +/// A short, plain-ish preview of the body for the list pane. `head` is only the +/// leading `HEAD_CHARS` of the document, so truncation is inferred from its +/// length rather than measured against the whole body. +fn excerpt(head: &str) -> String { + let flat: String = head + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```")) + .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' '])) + .collect::<Vec<_>>() + .join(" "); + let mut out: String = flat.chars().take(EXCERPT_CHARS).collect(); + if flat.chars().count() > EXCERPT_CHARS || head.chars().count() >= HEAD_CHARS { + out.push('…'); + } + out +} + +/// Turn free text into an FTS5 prefix query, discarding anything that could be +/// read as FTS syntax. +fn fts_query(raw: &str) -> Option<String> { + let terms: Vec<String> = raw + .split_whitespace() + .map(|term| term.replace(|c: char| !c.is_alphanumeric(), " ")) + .flat_map(|term| { + term.split_whitespace() + .map(str::to_string) + .collect::<Vec<_>>() + }) + .map(|term| format!("\"{term}\"*")) + .collect(); + (!terms.is_empty()).then(|| terms.join(" AND ")) +} + +/// A name as a path segment: lowercase, ASCII-ish, no surprises in a URL. An +/// empty result becomes `untitled`, because every node needs a slug and a +/// document usually gets its name minutes after it is created. +pub fn slugify(name: &str) -> String { + let mut slug = String::new(); + let mut dash = false; + for ch in name.trim().chars().flat_map(|c| c.to_lowercase()) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + dash = false; + } else if matches!(ch, 'ä' | 'ö' | 'ü' | 'ß') { + // The diary is written in two languages; transliterating rather + // than dropping keeps a German heading readable in its URL. + slug.push_str(match ch { + 'ä' => "ae", + 'ö' => "oe", + 'ü' => "ue", + _ => "ss", + }); + dash = false; + } else if !slug.is_empty() && !dash { + slug.push('-'); + dash = true; + } + } + let slug = slug.trim_end_matches('-').to_string(); + let slug: String = slug.chars().take(80).collect(); + let slug = slug.trim_end_matches('-').to_string(); + if slug.is_empty() { + "untitled".to_string() + } else { + slug + } +} + +/// `base`, or `base-2`, `base-3`… until one is free. Kept separate from the +/// database so the importer, which creates a whole tree of siblings before any +/// of them is queryable, can allocate against the set it is building. +pub fn next_slug(taken: &BTreeSet<String>, base: &str) -> AppResult<String> { + for suffix in 1..1000 { + let candidate = if suffix == 1 { + base.to_string() + } else { + format!("{base}-{suffix}") + }; + if !taken.contains(&candidate) { + return Ok(candidate); + } + } + Err(AppError::BadRequest( + "too many siblings with that name".into(), + )) +} + +/// The slugs already spoken for under `parent_id`. `exclude` is the node being +/// renamed, which must not collide with itself. +pub async fn sibling_slugs( + db: &SqlitePool, + parent_id: Option<i64>, + exclude: Option<i64>, +) -> AppResult<BTreeSet<String>> { + let rows = sqlx::query( + "SELECT slug FROM nodes + WHERE parent_id IS ?1 AND (?2 IS NULL OR id <> ?2)", + ) + .bind(parent_id) + .bind(exclude) + .fetch_all(db) + .await?; + + Ok(rows.iter().map(|row| row.get("slug")).collect()) +} + +/// `base`, or `base-2`, `base-3`… until no sibling holds it. +pub async fn unique_slug( + db: &SqlitePool, + parent_id: Option<i64>, + base: &str, + exclude: Option<i64>, +) -> AppResult<String> { + next_slug(&sibling_slugs(db, parent_id, exclude).await?, base) +} + +fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { + let share_token: Option<String> = row.get("share_token"); + NodeSummary { + id: row.get("id"), + parent_id: row.get("parent_id"), + kind: row.get("kind"), + name: row.get("name"), + slug: row.get("slug"), + excerpt: excerpt(head), + position: row.get("position"), + has_board: row.get::<i64, _>("has_board") != 0, + child_count: row.get("child_count"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + shared: share_token.is_some(), + share_token, + } +} + +/// The spaces, in display order. Their child counts are what the switcher shows. +pub async fn spaces(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<NodeSummary>>> { + let rows = sqlx::query(&format!( + "SELECT {SUMMARY_COLUMNS} FROM nodes n + WHERE n.parent_id IS NULL AND n.kind = 'space' + ORDER BY n.position, n.name" + )) + .fetch_all(&state.db) + .await?; + + Ok(Json( + rows.iter().map(|row| row_to_summary(row, "")).collect(), + )) +} + +pub async fn create_space( + _: Session, + State(state): State<AppState>, + Json(input): Json<SpaceInput>, +) -> AppResult<Json<Node>> { + let name = input.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("a space needs a name".into())); + } + + let slug = unique_slug(&state.db, None, &slugify(name), None).await?; + let now = crate::now(); + let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL") + .fetch_one(&state.db) + .await? + .get("next"); + + let id: i64 = sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, position, has_board, created_at, updated_at) + VALUES (NULL, 'space', ?1, ?2, ?3, ?4, ?5, ?5) + RETURNING id", + ) + .bind(name) + .bind(&slug) + .bind(position) + .bind(i64::from(input.has_board.unwrap_or(true))) + .bind(now) + .fetch_one(&state.db) + .await? + .get("id"); + + state.backup.signal(); + load(&state, id).await.map(Json) +} + +pub async fn list( + _: Session, + State(state): State<AppState>, + Query(params): Query<ListQuery>, +) -> AppResult<Json<Vec<NodeSummary>>> { + let limit = params.limit.unwrap_or(500).clamp(1, 2000); + let offset = params.offset.unwrap_or(0).max(0); + let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); + + let rows = match search.and_then(fts_query) { + // A search reaches through the whole tree, optionally confined to one + // space: the recursive term collects that space and everything under it. + Some(query) => { + sqlx::query(&format!( + "WITH RECURSIVE sub (id) AS ( + SELECT id FROM nodes WHERE id = ?4 + UNION ALL + SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id + ) + SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head + FROM nodes_fts + JOIN nodes n ON n.id = nodes_fts.rowid + WHERE nodes_fts MATCH ?1 + AND n.kind <> 'space' + AND (?4 IS NULL OR n.id IN (SELECT id FROM sub)) + ORDER BY rank + LIMIT ?2 OFFSET ?3" + )) + .bind(query) + .bind(limit) + .bind(offset) + .bind(params.space) + .fetch_all(&state.db) + .await? + } + // Browsing: the children of one node, folders before documents. + None => { + let parent = params.parent; + sqlx::query(&format!( + "SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head + FROM nodes n + WHERE n.parent_id IS ?1 + ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, + n.position, n.created_at DESC, n.id DESC + LIMIT ?2 OFFSET ?3" + )) + .bind(parent) + .bind(limit) + .bind(offset) + .fetch_all(&state.db) + .await? + } + }; + + Ok(Json( + rows.iter() + .map(|row| { + let head: String = row.try_get("head").unwrap_or_default(); + row_to_summary(row, &head) + }) + .collect(), + )) +} + +pub async fn get( + _: Session, + State(state): State<AppState>, + Path(id): Path<i64>, +) -> AppResult<Json<Node>> { + load(&state, id).await.map(Json) +} + +#[derive(Deserialize)] +pub struct ResolveQuery { + /// Slugs from a space downwards, `work/einarbeiten/01-was-ist-eebus`. + pub path: String, +} + +/// Resolve a slug path to the node it names. This is the other half of the +/// `/n/<space>/<slug>/...` addresses the client puts in the URL bar and the +/// importer writes into rewritten links: they stay readable because they are +/// resolved here rather than carrying ids around. +pub async fn resolve( + _: Session, + State(state): State<AppState>, + Query(params): Query<ResolveQuery>, +) -> AppResult<Json<Node>> { + let mut current: Option<i64> = None; + for segment in params.path.split('/').filter(|s| !s.is_empty()) { + let row = sqlx::query("SELECT id FROM nodes WHERE parent_id IS ?1 AND slug = ?2") + .bind(current) + .bind(segment) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + current = Some(row.get("id")); + } + load(&state, current.ok_or(AppError::NotFound)?).await.map(Json) +} + +pub async fn create( + _: Session, + State(state): State<AppState>, + Json(input): Json<CreateInput>, +) -> AppResult<Json<Node>> { + let kind = input.kind.as_deref().unwrap_or(DOCUMENT); + if kind != DOCUMENT && kind != FOLDER { + return Err(AppError::BadRequest(format!( + "a node created here is a document or a folder, not a {kind}" + ))); + } + + let parent_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") + .bind(input.parent_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)? + .get("kind"); + if parent_kind == DOCUMENT { + return Err(AppError::BadRequest( + "a document cannot contain other nodes".into(), + )); + } + + let now = crate::now(); + let created_at = input.created_at.unwrap_or(now); + let slug = unique_slug( + &state.db, + Some(input.parent_id), + &slugify(&input.name), + None, + ) + .await?; + + let id: i64 = sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + RETURNING id", + ) + .bind(input.parent_id) + .bind(kind) + .bind(input.name.trim()) + .bind(&slug) + .bind(&input.body) + .bind(created_at) + .bind(now) + .fetch_one(&state.db) + .await? + .get("id"); + + media::link_to_node(&state.db, id, &input.body).await?; + state.backup.signal(); + load(&state, id).await.map(Json) +} + +pub async fn update( + _: Session, + State(state): State<AppState>, + Path(id): Path<i64>, + Json(input): Json<UpdateInput>, +) -> AppResult<Json<Node>> { + let row = sqlx::query("SELECT parent_id, kind, name, slug FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let parent_id: Option<i64> = row.get("parent_id"); + let kind: String = row.get("kind"); + let previous_name: String = row.get("name"); + let name = input.name.trim(); + + // The slug follows the name, because a path nobody can read is no better + // than an id. It only moves when the name does, so a link to an unrenamed + // document keeps working. + let slug = if name == previous_name { + row.get::<String, _>("slug") + } else { + unique_slug(&state.db, parent_id, &slugify(name), Some(id)).await? + }; + + if input.has_board.is_some() && kind != SPACE { + return Err(AppError::BadRequest("only a space has a board".into())); + } + + let affected = sqlx::query( + "UPDATE nodes + SET name = ?1, body = ?2, slug = ?3, updated_at = ?4, + created_at = COALESCE(?5, created_at), + has_board = COALESCE(?6, has_board) + WHERE id = ?7", + ) + .bind(name) + .bind(&input.body) + .bind(&slug) + .bind(crate::now()) + .bind(input.created_at) + .bind(input.has_board.map(i64::from)) + .bind(id) + .execute(&state.db) + .await? + .rows_affected(); + + if affected == 0 { + return Err(AppError::NotFound); + } + + media::link_to_node(&state.db, id, &input.body).await?; + state.backup.signal(); + load(&state, id).await.map(Json) +} + +pub async fn remove( + _: Session, + State(state): State<AppState>, + Path(id): Path<i64>, +) -> AppResult<Json<serde_json::Value>> { + // Everything under a folder goes with it, so the files to clean up are + // those exclusive to the whole subtree. Collected before the delete, which + // takes the node_media rows with it. + let subtree = descendants(&state.db, id).await?; + if subtree.is_empty() { + return Err(AppError::NotFound); + } + let orphans = media::exclusive_media(&state.db, &subtree).await?; + + let affected = sqlx::query("DELETE FROM nodes WHERE id = ?1") + .bind(id) + .execute(&state.db) + .await? + .rows_affected(); + + if affected == 0 { + return Err(AppError::NotFound); + } + + for media_id in orphans { + media::delete_media(&state, &media_id).await?; + } + + state.backup.signal(); + Ok(Json(serde_json::json!({ "ok": true, "removed": subtree.len() }))) +} + +/// Reparent a node, and optionally place it among its new siblings. +pub async fn move_node( + _: Session, + State(state): State<AppState>, + Path(id): Path<i64>, + Json(input): Json<MoveInput>, +) -> AppResult<Json<Node>> { + if input.parent_id == id { + return Err(AppError::BadRequest("a node cannot contain itself".into())); + } + + let target_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") + .bind(input.parent_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)? + .get("kind"); + if target_kind == DOCUMENT { + return Err(AppError::BadRequest( + "a document cannot contain other nodes".into(), + )); + } + + // Moving a folder into its own subtree would cut that subtree loose from + // every root, where nothing could reach it again. + if descendants(&state.db, id).await?.contains(&input.parent_id) { + return Err(AppError::BadRequest( + "a folder cannot be moved into itself".into(), + )); + } + + let name: String = sqlx::query("SELECT name FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)? + .get("name"); + + // The slug has to be free where it lands, not where it came from. + let slug = unique_slug( + &state.db, + Some(input.parent_id), + &slugify(&name), + Some(id), + ) + .await?; + + sqlx::query( + "UPDATE nodes + SET parent_id = ?1, slug = ?2, position = COALESCE(?3, position), updated_at = ?4 + WHERE id = ?5", + ) + .bind(input.parent_id) + .bind(&slug) + .bind(input.position) + .bind(crate::now()) + .bind(id) + .execute(&state.db) + .await?; + + state.backup.signal(); + load(&state, id).await.map(Json) +} + +/// A node and everything beneath it, the node itself first. +pub async fn descendants(db: &SqlitePool, id: i64) -> AppResult<Vec<i64>> { + let rows = sqlx::query( + "WITH RECURSIVE sub (id) AS ( + SELECT id FROM nodes WHERE id = ?1 + UNION ALL + SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id + ) + SELECT id FROM sub", + ) + .bind(id) + .fetch_all(db) + .await?; + + Ok(rows.iter().map(|row| row.get("id")).collect()) +} + +/// The ancestors of a node, the space first and the node itself last. +pub async fn path_of(db: &SqlitePool, id: i64) -> AppResult<Vec<Crumb>> { + let rows = sqlx::query( + "WITH RECURSIVE up (id, parent_id, kind, name, slug, depth) AS ( + SELECT id, parent_id, kind, name, slug, 0 FROM nodes WHERE id = ?1 + UNION ALL + SELECT n.id, n.parent_id, n.kind, n.name, n.slug, up.depth + 1 + FROM nodes n JOIN up ON n.id = up.parent_id + ) + SELECT id, kind, name, slug FROM up ORDER BY depth DESC", + ) + .bind(id) + .fetch_all(db) + .await?; + + Ok(rows + .iter() + .map(|row| Crumb { + id: row.get("id"), + kind: row.get("kind"), + name: row.get("name"), + slug: row.get("slug"), + }) + .collect()) +} + +pub async fn load(state: &AppState, id: i64) -> AppResult<Node> { + let row = sqlx::query(&format!( + "SELECT {SUMMARY_COLUMNS}, n.body FROM nodes n WHERE n.id = ?1" + )) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let body: String = row.get("body"); + let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::<String>()); + + Ok(Node { + id: summary.id, + parent_id: summary.parent_id, + kind: summary.kind, + name: summary.name, + slug: summary.slug, + body, + excerpt: summary.excerpt, + position: summary.position, + has_board: summary.has_board, + child_count: summary.child_count, + created_at: summary.created_at, + updated_at: summary.updated_at, + shared: summary.shared, + share_token: summary.share_token, + path: path_of(&state.db, id).await?, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::{excerpt, fts_query, next_slug, slugify, EXCERPT_CHARS, HEAD_CHARS}; + + #[test] + fn strips_markdown_furniture() { + let body = "# Title\n\n![pic](/api/media/x)\n\n> a quote\n- a point"; + assert_eq!(excerpt(body), "Title a quote a point"); + } + + #[test] + fn marks_a_body_that_was_cut_short() { + assert!(!excerpt("short").ends_with('…')); + assert!(excerpt(&"word ".repeat(60)).ends_with('…')); + // A head at the read limit is truncated even when it flattens small. + assert!(excerpt(&"![x](/api/media/y)\n".repeat(HEAD_CHARS / 18)).ends_with('…')); + } + + #[test] + fn excerpt_never_exceeds_its_budget() { + let out = excerpt(&"a".repeat(HEAD_CHARS)); + assert_eq!(out.chars().count(), EXCERPT_CHARS + 1); + } + + #[test] + fn fts_query_discards_operators() { + // Anything that could be read as FTS syntax must not survive as syntax. + assert_eq!(fts_query("hello"), Some("\"hello\"*".into())); + assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into())); + assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into())); + assert_eq!(fts_query(" "), None); + assert_eq!(fts_query("***"), None); + } + + #[test] + fn slugs_are_url_safe() { + assert_eq!(slugify("01 — Was ist EEBUS?"), "01-was-ist-eebus"); + assert_eq!(slugify(" Trailing and inner "), "trailing-and-inner"); + assert_eq!(slugify("Übergrößen"), "uebergroessen"); + assert_eq!(slugify("README.md"), "readme-md"); + } + + #[test] + fn a_nameless_node_still_gets_a_slug() { + assert_eq!(slugify(""), "untitled"); + assert_eq!(slugify("***"), "untitled"); + assert_eq!(slugify(" "), "untitled"); + } + + #[test] + fn slugs_stay_short_and_never_end_in_a_dash() { + let slug = slugify(&"word ".repeat(40)); + assert!(slug.len() <= 80, "{slug}"); + assert!(!slug.ends_with('-'), "{slug}"); + } + + #[test] + fn a_taken_slug_gets_a_number() { + let taken: BTreeSet<String> = ["readme", "readme-2"].iter().map(|s| s.to_string()).collect(); + assert_eq!(next_slug(&taken, "readme").unwrap(), "readme-3"); + assert_eq!(next_slug(&taken, "notes").unwrap(), "notes"); + assert_eq!(next_slug(&BTreeSet::new(), "readme").unwrap(), "readme"); + } +} diff --git a/src/routes/session.rs b/src/routes/session.rs index 54b3fbb..b5db126 100644 --- a/src/routes/session.rs +++ b/src/routes/session.rs @@ -1,6 +1,6 @@ use axum::{ extract::State, - http::{header, StatusCode}, + http::{header, HeaderMap, StatusCode}, response::IntoResponse, Json, }; @@ -9,6 +9,7 @@ use serde_json::json; use crate::{ auth::{self, Session}, + throttle::FREE_ATTEMPTS, error::{AppError, AppResult}, state::AppState, }; @@ -21,6 +22,7 @@ pub struct LoginRequest { pub async fn login( State(state): State<AppState>, + headers: HeaderMap, Json(body): Json<LoginRequest>, ) -> AppResult<impl IntoResponse> { state.login_throttle.check().map_err(|wait| { @@ -29,11 +31,21 @@ pub async fn login( })?; if !auth::credentials_match(&state.config, &body.username, &body.password) { - state.login_throttle.record_failure(); + let failures = state.login_throttle.record_failure(); + // Worth a mail once someone is past guessing and into trying. + if failures > FREE_ATTEMPTS { + state.notify.note_failed_logins(failures).await; + } return Err(AppError::Unauthorized); } state.login_throttle.record_success(); + let user_agent = headers + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + state.notify.note_login(user_agent).await; + let token = auth::issue_token(&state.config); Ok(( StatusCode::OK, diff --git a/src/routes/share.rs b/src/routes/share.rs index b7f005f..f209a15 100644 --- a/src/routes/share.rs +++ b/src/routes/share.rs @@ -19,8 +19,8 @@ use crate::{ }; #[derive(Serialize)] -pub struct SharedEntry { - pub title: String, +pub struct SharedDocument { + pub name: String, pub body: String, pub created_at: i64, pub updated_at: i64, @@ -45,7 +45,7 @@ fn key_matches(stored: Option<&str>, presented: &str) -> bool { bool::from(hash_key(presented).as_bytes().ct_eq(stored.as_bytes())) } -/// Publish an entry, or mint a fresh key for one that is already published. +/// Publish a document, or mint a fresh key for one that is already published. /// /// The plaintext key is returned exactly once, here. Nothing but its hash is /// stored, so a link that the author loses cannot be recovered — only replaced, @@ -57,21 +57,25 @@ pub async fn enable( State(state): State<AppState>, Path(id): Path<i64>, ) -> AppResult<Json<serde_json::Value>> { - let existing: Option<Option<String>> = - sqlx::query("SELECT share_token FROM entries WHERE id = ?1") - .bind(id) - .fetch_optional(&state.db) - .await? - .map(|row| row.get("share_token")); + let row = sqlx::query("SELECT kind, share_token FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; - let token = match existing { - None => return Err(AppError::NotFound), - Some(Some(token)) => token, - Some(None) => random_secret(), + // A share link serves one body. A folder has none, and publishing a whole + // subtree behind one key is a different feature with different consequences. + if row.get::<String, _>("kind") != super::nodes::DOCUMENT { + return Err(AppError::BadRequest("only a document can be shared".into())); + } + + let token = match row.get::<Option<String>, _>("share_token") { + Some(token) => token, + None => random_secret(), }; let key = random_secret(); - sqlx::query("UPDATE entries SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3") + sqlx::query("UPDATE nodes SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3") .bind(&token) .bind(hash_key(&key)) .bind(id) @@ -92,7 +96,7 @@ pub async fn disable( Path(id): Path<i64>, ) -> AppResult<Json<serde_json::Value>> { let affected = - sqlx::query("UPDATE entries SET share_token = NULL, share_key_hash = NULL WHERE id = ?1") + sqlx::query("UPDATE nodes SET share_token = NULL, share_key_hash = NULL WHERE id = ?1") .bind(id) .execute(&state.db) .await? @@ -109,10 +113,10 @@ pub async fn disable( pub async fn read( State(state): State<AppState>, Path((token, key)): Path<(String, String)>, -) -> AppResult<Json<SharedEntry>> { +) -> AppResult<Json<SharedDocument>> { let row = sqlx::query( - "SELECT title, body, created_at, updated_at, share_key_hash - FROM entries WHERE share_token = ?1", + "SELECT name, body, created_at, updated_at, share_key_hash + FROM nodes WHERE share_token = ?1", ) .bind(&token) .fetch_optional(&state.db) @@ -126,8 +130,8 @@ pub async fn read( return Err(AppError::NotFound); } - Ok(Json(SharedEntry { - title: row.get("title"), + Ok(Json(SharedDocument { + name: row.get("name"), body: row.get("body"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), @@ -135,17 +139,17 @@ pub async fn read( })) } -/// Public: serves a media file only while it is embedded in a shared entry, and -/// only to a caller holding that entry's key. +/// Public: serves a media file only while it is embedded in a shared document, +/// and only to a caller holding that document's key. pub async fn serve_media( State(state): State<AppState>, Path((token, key, id)): Path<(String, String, String)>, ) -> AppResult<Response> { let stored: Option<String> = sqlx::query( - "SELECT e.share_key_hash - FROM entry_media em - JOIN entries e ON e.id = em.entry_id - WHERE em.media_id = ?1 AND e.share_token = ?2", + "SELECT n.share_key_hash + FROM node_media nm + JOIN nodes n ON n.id = nm.node_id + WHERE nm.media_id = ?1 AND n.share_token = ?2", ) .bind(&id) .bind(&token) diff --git a/src/state.rs b/src/state.rs index ea73ba3..f0eac76 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use sqlx::SqlitePool; -use crate::{backup::Backup, config::Config, throttle::LoginThrottle}; +use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle}; #[derive(Clone)] pub struct AppState { @@ -11,4 +11,6 @@ pub struct AppState { pub login_throttle: Arc<LoginThrottle>, /// The Proton Drive mirror. Always present; dormant until it has a session. pub backup: Arc<Backup>, + /// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set. + pub notify: Arc<Notifier>, } diff --git a/src/throttle.rs b/src/throttle.rs index ed595a7..347d9a4 100644 --- a/src/throttle.rs +++ b/src/throttle.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; /// Failed logins get progressively slower to answer. /// -/// The diary has exactly one account, so this is one counter rather than a map +/// The workspace has exactly one account, so this is one counter rather than a map /// keyed by address: an attacker cannot step around it by changing source IP, /// and there is no per-client table to grow. The cost of that choice is that /// someone hammering the login can slow the owner's own attempt down — which is @@ -19,7 +19,7 @@ struct State { } /// Wrong passwords happen; the first few are answered at full speed. -const FREE_ATTEMPTS: u32 = 3; +pub const FREE_ATTEMPTS: u32 = 3; const MAX_DELAY: i64 = 30; /// 1s, 2s, 4s, 8s… up to `MAX_DELAY`, starting once the free attempts are used. @@ -40,10 +40,13 @@ impl LoginThrottle { } } - pub fn record_failure(&self) { + /// Returns how many failures have piled up, so the caller can decide + /// whether this one is worth telling anybody about. + pub fn record_failure(&self) -> u32 { let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); state.failures = state.failures.saturating_add(1); state.next_allowed = crate::now() + delay_after(state.failures); + state.failures } pub fn record_success(&self) { diff --git a/web/index.html b/web/index.html index f944cfe..1f2aece 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="color-scheme" content="dark" /> <meta name="theme-color" content="#0b0e0d" /> - <title>~/diary + ~/workspace diff --git a/web/package.json b/web/package.json index 96c6283..c657b08 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "narl-diary-web", + "name": "narl-workspace-web", "private": true, "type": "module", "scripts": { diff --git a/web/src/App.svelte b/web/src/App.svelte index 04a9b1b..ab5686f 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,15 +1,16 @@ + +
+
+ ~/{workspace.space?.slug ?? ''}/board + + {workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards + + + + + + + + +
+ + + +
+ {#each workspace.board?.lists ?? [] as list, listIndex (list.id)} +
+
+ {list.name} + {list.cards.length} +
+ +
    + {#each list.cards as card, cardIndex (card.id)} +
  • + +
  • + {:else} +
  • —
  • + {/each} +
+
+ {:else} +
no lists — :list <name> makes one
+ {/each} +
+
+ + diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index 170439f..786643b 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -1,7 +1,7 @@
- entry:{entry.id} - {formatStamp(diary.draft.created_at)} {formatWeekday(diary.draft.created_at)} + {workspace.here}/{doc.slug} + {formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)} · - edited {relative(entry.updated_at)} + edited {relative(doc.updated_at)} · {words} words - {#if diary.dirty}[+]{/if} + {#if workspace.dirty}[+]{/if}
{#if isTouchDevice} {/if} - {#if diary.editing} + {#if workspace.editing} {:else} {/if} - @@ -56,12 +70,12 @@
{/if} - {#if diary.editing} + {#if workspace.editing} diary.touch()} + placeholder="name (optional)" + bind:value={workspace.draft.name} + oninput={() => workspace.touch()} onkeydown={(event) => { if (event.key === 'Enter') { event.preventDefault() @@ -70,7 +84,7 @@ }} />
- {#key entry.id} + {#key doc.id} {#await import('./Editor.svelte')}
loading editor…
{:then module} @@ -88,12 +102,13 @@ {/if} {:else}
-
- {#if entry.title.trim()}

{entry.title}

{/if} + +
+ {#if doc.name.trim()}

{doc.name}

{/if} {@html html} - {#if !entry.body.trim()} -

this entry is empty — press i to write.

+ {#if !doc.body.trim()} +

this document is empty — press i to write.

{/if}
diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index 21c4347..9f06efe 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -21,7 +21,7 @@ import { onDestroy, onMount } from 'svelte' import { commands, hooks, runCommand } from './commands' import { embedSnippet } from './markdown' - import { diary } from './store.svelte' + import { workspace } from './store.svelte' let host = $state() let view: EditorView | undefined @@ -110,18 +110,18 @@ const push = registers.pushText.bind(registers) registers.pushText = (name, operator, text, linewise, blockwise) => { push(name, operator, text, linewise, blockwise) - if (!name && text && diary.clipboard) void diary.copy(text) + if (!name && text && workspace.clipboard) void workspace.copy(text) } } /** - * `?` is vim's search-backwards, but the diary advertises it as the help key + * `?` is vim's search-backwards, but the workspace advertises it as the help key * everywhere else, so it opens the reference here too. `/` still searches. */ function bindHelpKey() { - Vim.defineAction('diaryHelp', () => void runCommand('help')) + Vim.defineAction('workspaceHelp', () => void runCommand('help')) for (const context of ['normal', 'visual'] as const) { - Vim.mapCommand('?', 'action', 'diaryHelp', {}, { context }) + Vim.mapCommand('?', 'action', 'workspaceHelp', {}, { context }) } } @@ -145,8 +145,8 @@ async function uploadFiles(files: File[]) { if (files.length === 0) return - await diary.guard(async () => { - const uploaded = await diary.upload(files) + await workspace.guard(async () => { + const uploaded = await workspace.upload(files) insertAtCursor(uploaded.map(embedSnippet).join('\n\n')) }) } @@ -157,7 +157,7 @@ mirrorUnnamedRegisterToClipboard() const extensions = [ - ...(diary.vimEnabled ? [vim({ status: false })] : []), + ...(workspace.vimEnabled ? [vim({ status: false })] : []), history(), drawSelection(), dropCursor(), @@ -167,13 +167,13 @@ markdown({ base: markdownLanguage, codeLanguages: [] }), syntaxHighlighting(markdownHighlight), EditorView.lineWrapping, - placeholder('dear diary…'), + placeholder('write…'), editorTheme, keymap.of([ { key: 'Mod-s', preventDefault: true, - run: () => (void diary.guard(() => diary.save()), true), + run: () => (void workspace.guard(() => workspace.save()), true), }, ...defaultKeymap, ...historyKeymap, @@ -181,8 +181,8 @@ ]), EditorView.updateListener.of((update) => { if (update.docChanged) { - diary.draft.body = update.state.doc.toString() - diary.touch() + workspace.draft.body = update.state.doc.toString() + workspace.touch() } }), EditorView.domEventHandlers({ @@ -204,7 +204,7 @@ ] view = new EditorView({ - state: EditorState.create({ doc: diary.draft.body, extensions }), + state: EditorState.create({ doc: workspace.draft.body, extensions }), parent: host!, }) @@ -212,21 +212,21 @@ hooks.insertText = insertAtCursor view.focus() - const cm = diary.vimEnabled ? getCM(view) : null + const cm = workspace.vimEnabled ? getCM(view) : null if (cm) { // Keep the status line's mode indicator in step with the editor. const emitter = cm as unknown as { on?: (event: string, handler: (e: { mode: string }) => void) => void } emitter.on?.('vim-mode-change', (event) => { - diary.vimMode = event.mode + workspace.vimMode = event.mode }) // A fresh entry starts in insert mode; an existing one starts in normal // mode, the way opening a file in vim does. - if (diary.enterInsert) { - diary.enterInsert = false - Vim.handleKey(cm, 'i', 'diary') + if (workspace.enterInsert) { + workspace.enterInsert = false + Vim.handleKey(cm, 'i', 'workspace') } } }) @@ -236,7 +236,7 @@ view = undefined hooks.focusEditor = () => {} hooks.insertText = () => {} - diary.vimMode = 'normal' + workspace.vimMode = 'normal' }) diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 7dd3159..55b8aa8 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -1,26 +1,44 @@ @@ -22,12 +22,12 @@
:media - {diary.media.length} files - + {workspace.media.length} files +
    - {#each diary.media as file (file.id)} + {#each workspace.media as file (file.id)}
  • {#if file.mime.startsWith('image/')} @@ -41,21 +41,21 @@ {file.filename} {formatBytes(file.size)} · {formatStamp(file.created_at)} · - {usage(file.entry_ids)} + {usage(file.node_ids)}
    - {#if diary.editing} + {#if workspace.editing} {/if} - - + +
  • {:else} diff --git a/web/src/lib/Shared.svelte b/web/src/lib/Shared.svelte index c3a4f9c..5f53aa4 100644 --- a/web/src/lib/Shared.svelte +++ b/web/src/lib/Shared.svelte @@ -1,11 +1,11 @@
    {#if error}

    {error}

    - {:else if entry} + {:else if doc}
    - {formatStamp(entry.created_at)} {formatWeekday(entry.created_at)} - shared entry + {formatStamp(doc.created_at)} {formatWeekday(doc.created_at)} + shared document
    - {#if entry.title.trim()}

    {entry.title}

    {/if} + {#if doc.name.trim()}

    {doc.name}

    {/if} - {@html renderMarkdown(entry.body, { token, key: shareKey })} + {@html renderMarkdown(doc.body, { token, key: shareKey })}
    -
    — written in ~/diary
    +
    — written in ~/workspace
    {:else}

    loading…

    {/if} diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index cfdfb2a..60e1dd0 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -1,75 +1,85 @@