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 @@