diff --git a/CLAUDE.md b/CLAUDE.md index 027c6e90..f10860e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ Este projeto é implementação própria. Não copie nem adapte código, testes, crates/botloft-core tipos de domínio, IDs, envelope, tipos do protocolo (exportados via ts-rs) crates/botloft-store SQLite, migrations em migrations/NNNN_nome.sql crates/botloftd daemon: rpc, supervisor, runtime, chat, courier, tools, platform +crates/botloft-cloud servidor da conta e das cópias na nuvem (spec 27), com SQLite próprio app/ Tauri v2 + React 19 + TS strict + Tailwind v4 + Zustand docs/ spec e ADRs (docs/adr/0001: bots headless com chat) ``` diff --git a/Cargo.lock b/Cargo.lock index a4ecbf55..aa408f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,6 +294,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -469,6 +492,33 @@ dependencies = [ "winreg", ] +[[package]] +name = "botloft-cloud" +version = "0.11.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.23.1", + "bytes", + "clap", + "futures-util", + "getrandom 0.4.3", + "hex", + "lettre", + "object_store", + "rusqlite", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.21", + "tokio", + "toml 1.1.6+spec-1.1.0", + "tower", + "tracing", + "tracing-subscriber", +] + [[package]] name = "botloft-core" version = "0.11.0" @@ -512,6 +562,7 @@ dependencies = [ "argon2", "axum", "base64 0.23.1", + "botloft-cloud", "botloft-core", "botloft-store", "bytes", @@ -525,6 +576,7 @@ dependencies = [ "jiff", "jpeg-encoder", "libc", + "reqwest", "serde", "serde_json", "sha2 0.11.0", @@ -674,6 +726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -721,6 +775,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.9.1" @@ -819,6 +879,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -928,6 +997,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1279,6 +1358,28 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[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 = "embed-resource" version = "3.0.11" @@ -1485,6 +1586,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -1492,6 +1599,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1685,8 +1793,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1708,9 +1818,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1861,6 +1973,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1973,6 +2104,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -1992,6 +2129,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2250,6 +2388,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2407,6 +2554,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "jpeg-encoder" version = "0.7.1" @@ -2462,6 +2619,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[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", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2558,6 +2742,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -2598,6 +2788,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2713,6 +2913,27 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify-rust" version = "4.18.1" @@ -2982,6 +3203,48 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object_store" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1796bc93603f78c5760a69f2d58badc9618d22adade0a95385bb2adbae4eb94" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.23.1", + "bytes", + "chrono", + "crc-fast", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools", + "md-5", + "nix", + "parking_lot", + "percent-encoding", + "quick-xml 0.41.0", + "rand 0.10.3", + "reqwest", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.21", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3191,7 +3454,7 @@ checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ "base64 0.23.1", "indexmap 2.14.2", - "quick-xml", + "quick-xml 0.42.0", "serde", "time", ] @@ -3354,6 +3617,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.42.0" @@ -3363,6 +3636,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3372,6 +3702,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 = "5.3.0" @@ -3439,6 +3775,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3523,6 +3868,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -3533,6 +3879,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3649,6 +3996,8 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -3675,6 +4024,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -3711,6 +4061,7 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4204,6 +4555,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -4907,9 +5264,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -4975,6 +5332,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -5627,6 +5985,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" diff --git a/Cargo.toml b/Cargo.toml index 12891a42..6a93e376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" -members = ["crates/botloft-core", "crates/botloft-store", "crates/botloftd", "app/src-tauri", "app/src-setup"] -default-members = ["crates/botloft-core", "crates/botloft-store", "crates/botloftd"] +members = ["crates/botloft-core", "crates/botloft-store", "crates/botloft-cloud", "crates/botloftd", "app/src-tauri", "app/src-setup"] +default-members = ["crates/botloft-core", "crates/botloft-store", "crates/botloft-cloud", "crates/botloftd"] [workspace.package] version = "0.11.0" diff --git a/README.md b/README.md index 192fbb15..8c147a90 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ you decide what they may do. waits near the clock and tells you when a bot needs you. - **Choose per bot.** How much it asks before acting, and which Claude model it uses. See how much of your plan's usage is left. +- **Back up and move.** Save a sealed backup file, or sign in with your e-mail (no password) to keep + a light copy in the cloud and restore it on another computer. The server cannot read it. @@ -128,8 +130,10 @@ The full design, from the protocol to the states of a bot, lives in [`docs/spec. ## Privacy and security -- **Local only.** The daemon listens on `127.0.0.1`. No cloud service of our own, no telemetry, no - accounts. What bots send to Claude goes through Claude Code, as when you use it yourself. +- **Local first.** The daemon listens on `127.0.0.1`, and there is no telemetry. An account is + optional: without one, Botloft uses the network for nothing beyond updates and what bots send to + Claude, which goes through Claude Code as when you use it yourself. With one, the server only ever + holds a backup sealed with a password that never leaves your computer (see the FAQ). - **You decide what bots may do.** Per bot: ask before anything, accept edits, plan only, or decide on its own what needs your OK. Unless it decides on its own, a bot also asks before its first visit to each website. @@ -169,6 +173,19 @@ yours. Botloft's own data (SQLite, logs, secrets) is in `%LOCALAPPDATA%\Botloft` `~/.local/share/Botloft` on Linux and `~/Library/Application Support/Botloft` on macOS. +
+What does the optional account keep, and who can read it? + +You sign in with a link sent to your e-mail; there is no account password. The cloud copy is the +light one: your crews' structure, the bots' memory and the routines, not the chats or files. Botloft +seals it on your computer with the backup password before sending, so the server stores bytes it +cannot open. It also knows your e-mail, the computers you signed in on, and the size and date of each +copy. Lose the backup password and the cloud copies cannot be recovered. Nothing is sent without a +click, and you can delete the copies or the whole account from the app. The server is this +repository's `botloft-cloud` crate: you can [run your own](docs/cloud-deploy.md) and point the app at +it. +
+
Why does Windows or macOS warn me when I install it? diff --git a/app/src/features/account/BackupRestore.tsx b/app/src/features/account/BackupRestore.tsx index cb608cb3..65c7d911 100644 --- a/app/src/features/account/BackupRestore.tsx +++ b/app/src/features/account/BackupRestore.tsx @@ -1,142 +1,38 @@ -// Restoring a copy (spec 14.2): the owner picks the file and types its -// passphrase; the daemon opens it and says what it holds; only after the -// owner confirms does Botloft restart and swap it in. +// Restoring a copy (spec 14.2): the owner picks the file, then `RestoreFile` +// asks for its passphrase and swaps it in on a restart. -import { FolderOpen, RotateCcw } from "lucide-react"; +import { FolderOpen } from "lucide-react"; import { useState } from "react"; import { useT } from "../../i18n"; -import { when } from "../../lib/format"; -import type { BackupManifest } from "../../lib/protocol.gen"; -import { useApi, useHost } from "../../store/context"; +import { useHost } from "../../store/context"; import { Button } from "../../ui/Button"; -import { Callout } from "../../ui/Callout"; -import { TextField } from "../../ui/Field"; -import { backupError } from "./BackupSettings"; +import { RestoreFile } from "./RestoreFile"; export function BackupRestore() { const b = useT().account.backup; - const api = useApi(); const host = useHost(); const [file, setFile] = useState(null); - const [passphrase, setPassphrase] = useState(""); - const [manifest, setManifest] = useState(null); - const [busy, setBusy] = useState(false); - const [failed, setFailed] = useState<{ title: string; text: string } | null>(null); const pick = async () => { const picked = await host.pickFile(b.pickTitle, b.kind, ["botloft"]); if (picked) { setFile(picked); - setManifest(null); - setFailed(null); } }; - const open = async () => { - if (!file) { - return; - } - setBusy(true); - setFailed(null); - try { - setManifest(await api.call("backup.stage", { path: file, passphrase })); - } catch (failure) { - setFailed({ title: b.openFailed, text: backupError(failure, b.reasons) }); - } - setBusy(false); - }; - - const restore = async () => { - setBusy(true); - setFailed(null); - try { - await api.call("backup.confirm"); - await host.restartDaemon(); - } catch (failure) { - setFailed({ title: b.restoreFailed, text: backupError(failure, b.reasons) }); - setBusy(false); - } - }; - - const cancel = async () => { - await api.call("backup.cancel").catch(() => null); - setManifest(null); - setFile(null); - setPassphrase(""); - }; - - const folders = (manifest?.crews ?? []).flatMap((crew) => - crew.workFolder ? [crew.workFolder] : [], - ); - return (
- {!manifest && ( - <> -
- - {file && ( - - {file} - - )} -
- {file && ( -
-
- setPassphrase(event.target.value)} - /> -
- -
- )} - - )} - {manifest && ( -
-

{b.from(when(manifest.createdAt))}

-
    - {manifest.crews.map((crew) => ( -
  • {b.crew(crew.name, crew.bots.length)}
  • - ))} -
- {folders.length > 0 && ( -
-

{b.workFolders}

-
    - {folders.map((folder) => ( -
  • - {folder} -
  • - ))} -
-
- )} -

{b.replaces}

-
- - -
-
- )} - {failed && ( - - {failed.text} - - )} +
+ + {file && ( + + {file} + + )} +
+ {file && setFile(null)} />}
); } diff --git a/app/src/features/account/BackupSettings.tsx b/app/src/features/account/BackupSettings.tsx index 0f016c17..590dfa39 100644 --- a/app/src/features/account/BackupSettings.tsx +++ b/app/src/features/account/BackupSettings.tsx @@ -1,32 +1,31 @@ -// Settings, "Backup" (spec 14.2): save everything to one file locked with -// a passphrase, and restore such a file. The daemon makes the copy; the -// owner picks where it goes with the system's Save dialog. +// Settings, "Backup" (spec 14.2, 27): save everything to one file locked with +// a passphrase, restore such a file, and keep light copies in the cloud. The +// daemon makes the copy; the owner picks where it goes with the system's Save +// dialog. import { Download } from "lucide-react"; import { useState } from "react"; import { useT } from "../../i18n"; -import { errorText } from "../../lib/api"; import { fileSize } from "../../lib/format"; -import { RpcError } from "../../lib/rpc"; import { useApi, useHost } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; -import { TextField } from "../../ui/Field"; import { BackupRestore } from "./BackupRestore"; +import { backupError } from "./backupError"; +import { CloudAccount } from "./CloudAccount"; +import { PassphraseFields, usePassphrase } from "./PassphraseFields"; import { Section } from "./settingsParts"; -/** A failure as the owner reads it: the daemon's reason, worded, or the error. */ -export function backupError(failure: unknown, reasons: Record): string { - const reason = failure instanceof RpcError ? failure.reason : undefined; - return (reason && reasons[reason]) || errorText(failure); -} - type Outcome = { saved: string } | { notSaved: true } | { failed: string } | null; export function BackupSettings() { const b = useT().account.backup; + const cloud = useT().account.cloud; return ( <> +
+ +

{b.exportIntro}

@@ -43,23 +42,19 @@ function ExportCopy() { const b = useT().account.backup; const api = useApi(); const host = useHost(); - const [passphrase, setPassphrase] = useState(""); - const [repeat, setRepeat] = useState(""); + const passphrase = usePassphrase(); const [busy, setBusy] = useState(false); const [outcome, setOutcome] = useState(null); - const mismatch = repeat.length > 0 && repeat !== passphrase; - const ready = [...passphrase].length >= 8 && repeat === passphrase; const save = async () => { setBusy(true); setOutcome(null); try { - const exported = await api.call("backup.export", { passphrase }); + const exported = await api.call("backup.export", { passphrase: passphrase.passphrase }); const kept = await host.saveFileAs(exported.path); setOutcome(kept ? { saved: fileSize(exported.size) } : { notSaved: true }); if (kept) { - setPassphrase(""); - setRepeat(""); + passphrase.clear(); } } catch (failure) { setOutcome({ failed: backupError(failure, b.reasons) }); @@ -69,26 +64,13 @@ function ExportCopy() { return (
-
- setPassphrase(event.target.value)} - /> - setRepeat(event.target.value)} - /> -
-

- {mismatch ? b.mismatch : b.passphraseHint} -

- {outcome && "saved" in outcome && ( diff --git a/app/src/features/account/CloudAccount.tsx b/app/src/features/account/CloudAccount.tsx new file mode 100644 index 00000000..4a8dec88 --- /dev/null +++ b/app/src/features/account/CloudAccount.tsx @@ -0,0 +1,29 @@ +// Settings, "Backup", the account block (spec 27.6): sign in with an e-mail +// link, then send light copies to the cloud and bring them back. + +import { useT } from "../../i18n"; +import { CloudSignedIn } from "./CloudSignedIn"; +import { CloudSignIn } from "./CloudSignIn"; +import { useCloud } from "./useCloud"; + +export function CloudAccount() { + const t = useT().account.cloud; + const { status, refresh, expired, forgetExpired } = useCloud(); + if (!status) { + return null; + } + if (!status.url) { + return

{t.noCloud}

; + } + if (status.signedIn) { + return ; + } + return ( + + ); +} diff --git a/app/src/features/account/CloudCopies.tsx b/app/src/features/account/CloudCopies.tsx new file mode 100644 index 00000000..da826578 --- /dev/null +++ b/app/src/features/account/CloudCopies.tsx @@ -0,0 +1,119 @@ +// The copies kept in the cloud (spec 27.6): when each is from and how big, +// to bring back or delete. + +import { useCallback, useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import { fileSize, when } from "../../lib/format"; +import type { CloudCopy } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Confirm } from "../../ui/Confirm"; +import { backupError } from "./backupError"; +import { ProgressBar } from "./ProgressBar"; +import { RestoreFile } from "./RestoreFile"; +import { useCloudProgress } from "./useCloud"; + +export function CloudCopies({ + version, + onChanged, +}: { + version: number; + onChanged(): Promise; +}) { + const t = useT().account.cloud; + const api = useApi(); + const [copies, setCopies] = useState(null); + const [bringing, setBringing] = useState(null); + const [file, setFile] = useState(null); + const [removing, setRemoving] = useState(null); + const [problem, setProblem] = useState(null); + const progress = useCloudProgress("download", bringing !== null); + + const load = useCallback(async () => { + try { + setCopies((await api.call("cloud.copies")).copies); + } catch (failure) { + setCopies([]); + setProblem(backupError(failure, t.reasons)); + } + }, [api, t.reasons]); + + // `version` changes when a copy was just sent. + // biome-ignore lint/correctness/useExhaustiveDependencies: reload on a new copy + useEffect(() => { + void load(); + }, [load, version]); + + const bring = async (copy: CloudCopy) => { + setBringing(copy.id); + setProblem(null); + try { + setFile((await api.call("cloud.download", { id: copy.id })).path); + } catch (failure) { + setProblem(backupError(failure, t.reasons)); + } + setBringing(null); + }; + + const remove = async (copy: CloudCopy) => { + try { + await api.call("cloud.delete", { id: copy.id }); + } catch (failure) { + setProblem(backupError(failure, t.reasons)); + } + await load(); + await onChanged(); + }; + + if (!copies) { + return null; + } + return ( +
+

{t.copiesTitle}

+ {copies.length === 0 ? ( +

{t.noCopies}

+ ) : ( +
    + {copies.map((copy) => ( +
  • + + {t.copyFrom(when(copy.created), fileSize(copy.size))} + + + +
  • + ))} +
+ )} + {bringing !== null && progress !== null && ( + + )} + {file && setFile(null)} />} + {problem && ( + + {problem} + + )} + {removing && ( + remove(removing)} + onClose={() => setRemoving(null)} + > + {t.removeCopyText} + + )} +
+ ); +} diff --git a/app/src/features/account/CloudSignIn.tsx b/app/src/features/account/CloudSignIn.tsx new file mode 100644 index 00000000..25b73c10 --- /dev/null +++ b/app/src/features/account/CloudSignIn.tsx @@ -0,0 +1,116 @@ +// Asking for the sign-in link and waiting for it (spec 27.3, 27.6). + +import { Mail } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useLocale, useT } from "../../i18n"; +import type { CloudStatus } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { TextField } from "../../ui/Field"; +import { backupError } from "./backupError"; + +/** The link can be asked for again after this long (the server holds it a minute). */ +const RESEND_AFTER_MS = 60_000; + +export function CloudSignIn({ + status, + refresh, + expired, + forgetExpired, +}: { + status: CloudStatus; + refresh(): Promise; + expired: boolean; + forgetExpired(): void; +}) { + const t = useT().account.cloud; + const { locale } = useLocale(); + const api = useApi(); + const [email, setEmail] = useState(""); + const [sentTo, setSentTo] = useState(null); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(null); + const [canResend, setCanResend] = useState(false); + + // A link asked for before this screen opened still waits. + const waiting = status.pending; + + useEffect(() => { + if (!waiting) { + setCanResend(false); + return; + } + const timer = setTimeout(() => setCanResend(true), RESEND_AFTER_MS); + return () => clearTimeout(timer); + }, [waiting]); + + const ask = async (address: string) => { + setBusy(true); + setProblem(null); + forgetExpired(); + try { + await api.call("cloud.signin", { email: address, locale }); + setSentTo(address); + setCanResend(false); + await refresh(); + } catch (failure) { + setProblem(backupError(failure, t.reasons)); + } + setBusy(false); + }; + + const cancel = async () => { + await api.call("cloud.signin_cancel").catch(() => null); + setSentTo(null); + await refresh(); + }; + + return ( +
+

{t.intro}

+ {waiting ? ( +
+

+ {sentTo ? t.linkSent(sentTo) : t.linkWaiting} +

+
+ + +
+
+ ) : ( +
{ + event.preventDefault(); + void ask(email.trim()); + }} + > +
+ setEmail(event.target.value)} + /> +
+ + + )} + {expired && !waiting &&

{t.expired}

} + {problem && ( + + {problem} + + )} +
+ ); +} diff --git a/app/src/features/account/CloudSignedIn.tsx b/app/src/features/account/CloudSignedIn.tsx new file mode 100644 index 00000000..b68dc626 --- /dev/null +++ b/app/src/features/account/CloudSignedIn.tsx @@ -0,0 +1,101 @@ +// The account when this computer is in (spec 27.6): who, how much is used, +// the copies, and the way out. + +import { useState } from "react"; +import { useT } from "../../i18n"; +import { fileSize } from "../../lib/format"; +import type { CloudStatus } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Confirm } from "../../ui/Confirm"; +import { backupError } from "./backupError"; +import { CloudCopies } from "./CloudCopies"; +import { CloudUpload } from "./CloudUpload"; + +export function CloudSignedIn({ + status, + refresh, +}: { + status: CloudStatus; + refresh(): Promise; +}) { + const t = useT().account.cloud; + const api = useApi(); + const [version, setVersion] = useState(0); + const [removing, setRemoving] = useState(false); + const [asked, setAsked] = useState(false); + const [problem, setProblem] = useState(null); + + const signOut = async () => { + await api.call("cloud.signout").catch(() => null); + await refresh(); + }; + + const removeAccount = async () => { + setProblem(null); + try { + await api.call("cloud.delete_account"); + setAsked(true); + } catch (failure) { + setProblem(backupError(failure, t.reasons)); + } + }; + + return ( +
+
+
+

{t.signedInAs(status.email ?? "")}

+ {status.used !== undefined && status.quota !== undefined && ( +

+ {t.usage(fileSize(status.used), fileSize(status.quota))} +

+ )} +
+ +
+ + { + setVersion((current) => current + 1); + void refresh(); + }} + /> + + +
+ {asked ? ( +

+ {t.removeAccountSent} +

+ ) : ( + + )} + {problem && ( + + {problem} + + )} +
+ {removing && ( + setRemoving(false)} + > + {t.removeAccountText} + + )} +
+ ); +} diff --git a/app/src/features/account/CloudUpload.tsx b/app/src/features/account/CloudUpload.tsx new file mode 100644 index 00000000..3ce0a9a5 --- /dev/null +++ b/app/src/features/account/CloudUpload.tsx @@ -0,0 +1,66 @@ +// Sending a light copy to the cloud (spec 27.6): the passphrase twice, then +// a bar while it goes up. + +import { CloudUpload as UploadIcon } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import { fileSize } from "../../lib/format"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { backupError } from "./backupError"; +import { PassphraseFields, usePassphrase } from "./PassphraseFields"; +import { ProgressBar } from "./ProgressBar"; +import { useCloudProgress } from "./useCloud"; + +type Outcome = { sent: string } | { failed: string } | null; + +export function CloudUpload({ onSent }: { onSent(): void }) { + const t = useT().account.cloud; + const api = useApi(); + const passphrase = usePassphrase(); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + const progress = useCloudProgress("upload", busy); + + const send = async () => { + setBusy(true); + setOutcome(null); + try { + const copy = await api.call("cloud.upload", { passphrase: passphrase.passphrase }); + setOutcome({ sent: fileSize(copy.size) }); + passphrase.clear(); + onSent(); + } catch (failure) { + setOutcome({ failed: backupError(failure, t.reasons) }); + } + setBusy(false); + }; + + return ( +
+

{t.uploadTitle}

+

{t.uploadIntro}

+ + + {busy && progress !== null && } + {outcome && "sent" in outcome && ( +

+ {t.uploaded(outcome.sent)} +

+ )} + {outcome && "failed" in outcome && ( + + {outcome.failed} + + )} +
+ ); +} diff --git a/app/src/features/account/PassphraseFields.tsx b/app/src/features/account/PassphraseFields.tsx new file mode 100644 index 00000000..b1b174d2 --- /dev/null +++ b/app/src/features/account/PassphraseFields.tsx @@ -0,0 +1,50 @@ +// The passphrase typed twice (spec 14.2, 27.6): for a copy saved on this +// computer and for one sent to the cloud. + +import { useState } from "react"; +import { useT } from "../../i18n"; +import { TextField } from "../../ui/Field"; + +export function usePassphrase() { + const [passphrase, setPassphrase] = useState(""); + const [repeat, setRepeat] = useState(""); + return { + passphrase, + repeat, + setPassphrase, + setRepeat, + ready: [...passphrase].length >= 8 && repeat === passphrase, + clear: () => { + setPassphrase(""); + setRepeat(""); + }, + }; +} + +export function PassphraseFields({ value }: { value: ReturnType }) { + const b = useT().account.backup; + const mismatch = value.repeat.length > 0 && value.repeat !== value.passphrase; + return ( + <> +
+ value.setPassphrase(event.target.value)} + /> + value.setRepeat(event.target.value)} + /> +
+

+ {mismatch ? b.mismatch : b.passphraseHint} +

+ + ); +} diff --git a/app/src/features/account/ProgressBar.tsx b/app/src/features/account/ProgressBar.tsx new file mode 100644 index 00000000..a812af79 --- /dev/null +++ b/app/src/features/account/ProgressBar.tsx @@ -0,0 +1,16 @@ +/** How far a transfer has got, from 0 to 1. */ +export function ProgressBar({ value, label }: { value: number; label: string }) { + const percent = Math.round(value * 100); + return ( +
+
+
+ ); +} diff --git a/app/src/features/account/RestoreFile.tsx b/app/src/features/account/RestoreFile.tsx new file mode 100644 index 00000000..8f49c3df --- /dev/null +++ b/app/src/features/account/RestoreFile.tsx @@ -0,0 +1,115 @@ +// Restoring a copy that is a file on this computer (spec 14.2): the owner +// types its passphrase, the daemon opens it and says what it holds, and only +// after the owner confirms does Botloft restart and swap it in. The file +// comes from the picker, or from the cloud (spec 27). + +import { RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import { when } from "../../lib/format"; +import type { BackupManifest } from "../../lib/protocol.gen"; +import { useApi, useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { TextField } from "../../ui/Field"; +import { backupError } from "./backupError"; + +export function RestoreFile({ file, onCancel }: { file: string; onCancel(): void }) { + const b = useT().account.backup; + const api = useApi(); + const host = useHost(); + const [passphrase, setPassphrase] = useState(""); + const [manifest, setManifest] = useState(null); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState<{ title: string; text: string } | null>(null); + + const open = async () => { + setBusy(true); + setFailed(null); + try { + setManifest(await api.call("backup.stage", { path: file, passphrase })); + } catch (failure) { + setFailed({ title: b.openFailed, text: backupError(failure, b.reasons) }); + } + setBusy(false); + }; + + const restore = async () => { + setBusy(true); + setFailed(null); + try { + await api.call("backup.confirm"); + await host.restartDaemon(); + } catch (failure) { + setFailed({ title: b.restoreFailed, text: backupError(failure, b.reasons) }); + setBusy(false); + } + }; + + const cancel = async () => { + await api.call("backup.cancel").catch(() => null); + onCancel(); + }; + + const folders = (manifest?.crews ?? []).flatMap((crew) => + crew.workFolder ? [crew.workFolder] : [], + ); + + return ( +
+ {!manifest && ( +
+
+ setPassphrase(event.target.value)} + /> +
+ +
+ )} + {manifest && ( +
+

{b.from(when(manifest.createdAt))}

+
    + {manifest.crews.map((crew) => ( +
  • {b.crew(crew.name, crew.bots.length)}
  • + ))} +
+ {manifest.scope === "light" &&

{b.lightNote}

} + {folders.length > 0 && ( +
+

{b.workFolders}

+
    + {folders.map((folder) => ( +
  • + {folder} +
  • + ))} +
+
+ )} +

{b.replaces}

+
+ + +
+
+ )} + {failed && ( + + {failed.text} + + )} +
+ ); +} diff --git a/app/src/features/account/backupError.ts b/app/src/features/account/backupError.ts new file mode 100644 index 00000000..fc699bc3 --- /dev/null +++ b/app/src/features/account/backupError.ts @@ -0,0 +1,8 @@ +import { errorText } from "../../lib/api"; +import { RpcError } from "../../lib/rpc"; + +/** A failure as the owner reads it: the daemon's reason, worded, or the error. */ +export function backupError(failure: unknown, reasons: Record): string { + const reason = failure instanceof RpcError ? failure.reason : undefined; + return (reason && reasons[reason]) || errorText(failure); +} diff --git a/app/src/features/account/cloud.test.tsx b/app/src/features/account/cloud.test.tsx new file mode 100644 index 00000000..6890e40d --- /dev/null +++ b/app/src/features/account/cloud.test.tsx @@ -0,0 +1,138 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { FAKE_PASSPHRASE } from "../../lib/fakeBackup"; +import { expireFakeCloudLink, openFakeCloudLink } from "../../lib/fakeCloud"; +import { FakeHost } from "../../lib/fakeHost"; +import { RpcErrorCode } from "../../lib/protocol.gen"; +import { RpcError } from "../../lib/rpc"; +import { crewOpened, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function openBackup() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + fake.addBot(ops.id, "Scout"); + fake.addBot(ops.id, "Writer"); + const host = new FakeHost(); + renderApp(fake, host); + await crewOpened("Ops"); + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" })); + return { fake, host, dialog }; +} + +const type = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } }); + +/** Signs in through the screen, the way the owner does. */ +async function signIn(fake: FakeBotloft, dialog: HTMLElement) { + type(await within(dialog).findByLabelText("Your e-mail"), "Ana@Exemplo.com"); + fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" })); + await within(dialog).findByText(/We sent a link to Ana@Exemplo.com/); + act(() => openFakeCloudLink(fake)); + await within(dialog).findByText("Signed in as ana@exemplo.com"); +} + +/** Types the passphrase twice in the cloud block, the first pair of fields. */ +function sendCopy(dialog: HTMLElement) { + const [first] = within(dialog).getAllByLabelText("Passphrase"); + const [again] = within(dialog).getAllByLabelText("Passphrase again"); + type(first as HTMLElement, "correct horse"); + type(again as HTMLElement, "correct horse"); + fireEvent.click(within(dialog).getByRole("button", { name: "Send a copy now" })); +} + +describe("the account and the copies in the cloud", () => { + test("a link is asked for in the app's language and signs in when it is opened", async () => { + const { fake, dialog } = await openBackup(); + const ask = await within(dialog).findByRole("button", { name: "Send me the link" }); + expect(ask).toHaveProperty("disabled", true); + await signIn(fake, dialog); + const call = fake.calls.find((each) => each.method === "cloud.signin"); + expect(call?.params).toEqual({ email: "Ana@Exemplo.com", locale: "en" }); + expect(within(dialog).getByRole("button", { name: "Sign out" })).toBeDefined(); + }); + + test("a link that ran out says so and can be asked for again", async () => { + const { fake, dialog } = await openBackup(); + type(await within(dialog).findByLabelText("Your e-mail"), "ana@exemplo.com"); + fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" })); + await within(dialog).findByText(/We sent a link/); + act(() => expireFakeCloudLink(fake)); + expect(await within(dialog).findByText("The link ran out. Ask for a new one.")).toBeDefined(); + expect(within(dialog).getByRole("button", { name: "Send me the link" })).toBeDefined(); + }); + + test("a copy goes up, is listed, and comes back with its passphrase as a light one", async () => { + const { fake, host, dialog } = await openBackup(); + await signIn(fake, dialog); + expect(await within(dialog).findByText("No copies yet.")).toBeDefined(); + + sendCopy(dialog); + expect(await within(dialog).findByText("Copy sent (47 KB).")).toBeDefined(); + const upload = fake.calls.find((each) => each.method === "cloud.upload"); + expect(upload?.params).toEqual({ passphrase: "correct horse" }); + expect(await within(dialog).findByText(/47 KB$/)).toBeDefined(); + expect(within(dialog).getByText(/47 KB of 200 MB used/)).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Restore" })); + const field = await within(dialog).findByLabelText("The copy's passphrase"); + type(field, FAKE_PASSPHRASE); + fireEvent.click(within(dialog).getByRole("button", { name: "Open" })); + expect(await within(dialog).findByText("Ops: 2 bots")).toBeDefined(); + expect(within(dialog).getByText(/The chats and files are not in it/)).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "Restore and restart" })); + await waitFor(() => expect(host.installs).toContain("restart")); + }); + + test("a copy is deleted only after the owner confirms", async () => { + const { fake, dialog } = await openBackup(); + await signIn(fake, dialog); + sendCopy(dialog); + await within(dialog).findByText("Copy sent (47 KB)."); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + const ask = await screen.findByRole("dialog", { name: "Delete this copy?" }); + fireEvent.click(within(ask).getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Delete this copy?" })).toBeNull(), + ); + expect(fake.calls.some((each) => each.method === "cloud.delete")).toBe(false); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + const again = await screen.findByRole("dialog", { name: "Delete this copy?" }); + fireEvent.click(within(again).getByRole("button", { name: "Delete" })); + expect(await within(dialog).findByText("No copies yet.")).toBeDefined(); + }); + + test("signing out goes back to asking for a link, and deleting the account asks first", async () => { + const { fake, dialog } = await openBackup(); + await signIn(fake, dialog); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete my account…" })); + const ask = await screen.findByRole("dialog", { name: "Delete your account?" }); + fireEvent.click(within(ask).getByRole("button", { name: "Send the link" })); + expect( + await within(dialog).findByText(/We sent a link to your e-mail\. Open it and press/), + ).toBeDefined(); + expect(fake.calls.some((each) => each.method === "cloud.delete_account")).toBe(true); + + fireEvent.click(within(dialog).getByRole("button", { name: "Sign out" })); + expect(await within(dialog).findByLabelText("Your e-mail")).toBeDefined(); + }); + + test("a failure is told in the owner's words", async () => { + const { fake, dialog } = await openBackup(); + type(await within(dialog).findByLabelText("Your e-mail"), "a@b"); + fake.failNext("cloud.signin", new RpcError(RpcErrorCode.validation, "offline", "offline")); + fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" })); + expect( + await within(dialog).findByText( + "Could not reach the cloud. Check your connection and try again.", + ), + ).toBeDefined(); + }); +}); diff --git a/app/src/features/account/useCloud.ts b/app/src/features/account/useCloud.ts new file mode 100644 index 00000000..ecca0197 --- /dev/null +++ b/app/src/features/account/useCloud.ts @@ -0,0 +1,57 @@ +// What the Settings screen knows of the account (spec 27.6): its status, +// kept current by the daemon's notifications, and how a transfer is going. + +import { useCallback, useEffect, useState } from "react"; +import type { CloudDirection, CloudProgress, CloudStatus } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; + +export function useCloud() { + const api = useApi(); + const [status, setStatus] = useState(null); + const [expired, setExpired] = useState(false); + + const refresh = useCallback(async () => { + try { + setStatus(await api.call("cloud.status")); + } catch { + // The screen keeps what it had. + } + }, [api]); + + useEffect(() => { + void refresh(); + return api.subscribe((event) => { + if (event.name === "cloud.signed_in") { + setExpired(false); + void refresh(); + } else if (event.name === "cloud.signin_expired") { + setExpired(true); + void refresh(); + } + }); + }, [api, refresh]); + + return { status, refresh, expired, forgetExpired: () => setExpired(false) }; +} + +/** How much of a copy has gone `direction`, from 0 to 1, or null when nothing moves. */ +export function useCloudProgress(direction: CloudDirection, active: boolean): number | null { + const api = useApi(); + const [progress, setProgress] = useState(null); + + useEffect(() => { + if (!active) { + setProgress(null); + return; + } + return api.subscribe((event) => { + if (event.name === "cloud.progress" && event.params.direction === direction) { + setProgress(event.params); + } + }); + }, [api, direction, active]); + + return active && progress && progress.total > 0 + ? Math.min(1, progress.sent / progress.total) + : null; +} diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 7555d364..878d0b46 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -88,6 +88,8 @@ export const account = { cancel: "Cancel", openFailed: "Could not open the copy", restoreFailed: "Could not restore the copy", + lightNote: + "This copy has the crews, the bots, their memory and routines. The chats and files are not in it.", reasons: { short_passphrase: "The passphrase needs at least 8 characters.", wrong_passphrase: "The passphrase is wrong, or the file is damaged.", @@ -95,6 +97,61 @@ export const account = { newer_backup: "This copy is from a newer Botloft. Update Botloft first.", } as Record, }, + cloud: { + title: "Account and cloud copies", + noCloud: "Cloud copies are not set up in this Botloft yet.", + intro: + "An account keeps copies of your crews in the cloud, so you can bring them back on another computer. Only your e-mail and the copies are kept, and nobody can open a copy without your passphrase.", + email: "Your e-mail", + sendLink: "Send me the link", + sendingLink: "Sending…", + linkSent: (email: string) => + `We sent a link to ${email}. Open the e-mail, press the button there and come back here.`, + linkWaiting: "We sent you a link. Open the e-mail, press the button there and come back here.", + resend: "Send again", + cancel: "Cancel", + expired: "The link ran out. Ask for a new one.", + signedInAs: (email: string) => `Signed in as ${email}`, + signOut: "Sign out", + usage: (used: string, quota: string) => `${used} of ${quota} used`, + uploadTitle: "Send a copy to the cloud", + uploadIntro: + "Goes up: your crews, bots, each bot's memory and routines. Chats and files stay only on this computer. The copy is locked with a passphrase that never leaves it.", + upload: "Send a copy now", + uploading: "Sending…", + uploaded: (size: string) => `Copy sent (${size}).`, + uploadFailed: "Could not send the copy", + copiesTitle: "Copies in the cloud", + noCopies: "No copies yet.", + copyFrom: (when: string, size: string) => `${when} · ${size}`, + restore: "Restore", + bringing: "Bringing it down…", + removeCopy: "Delete", + removeCopyTitle: "Delete this copy?", + removeCopyText: "It is deleted from the cloud. Copies saved on your computer stay as they are.", + removeAccount: "Delete my account…", + removeAccountTitle: "Delete your account?", + removeAccountText: + "This deletes your account and every copy saved in it, and it cannot be undone. We send a link to your e-mail, and nothing is deleted until you press the button there.", + removeAccountSend: "Send the link", + removeAccountSent: "We sent a link to your e-mail. Open it and press the button to finish.", + failed: "Something went wrong", + reasons: { + no_server: "This Botloft has no cloud set up yet.", + offline: "Could not reach the cloud. Check your connection and try again.", + bad_email: "That does not look like an e-mail address.", + rate_limited: "Too many tries. Wait a little and try again.", + mail_failed: "The e-mail could not be sent. Try again in a moment.", + not_signed_in: "Sign in to use the account.", + signed_out: "This computer was signed out of the account. Sign in again.", + short_passphrase: "The passphrase needs at least 8 characters.", + too_big: "This copy is too big for the cloud.", + quota: "There is no room left in your account. Delete an old copy first.", + not_found: "That copy is no longer there.", + bad_hash: "The copy was damaged on the way. Try again.", + cloud_error: "Something went wrong with the cloud. Try again in a moment.", + } as Record, + }, settings: { title: "Settings", background: "In the background", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index a534bcd0..87aebea5 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -88,6 +88,8 @@ export const account: Messages["account"] = { cancel: "Cancelar", openFailed: "No se pudo abrir la copia", restoreFailed: "No se pudo restaurar la copia", + lightNote: + "Esta copia tiene los equipos, los bots, su memoria y las rutinas. Las conversaciones y los archivos no están en ella.", reasons: { short_passphrase: "La contraseña necesita al menos 8 caracteres.", wrong_passphrase: "La contraseña es incorrecta, o el archivo está dañado.", @@ -95,6 +97,63 @@ export const account: Messages["account"] = { newer_backup: "Esta copia es de un Botloft más nuevo. Actualiza Botloft antes.", } as Record, }, + cloud: { + title: "Cuenta y copias en la nube", + noCloud: "Las copias en la nube aún no están configuradas en este Botloft.", + intro: + "La cuenta guarda copias de tus equipos en la nube, para que las recuperes en otro ordenador. Solo se guardan tu correo y las copias, y nadie abre una copia sin tu contraseña.", + email: "Tu correo", + sendLink: "Enviar el enlace", + sendingLink: "Enviando…", + linkSent: (email: string) => + `Enviamos un enlace a ${email}. Abre el correo, pulsa el botón que hay allí y vuelve aquí.`, + linkWaiting: + "Te enviamos un enlace. Abre el correo, pulsa el botón que hay allí y vuelve aquí.", + resend: "Enviar otra vez", + cancel: "Cancelar", + expired: "El enlace caducó. Pide otro.", + signedInAs: (email: string) => `Sesión iniciada como ${email}`, + signOut: "Salir", + usage: (used: string, quota: string) => `${used} de ${quota} usados`, + uploadTitle: "Enviar una copia a la nube", + uploadIntro: + "Suben: tus equipos, los bots, la memoria de cada uno y las rutinas. Las conversaciones y los archivos se quedan solo en este ordenador. La copia se cierra con una contraseña que nunca sale de él.", + upload: "Enviar una copia ahora", + uploading: "Enviando…", + uploaded: (size: string) => `Copia enviada (${size}).`, + uploadFailed: "No se pudo enviar la copia", + copiesTitle: "Copias en la nube", + noCopies: "Aún no hay copias.", + copyFrom: (when: string, size: string) => `${when} · ${size}`, + restore: "Restaurar", + bringing: "Trayendo la copia…", + removeCopy: "Borrar", + removeCopyTitle: "¿Borrar esta copia?", + removeCopyText: + "Se borra de la nube. Las copias guardadas en tu ordenador se quedan como están.", + removeAccount: "Borrar mi cuenta…", + removeAccountTitle: "¿Borrar tu cuenta?", + removeAccountText: + "Esto borra tu cuenta y todas las copias guardadas en ella, y no se puede deshacer. Enviamos un enlace a tu correo, y no se borra nada hasta que pulses el botón de allí.", + removeAccountSend: "Enviar el enlace", + removeAccountSent: "Enviamos un enlace a tu correo. Ábrelo y pulsa el botón para terminar.", + failed: "Algo salió mal", + reasons: { + no_server: "Este Botloft aún no tiene la nube configurada.", + offline: "No se pudo hablar con la nube. Revisa la conexión e inténtalo de nuevo.", + bad_email: "Eso no parece una dirección de correo.", + rate_limited: "Demasiados intentos. Espera un poco e inténtalo de nuevo.", + mail_failed: "No se pudo enviar el correo. Inténtalo de nuevo en un momento.", + not_signed_in: "Inicia sesión para usar la cuenta.", + signed_out: "Este ordenador salió de la cuenta. Inicia sesión de nuevo.", + short_passphrase: "La contraseña necesita al menos 8 caracteres.", + too_big: "Esta copia es demasiado grande para la nube.", + quota: "No queda espacio en tu cuenta. Borra antes una copia antigua.", + not_found: "Esa copia ya no está.", + bad_hash: "La copia se dañó por el camino. Inténtalo de nuevo.", + cloud_error: "Algo salió mal con la nube. Inténtalo de nuevo en un momento.", + } as Record, + }, settings: { title: "Configuración", background: "En segundo plano", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 6d4822bd..474e233b 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -88,6 +88,8 @@ export const account: Messages["account"] = { cancel: "Cancelar", openFailed: "Não foi possível abrir a cópia", restoreFailed: "Não foi possível restaurar a cópia", + lightNote: + "Esta cópia tem as equipes, os bots, a memória deles e as rotinas. As conversas e os arquivos não estão nela.", reasons: { short_passphrase: "A senha precisa de ao menos 8 caracteres.", wrong_passphrase: "A senha está errada, ou o arquivo está danificado.", @@ -95,6 +97,63 @@ export const account: Messages["account"] = { newer_backup: "Esta cópia é de um Botloft mais novo. Atualize o Botloft antes.", } as Record, }, + cloud: { + title: "Conta e cópias na nuvem", + noCloud: "As cópias na nuvem ainda não estão configuradas neste Botloft.", + intro: + "A conta guarda cópias das suas equipes na nuvem, para você trazê-las de volta em outro computador. Só o seu e-mail e as cópias ficam guardados, e ninguém abre uma cópia sem a sua senha.", + email: "Seu e-mail", + sendLink: "Enviar o link", + sendingLink: "Enviando…", + linkSent: (email: string) => + `Enviamos um link para ${email}. Abra o e-mail, aperte o botão que está lá e volte aqui.`, + linkWaiting: + "Enviamos um link para você. Abra o e-mail, aperte o botão que está lá e volte aqui.", + resend: "Enviar de novo", + cancel: "Cancelar", + expired: "O link venceu. Peça outro.", + signedInAs: (email: string) => `Conectado como ${email}`, + signOut: "Sair", + usage: (used: string, quota: string) => `${used} de ${quota} usados`, + uploadTitle: "Enviar uma cópia para a nuvem", + uploadIntro: + "Sobem: suas equipes, os bots, a memória de cada um e as rotinas. As conversas e os arquivos ficam só neste computador. A cópia é trancada com uma senha que nunca sai dele.", + upload: "Enviar uma cópia agora", + uploading: "Enviando…", + uploaded: (size: string) => `Cópia enviada (${size}).`, + uploadFailed: "Não deu para enviar a cópia", + copiesTitle: "Cópias na nuvem", + noCopies: "Ainda não há cópias.", + copyFrom: (when: string, size: string) => `${when} · ${size}`, + restore: "Restaurar", + bringing: "Trazendo a cópia…", + removeCopy: "Apagar", + removeCopyTitle: "Apagar esta cópia?", + removeCopyText: + "Ela é apagada da nuvem. As cópias salvas no seu computador continuam como estão.", + removeAccount: "Apagar minha conta…", + removeAccountTitle: "Apagar sua conta?", + removeAccountText: + "Isto apaga a sua conta e todas as cópias guardadas nela, e não dá para desfazer. Enviamos um link para o seu e-mail, e nada é apagado até você apertar o botão de lá.", + removeAccountSend: "Enviar o link", + removeAccountSent: "Enviamos um link para o seu e-mail. Abra e aperte o botão para terminar.", + failed: "Algo deu errado", + reasons: { + no_server: "Este Botloft ainda não tem a nuvem configurada.", + offline: "Não deu para falar com a nuvem. Veja a conexão e tente de novo.", + bad_email: "Isso não parece um endereço de e-mail.", + rate_limited: "Tentativas demais. Espere um pouco e tente de novo.", + mail_failed: "Não deu para enviar o e-mail. Tente de novo daqui a pouco.", + not_signed_in: "Entre na conta para usar.", + signed_out: "Este computador saiu da conta. Entre de novo.", + short_passphrase: "A senha precisa de pelo menos 8 caracteres.", + too_big: "Esta cópia é grande demais para a nuvem.", + quota: "Não há mais espaço na sua conta. Apague uma cópia antiga antes.", + not_found: "Essa cópia não está mais lá.", + bad_hash: "A cópia se danificou no caminho. Tente de novo.", + cloud_error: "Algo deu errado com a nuvem. Tente de novo daqui a pouco.", + } as Record, + }, settings: { title: "Configurações", background: "Em segundo plano", diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 5191c4f4..3e3b954a 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -9,6 +9,7 @@ import { botHandlers } from "./fakeBots"; import { FakeBrowser } from "./fakeBrowser"; import { catalogHandlers } from "./fakeCatalog"; import { FakeChat } from "./fakeChat"; +import { cloudHandlers } from "./fakeCloud"; import { FakeConversation } from "./fakeConversation"; import { FakeCrewAccess } from "./fakeCrewAccess"; import { crewHandlers } from "./fakeCrews"; @@ -289,6 +290,7 @@ export class FakeBotloft implements BotloftApi { ...this.lesson.handlers(), ...usageHandlers(this), ...backupHandlers(this), + ...cloudHandlers(this), ...searchHandlers(this), }; } diff --git a/app/src/lib/fakeBackup.ts b/app/src/lib/fakeBackup.ts index a415784f..f8b5299f 100644 --- a/app/src/lib/fakeBackup.ts +++ b/app/src/lib/fakeBackup.ts @@ -3,7 +3,7 @@ // `FAKE_PASSPHRASE`. Nothing is sealed, written or restored. import type { FakeBotloft, Handlers } from "./fake"; -import { type BackupManifest, RpcErrorCode } from "./protocol.gen"; +import { type BackupManifest, type BackupScope, RpcErrorCode } from "./protocol.gen"; import { RpcError } from "./rpc"; type BackupMethods = Extract; @@ -15,11 +15,12 @@ function refused(reason: string, message: string): RpcError { return new RpcError(RpcErrorCode.validation, message, reason); } -function manifestOf(fake: FakeBotloft): BackupManifest { +function manifestOf(fake: FakeBotloft, scope: BackupScope = "full"): BackupManifest { return { format: 1, createdAt: fake.now, - version: "0.10.0", + version: "0.11.0", + scope, crews: [...fake.crews.values()].map((crew) => ({ name: crew.name, bots: [...fake.bots.values()] @@ -32,21 +33,22 @@ function manifestOf(fake: FakeBotloft): BackupManifest { export function backupHandlers(fake: FakeBotloft): Pick { return { - "backup.export": ({ passphrase }) => { + "backup.export": ({ passphrase, scope }) => { if ([...passphrase].length < 8) { throw refused("short_passphrase", "the passphrase must have at least 8 characters"); } return { path: "C:\\Users\\owner\\AppData\\Local\\Botloft\\exports\\botloft-2026-10-05-1430.botloft", size: 1_234_567, - manifest: manifestOf(fake), + manifest: manifestOf(fake, scope ?? "full"), }; }, - "backup.stage": ({ passphrase }) => { + "backup.stage": ({ path, passphrase }) => { if (passphrase !== FAKE_PASSPHRASE) { throw refused("wrong_passphrase", "the passphrase is wrong, or the file is damaged"); } - return manifestOf(fake); + // A copy that came down from the cloud is a light one (spec 14.2). + return manifestOf(fake, path.includes("cloud-download") ? "light" : "full"); }, "backup.confirm": () => null, "backup.cancel": () => null, diff --git a/app/src/lib/fakeCloud.test.ts b/app/src/lib/fakeCloud.test.ts new file mode 100644 index 00000000..90488b10 --- /dev/null +++ b/app/src/lib/fakeCloud.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { FakeBotloft } from "./fake"; +import { expireFakeCloudLink, openFakeCloudLink } from "./fakeCloud"; + +describe("the fake's account and cloud copies", () => { + it("signs in when the link is opened, keeps copies and signs out", async () => { + const fake = new FakeBotloft(); + const seen: string[] = []; + fake.subscribe((event) => seen.push(event.name)); + + expect((await fake.call("cloud.status")).signedIn).toBe(false); + await expect(fake.call("cloud.copies")).rejects.toMatchObject({ reason: "not_signed_in" }); + await expect(fake.call("cloud.signin", { email: "nobody" })).rejects.toMatchObject({ + reason: "bad_email", + }); + + expect((await fake.call("cloud.signin", { email: "Ana@Exemplo.com" })).wait).toBe(600); + expect((await fake.call("cloud.status")).pending).toBe(true); + openFakeCloudLink(fake); + const status = await fake.call("cloud.status"); + expect(status).toMatchObject({ signedIn: true, email: "ana@exemplo.com", pending: false }); + expect(seen).toContain("cloud.signed_in"); + + await expect(fake.call("cloud.upload", { passphrase: "short" })).rejects.toMatchObject({ + reason: "short_passphrase", + }); + const copy = await fake.call("cloud.upload", { passphrase: "correct horse" }); + expect((await fake.call("cloud.copies")).copies).toEqual([copy]); + expect((await fake.call("cloud.status")).used).toBe(copy.size); + expect(seen).toContain("cloud.progress"); + expect((await fake.call("cloud.download", { id: copy.id })).path).toContain("cloud-download"); + + await fake.call("cloud.delete", { id: copy.id }); + expect((await fake.call("cloud.copies")).copies).toHaveLength(0); + await fake.call("cloud.signout"); + expect((await fake.call("cloud.status")).signedIn).toBe(false); + }); + + it("keeps only the newest five copies, and a link can run out", async () => { + const fake = new FakeBotloft(); + await fake.call("cloud.signin", { email: "ana@exemplo.com" }); + expireFakeCloudLink(fake); + expect((await fake.call("cloud.status")).pending).toBe(false); + + await fake.call("cloud.signin", { email: "ana@exemplo.com" }); + openFakeCloudLink(fake); + for (let n = 0; n < 6; n++) { + await fake.call("cloud.upload", { passphrase: "correct horse" }); + } + const { copies } = await fake.call("cloud.copies"); + expect(copies).toHaveLength(5); + expect(copies[0]?.id).toBe("cpy_fake6"); + }); +}); diff --git a/app/src/lib/fakeCloud.ts b/app/src/lib/fakeCloud.ts new file mode 100644 index 00000000..24bddef9 --- /dev/null +++ b/app/src/lib/fakeCloud.ts @@ -0,0 +1,132 @@ +// The fake daemon's account and cloud copies (spec 27.5), all in memory. A +// sign-in waits until `openFakeCloudLink` plays the owner opening the link in +// the e-mail. Nothing leaves the page. + +import type { FakeBotloft, Handlers } from "./fake"; +import { type CloudCopy, type CloudStatus, RpcErrorCode } from "./protocol.gen"; +import { RpcError } from "./rpc"; + +type CloudMethods = Extract; + +const URL = "https://cloud.botloft.example"; +const QUOTA = 200 * 1024 * 1024; +const COPY_SIZE = 48_000; + +interface CloudState { + signedIn: boolean; + email: string | null; + /** The e-mail a waiting sign-in went to. */ + pending: string | null; + copies: CloudCopy[]; + next: number; +} + +const states = new WeakMap(); + +function stateOf(fake: FakeBotloft): CloudState { + let state = states.get(fake); + if (!state) { + state = { signedIn: false, email: null, pending: null, copies: [], next: 1 }; + states.set(fake, state); + } + return state; +} + +function refused(reason: string, message: string): RpcError { + return new RpcError(RpcErrorCode.validation, message, reason); +} + +function signedIn(state: CloudState): void { + if (!state.signedIn) throw refused("not_signed_in", "sign in to the account first"); +} + +/** The owner opens the link in the e-mail: the waiting sign-in finishes. */ +export function openFakeCloudLink(fake: FakeBotloft): void { + const state = stateOf(fake); + if (!state.pending) return; + state.signedIn = true; + state.email = state.pending; + state.pending = null; + fake.emit({ name: "cloud.signed_in", params: { email: state.email } }); +} + +/** The link ran out before the owner opened it. */ +export function expireFakeCloudLink(fake: FakeBotloft): void { + const state = stateOf(fake); + if (!state.pending) return; + state.pending = null; + fake.emit({ name: "cloud.signin_expired", params: null }); +} + +export function cloudHandlers(fake: FakeBotloft): Pick { + const state = stateOf(fake); + const used = () => state.copies.reduce((sum, copy) => sum + copy.size, 0); + return { + "cloud.status": () => { + const status: CloudStatus = { + url: URL, + signedIn: state.signedIn, + pending: state.pending !== null, + }; + if (state.signedIn && state.email) { + status.email = state.email; + status.used = used(); + status.quota = QUOTA; + } + return status; + }, + "cloud.signin": ({ email }) => { + if (!email.includes("@")) throw refused("bad_email", "that is not an e-mail address"); + state.pending = email.trim().toLowerCase(); + return { wait: 600 }; + }, + "cloud.signin_cancel": () => { + state.pending = null; + return null; + }, + "cloud.signout": () => { + state.signedIn = false; + state.email = null; + state.pending = null; + return null; + }, + "cloud.upload": ({ passphrase }) => { + signedIn(state); + if ([...passphrase].length < 8) { + throw refused("short_passphrase", "the passphrase must have at least 8 characters"); + } + const copy: CloudCopy = { + id: `cpy_fake${state.next++}`, + size: COPY_SIZE, + created: fake.now, + }; + // Only the newest five are kept (spec 27.2). + state.copies = [copy, ...state.copies].slice(0, 5); + fake.emit({ + name: "cloud.progress", + params: { direction: "upload", sent: COPY_SIZE, total: COPY_SIZE }, + }); + return copy; + }, + "cloud.copies": () => { + signedIn(state); + return { copies: [...state.copies] }; + }, + "cloud.download": ({ id }) => { + signedIn(state); + if (!state.copies.some((copy) => copy.id === id)) { + throw refused("not_found", "there is no such copy"); + } + return { path: "C:\\Users\\owner\\AppData\\Local\\Botloft\\cloud-download\\copy.botloft" }; + }, + "cloud.delete": ({ id }) => { + signedIn(state); + state.copies = state.copies.filter((copy) => copy.id !== id); + return null; + }, + "cloud.delete_account": () => { + signedIn(state); + return null; + }, + }; +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 02652fca..2718da46 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1163,6 +1163,11 @@ export type CatalogAddParams = { crewId: CrewId, templateId: string, */ name: string, role: string, }; +/** + * How much a backup carries (spec 14.2). + */ +export type BackupScope = "full" | "light"; + export type BackupCrew = { name: string, /** * The crew's bots, archived ones aside. @@ -1189,9 +1194,17 @@ createdAt: number, /** * The Botloft version that made it. */ -version: string, crews: Array, }; +version: string, +/** + * Missing in a manifest from before the scope existed: `full`. + */ +scope: BackupScope, crews: Array, }; -export type BackupExportParams = { passphrase: string, }; +export type BackupExportParams = { passphrase: string, +/** + * `full` when missing. + */ +scope?: BackupScope, }; /** * A backup written and ready for the owner to save elsewhere. @@ -1207,6 +1220,90 @@ size: number, manifest: BackupManifest, }; */ export type BackupStageParams = { path: string, passphrase: string, }; +/** + * `cloud.status`: where the owner stands with the account. + */ +export type CloudStatus = { +/** + * The server the app talks to; empty when none is set up. + */ +url: string, signedIn: boolean, email?: string, +/** + * Bytes the account's copies take, when the server answered. + */ +used?: number, +/** + * Bytes the account may keep, when the server answered. + */ +quota?: number, +/** + * A sign-in waits for the owner to open the link in the e-mail. + */ +pending: boolean, }; + +/** + * `cloud.signin`: the e-mail the link goes to. + */ +export type CloudSigninParams = { email: string, +/** + * The app's language (`en`, `pt-BR`, `es`), for the e-mail. + */ +locale?: string, }; + +export type CloudSigninStarted = { +/** + * Seconds the link in the e-mail works. + */ +wait: number, }; + +/** + * `cloud.upload`: the passphrase seals the copy, and never leaves here. + */ +export type CloudUploadParams = { passphrase: string, }; + +/** + * A copy the account keeps. + */ +export type CloudCopy = { id: string, +/** + * Bytes. + */ +size: number, +/** + * Unix time in milliseconds. + */ +created: number, }; + +/** + * `cloud.copies`, newest first. + */ +export type CloudCopies = { copies: Array, }; + +/** + * `cloud.download` and `cloud.delete`: which copy. + */ +export type CloudCopyParams = { id: string, }; + +/** + * A copy fetched, where the app goes on with `backup.stage`. + */ +export type CloudDownloaded = { path: string, }; + +/** + * `cloud.signed_in`: the owner opened the link and this computer is in. + */ +export type CloudSignedIn = { email: string, }; + +/** + * Which way the bytes of a [`CloudProgress`] go. + */ +export type CloudDirection = "upload" | "download"; + +/** + * `cloud.progress`: how much of a copy went up or came down. + */ +export type CloudProgress = { direction: CloudDirection, sent: number, total: number, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -1917,6 +2014,15 @@ export interface RpcMethods { "backup.stage": { params: BackupStageParams; result: BackupManifest }; "backup.confirm": { params: undefined; result: null }; "backup.cancel": { params: undefined; result: null }; + "cloud.status": { params: undefined; result: CloudStatus }; + "cloud.signin": { params: CloudSigninParams; result: CloudSigninStarted }; + "cloud.signin_cancel": { params: undefined; result: null }; + "cloud.signout": { params: undefined; result: null }; + "cloud.upload": { params: CloudUploadParams; result: CloudCopy }; + "cloud.copies": { params: undefined; result: CloudCopies }; + "cloud.download": { params: CloudCopyParams; result: CloudDownloaded }; + "cloud.delete": { params: CloudCopyParams; result: null }; + "cloud.delete_account": { params: undefined; result: null }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; @@ -1972,6 +2078,9 @@ export interface RpcNotifications { "desktop.away": Array; "mcp.servers": McpOverview; "bot.mcp": BotMcp; + "cloud.signed_in": CloudSignedIn; + "cloud.progress": CloudProgress; + "cloud.signin_expired": null; "browser.changed": BrowserState; "browser.action": BrowserAction; "browser.frame": BrowserFrame; diff --git a/crates/botloft-cloud/Cargo.toml b/crates/botloft-cloud/Cargo.toml new file mode 100644 index 00000000..004e3062 --- /dev/null +++ b/crates/botloft-cloud/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "botloft-cloud" +description = "Botloft cloud server: accounts and sealed backup copies" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0.104" +axum = "0.8.9" +base64 = "0.23.1" +bytes = "1.12.1" +clap = { version = "4.6.7", features = ["derive"] } +futures-util = "0.3.34" +getrandom = "0.4.3" +hex = "0.4.3" +lettre = { version = "0.11.23", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls", "ring", "webpki-roots"] } +object_store = { version = "0.14.2", default-features = false, features = ["aws", "fs", "tokio"] } +rusqlite = { version = "0.40.2", features = ["bundled"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.11.0" +thiserror = "2.0.21" +tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync", "fs", "io-util"] } +toml = "1.1.6" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3.27.0" +tower = { version = "0.5.3", features = ["util"] } diff --git a/crates/botloft-cloud/assets/flame.png b/crates/botloft-cloud/assets/flame.png new file mode 100644 index 00000000..3bd46d2a Binary files /dev/null and b/crates/botloft-cloud/assets/flame.png differ diff --git a/crates/botloft-cloud/migrations/0001_accounts.sql b/crates/botloft-cloud/migrations/0001_accounts.sql new file mode 100644 index 00000000..017fbdb0 --- /dev/null +++ b/crates/botloft-cloud/migrations/0001_accounts.sql @@ -0,0 +1,39 @@ +-- One row per e-mail address; the account is born when the first sign-in link +-- is confirmed (spec 27.3). +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); + +-- A computer or phone that signed in. Only the SHA-256 of its token is kept. +CREATE TABLE devices ( + id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL +); +CREATE INDEX devices_by_account ON devices(account_id); + +-- A sign-in waiting for the link. `account_id` is set when the link is +-- confirmed; the app's next poll turns it into a device and the row goes away. +CREATE TABLE logins ( + request_hash TEXT PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + device_name TEXT NOT NULL, + locale TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE +); + +-- What the rate limits count (27.3): `kind` and a hashed `key`. +CREATE TABLE attempts ( + kind TEXT NOT NULL, + key TEXT NOT NULL, + at INTEGER NOT NULL +); +CREATE INDEX attempts_by_key ON attempts(kind, key, at); diff --git a/crates/botloft-cloud/migrations/0002_copies.sql b/crates/botloft-cloud/migrations/0002_copies.sql new file mode 100644 index 00000000..b6ef4b95 --- /dev/null +++ b/crates/botloft-cloud/migrations/0002_copies.sql @@ -0,0 +1,19 @@ +-- The sealed copies an account keeps (spec 27.4). The bytes live in the +-- CopyStore under `/`; this is only what the server knows. +CREATE TABLE copies ( + id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX copies_by_account ON copies(account_id, created_at); + +-- A request to delete an account, waiting for the owner to press the button +-- in the e-mail (spec 27.3). +CREATE TABLE deletions ( + code_hash TEXT PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + locale TEXT NOT NULL, + expires_at INTEGER NOT NULL +); diff --git a/crates/botloft-cloud/src/accounts.rs b/crates/botloft-cloud/src/accounts.rs new file mode 100644 index 00000000..f5cb9724 --- /dev/null +++ b/crates/botloft-cloud/src/accounts.rs @@ -0,0 +1,243 @@ +//! Queries for logins, accounts and devices (spec 27.3). + +use rusqlite::{Connection, OptionalExtension, params}; +use serde::Serialize; + +/// How long a sign-in link and its request live. +pub const LOGIN_TTL_MS: i64 = 600_000; + +pub struct NewLogin<'a> { + pub request_hash: &'a str, + pub code_hash: &'a str, + pub email: &'a str, + pub device_name: &'a str, + pub locale: &'a str, +} + +pub fn insert_login(conn: &Connection, login: &NewLogin, now: i64) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO logins (request_hash, code_hash, email, device_name, locale, created_at, \ + expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + login.request_hash, + login.code_hash, + login.email, + login.device_name, + login.locale, + now, + now + LOGIN_TTL_MS + ], + )?; + Ok(()) +} + +pub fn drop_login(conn: &Connection, request_hash: &str) -> rusqlite::Result<()> { + conn.execute("DELETE FROM logins WHERE request_hash = ?1", [request_hash])?; + Ok(()) +} + +/// What the page behind a link shows before the owner confirms. +pub struct Waiting { + pub device_name: String, + pub locale: String, +} + +/// A link that is still good: not used, not expired. +pub fn peek_login( + conn: &Connection, + code_hash: &str, + now: i64, +) -> rusqlite::Result> { + conn.query_row( + "SELECT device_name, locale FROM logins \ + WHERE code_hash = ?1 AND expires_at > ?2 AND account_id IS NULL", + params![code_hash, now], + |row| { + Ok(Waiting { + device_name: row.get(0)?, + locale: row.get(1)?, + }) + }, + ) + .optional() +} + +/// The link was confirmed: the account exists now (born here, if new) and the +/// request waits for the app to collect its device. False if the link is bad. +pub fn confirm_login(conn: &mut Connection, code_hash: &str, now: i64) -> rusqlite::Result { + let tx = conn.transaction()?; + let email: Option = tx + .query_row( + "SELECT email FROM logins \ + WHERE code_hash = ?1 AND expires_at > ?2 AND account_id IS NULL", + params![code_hash, now], + |row| row.get(0), + ) + .optional()?; + let Some(email) = email else { + return Ok(false); + }; + tx.execute( + "INSERT INTO accounts (email, created_at) VALUES (?1, ?2) ON CONFLICT(email) DO NOTHING", + params![email, now], + )?; + let account: i64 = tx.query_row( + "SELECT id FROM accounts WHERE email = ?1", + [&email], + |row| row.get(0), + )?; + tx.execute( + "UPDATE logins SET account_id = ?1 WHERE code_hash = ?2", + params![account, code_hash], + )?; + tx.commit()?; + Ok(true) +} + +/// What a poll of a request finds. +pub enum Request { + /// No such request, or it ran out. + Gone, + Pending, + Approved(Approved), +} + +pub struct Approved { + pub account_id: i64, + pub email: String, + pub device_name: String, +} + +pub fn read_request(conn: &Connection, request_hash: &str, now: i64) -> rusqlite::Result { + let row = conn + .query_row( + "SELECT logins.expires_at, logins.account_id, logins.email, logins.device_name \ + FROM logins WHERE request_hash = ?1", + [request_hash], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional()?; + Ok(match row { + Some((expires, _, _, _)) if expires <= now => Request::Gone, + Some((_, None, _, _)) => Request::Pending, + Some((_, Some(account_id), email, device_name)) => Request::Approved(Approved { + account_id, + email, + device_name, + }), + None => Request::Gone, + }) +} + +/// The app collected its sign-in: a device with the hash of its new token, and +/// the request is gone, so the token is handed out once. +pub fn finish_login( + conn: &mut Connection, + request_hash: &str, + approved: &Approved, + device_id: &str, + token_hash: &str, + now: i64, +) -> rusqlite::Result { + let tx = conn.transaction()?; + let removed = tx.execute( + "DELETE FROM logins WHERE request_hash = ?1 AND account_id IS NOT NULL", + [request_hash], + )?; + if removed == 0 { + return Ok(false); + } + tx.execute( + "INSERT INTO devices (id, account_id, name, token_hash, created_at, last_used_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + params![ + device_id, + approved.account_id, + approved.device_name, + token_hash, + now + ], + )?; + tx.commit()?; + Ok(true) +} + +/// Who a token belongs to. +pub struct Who { + pub account_id: i64, + pub device_id: String, + pub email: String, +} + +/// Looks the token up and notes that the device was used, at most once a minute. +pub fn who_is(conn: &Connection, token_hash: &str, now: i64) -> rusqlite::Result> { + let who = conn + .query_row( + "SELECT devices.account_id, devices.id, accounts.email FROM devices \ + JOIN accounts ON accounts.id = devices.account_id WHERE devices.token_hash = ?1", + [token_hash], + |row| { + Ok(Who { + account_id: row.get(0)?, + device_id: row.get(1)?, + email: row.get(2)?, + }) + }, + ) + .optional()?; + if let Some(who) = &who { + conn.execute( + "UPDATE devices SET last_used_at = ?1 WHERE id = ?2 AND last_used_at < ?1 - 60000", + params![now, who.device_id], + )?; + } + Ok(who) +} + +#[derive(Serialize)] +pub struct Device { + pub id: String, + pub name: String, + pub created_at: i64, + pub last_used_at: i64, +} + +pub fn devices(conn: &Connection, account_id: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, name, created_at, last_used_at FROM devices \ + WHERE account_id = ?1 ORDER BY created_at", + )?; + stmt.query_map([account_id], |row| { + Ok(Device { + id: row.get(0)?, + name: row.get(1)?, + created_at: row.get(2)?, + last_used_at: row.get(3)?, + }) + })? + .collect() +} + +/// False when the account has no such device. +pub fn delete_device(conn: &Connection, account_id: i64, id: &str) -> rusqlite::Result { + let removed = conn.execute( + "DELETE FROM devices WHERE id = ?1 AND account_id = ?2", + params![id, account_id], + )?; + Ok(removed > 0) +} + +/// Drops what ran out: links and the old counts of the rate limits. +pub fn purge(conn: &Connection, now: i64) -> rusqlite::Result<()> { + conn.execute("DELETE FROM logins WHERE expires_at <= ?1", [now])?; + conn.execute("DELETE FROM attempts WHERE at <= ?1", [now - 3_600_000])?; + conn.execute("DELETE FROM deletions WHERE expires_at <= ?1", [now])?; + Ok(()) +} diff --git a/crates/botloft-cloud/src/app.rs b/crates/botloft-cloud/src/app.rs new file mode 100644 index 00000000..104dc8f2 --- /dev/null +++ b/crates/botloft-cloud/src/app.rs @@ -0,0 +1,45 @@ +//! The routes (spec 27.3, 27.4) and what every handler shares. + +use std::sync::Arc; + +use axum::Router; +use axum::routing::{delete, get, post}; + +use crate::clock::Clock; +use crate::config::Config; +use crate::db::Db; +use crate::mailer::Mailer; +use crate::store::CopyStore; +use crate::{copies, login, removal, session}; + +#[derive(Clone)] +pub struct AppState { + pub db: Db, + pub mailer: Arc, + pub clock: Clock, + pub config: Arc, + pub store: CopyStore, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(|| async { "ok" })) + .route("/brand/flame.png", get(crate::html::flame)) + .route("/v1/login", post(login::ask)) + .route( + "/v1/login/confirm", + get(login::confirm_page).post(login::confirm), + ) + .route("/v1/login/{request}", get(login::poll)) + .route("/v1/me", get(session::me)) + .route("/v1/logout", post(session::logout)) + .route("/v1/devices/{id}", delete(session::remove_device)) + .route("/v1/copies", get(copies::list).put(copies::put)) + .route("/v1/copies/{id}", get(copies::get).delete(copies::remove)) + .route("/v1/account/delete", post(removal::ask)) + .route( + "/v1/account/delete/confirm", + get(removal::confirm_page).post(removal::confirm), + ) + .with_state(state) +} diff --git a/crates/botloft-cloud/src/clock.rs b/crates/botloft-cloud/src/clock.rs new file mode 100644 index 00000000..1260ae8c --- /dev/null +++ b/crates/botloft-cloud/src/clock.rs @@ -0,0 +1,23 @@ +//! The time the server goes by, in ms Unix. Tests move it forward instead of +//! waiting for a link to expire. + +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Default)] +pub struct Clock(Arc); + +impl Clock { + pub fn now(&self) -> i64 { + let real = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_millis() as i64); + real + self.0.load(Ordering::Relaxed) + } + + /// Jumps `ms` ahead, for tests. + pub fn advance(&self, ms: i64) { + self.0.fetch_add(ms, Ordering::Relaxed); + } +} diff --git a/crates/botloft-cloud/src/config.rs b/crates/botloft-cloud/src/config.rs new file mode 100644 index 00000000..d1d62ac4 --- /dev/null +++ b/crates/botloft-cloud/src/config.rs @@ -0,0 +1,340 @@ +//! `cloud.toml` (spec 27.2). + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default = "default_listen")] + pub listen: SocketAddr, + /// The public address, which goes in the links of the e-mails. + pub public_url: String, + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, + #[serde(default = "default_quota")] + pub quota_bytes: u64, + #[serde(default = "default_max_copy")] + pub max_copy_bytes: u64, + #[serde(default = "default_keep")] + pub keep: u32, + /// A proxy in front sets `X-Forwarded-For`; its last value is the client. + #[serde(default = "default_true")] + pub behind_proxy: bool, + /// A header that holds the client's address, such as `CF-Connecting-IP` + /// behind Cloudflare. Trust it only when nothing but that proxy can reach + /// the server, because anyone else could write it. Wins over + /// `X-Forwarded-For`. + #[serde(default)] + pub client_ip_header: Option, + #[serde(default)] + pub storage: Storage, + /// Where the copies go when `storage = "bucket"`. + pub bucket: Option, + pub smtp: Smtp, +} + +/// Where the bytes of the copies are kept (spec 27.2). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Storage { + /// In `data_dir`. + #[default] + Disk, + /// An S3-compatible bucket, such as Cloudflare R2. + Bucket, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Bucket { + pub endpoint: String, + pub name: String, + #[serde(default = "default_region")] + pub region: String, + pub access_key_id: String, + /// A file with the secret alone, so it never sits in `cloud.toml`. The + /// variable `BOTLOFT_CLOUD_BUCKET_SECRET` does the same, and wins. + #[serde(default)] + pub secret_file: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Smtp { + pub host: String, + #[serde(default = "default_port")] + pub port: u16, + pub user: String, + /// A file with the password alone, so it never sits in this one. The + /// variable `BOTLOFT_CLOUD_SMTP_PASSWORD` does the same, and wins. + #[serde(default)] + pub password_file: Option, + pub from: String, +} + +/// The whole configuration as text, for a platform that keeps settings +/// (Coolify, for one) rather than files. +pub const CONFIG_VAR: &str = "BOTLOFT_CLOUD_CONFIG"; + +/// The variables that can carry a secret, for the same platforms. +pub const SMTP_PASSWORD_VAR: &str = "BOTLOFT_CLOUD_SMTP_PASSWORD"; +pub const BUCKET_SECRET_VAR: &str = "BOTLOFT_CLOUD_BUCKET_SECRET"; + +/// A secret from the variable `var` if it is set, else from `file`. `what` +/// names it in the error, which never holds the value. +pub fn secret(file: Option<&Path>, var: &str, what: &str) -> anyhow::Result { + secret_from(file, std::env::var(var).ok(), var, what) +} + +fn secret_from( + file: Option<&Path>, + value: Option, + var: &str, + what: &str, +) -> anyhow::Result { + if let Some(value) = value + .map(|value| value.trim().to_owned()) + .filter(|v| !v.is_empty()) + { + return Ok(value); + } + match file { + Some(file) => std::fs::read_to_string(file) + .map(|text| text.trim().to_owned()) + .map_err(|err| anyhow::anyhow!("cannot read the {what} file: {}", err.kind())), + None => anyhow::bail!("the {what} is missing: set {var}, or a file in cloud.toml"), + } +} + +fn default_listen() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 8787)) +} +fn default_data_dir() -> PathBuf { + PathBuf::from("./data") +} +fn default_quota() -> u64 { + 200 << 20 +} +fn default_max_copy() -> u64 { + 50 << 20 +} +fn default_keep() -> u32 { + 5 +} +fn default_true() -> bool { + true +} +fn default_region() -> String { + "auto".to_owned() +} +fn default_port() -> u16 { + 587 +} + +impl Config { + /// The configuration: the text in `BOTLOFT_CLOUD_CONFIG` if that is set + /// (for a platform that keeps settings, not files), else the file. + pub fn load(path: &Path) -> anyhow::Result { + match std::env::var(CONFIG_VAR) + .ok() + .filter(|text| !text.trim().is_empty()) + { + Some(text) => Self::parse(&text, CONFIG_VAR), + None => { + let text = std::fs::read_to_string(path) + .map_err(|err| anyhow::anyhow!("cannot read {}: {err}", path.display()))?; + Self::parse(&text, &path.display().to_string()) + } + } + } + + /// `text` as a configuration; `from` names where it came from. + pub fn parse(text: &str, from: &str) -> anyhow::Result { + let config: Self = + toml::from_str(text).map_err(|err| anyhow::anyhow!("{from} is not valid: {err}"))?; + if !config.public_url.starts_with("https://") + && !config.public_url.starts_with("http://127.0.0.1") + { + anyhow::bail!("public_url must be https (or http://127.0.0.1 for tests)"); + } + if let Some(name) = &config.client_ip_header + && axum::http::HeaderName::from_bytes(name.trim().as_bytes()).is_err() + { + anyhow::bail!("client_ip_header is not a header name"); + } + if config.storage == Storage::Bucket && config.bucket.is_none() { + anyhow::bail!("storage = \"bucket\" needs a [bucket] section"); + } + Ok(config) + } + + /// The link base, without a closing slash. + pub fn base_url(&self) -> &str { + self.public_url.trim_end_matches('/') + } + + /// A config for tests: nothing on disk and a mailer that never connects. + pub fn for_tests() -> Self { + Self { + listen: default_listen(), + public_url: "http://127.0.0.1:8787".to_owned(), + data_dir: PathBuf::from("."), + quota_bytes: default_quota(), + max_copy_bytes: default_max_copy(), + keep: default_keep(), + behind_proxy: true, + client_ip_header: None, + storage: Storage::Disk, + bucket: None, + smtp: Smtp { + host: "localhost".to_owned(), + port: 587, + user: String::new(), + password_file: None, + from: "Botloft ".to_owned(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(text: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("dir"); + let path = dir.path().join("cloud.toml"); + std::fs::write(&path, text).expect("write"); + (dir, path) + } + + #[test] + fn a_secret_comes_from_the_variable_first_then_the_file() { + let (_dir, path) = write(" from-the-file \n"); + let var = Some(" from-the-variable ".to_owned()); + let said = |file: Option<&Path>, value: Option| secret_from(file, value, "V", "x"); + assert_eq!( + said(Some(&path), var.clone()).expect("var"), + "from-the-variable" + ); + assert_eq!(said(Some(&path), None).expect("file"), "from-the-file"); + // An empty variable counts as not set. + assert_eq!( + said(Some(&path), Some(" ".to_owned())).expect("file"), + "from-the-file" + ); + assert_eq!(said(None, var).expect("var alone"), "from-the-variable"); + let nothing = said(None, None).expect_err("nothing"); + assert!(nothing.to_string().contains("set V"), "{nothing}"); + let gone = said(Some(&path.with_extension("none")), None).expect_err("missing file"); + assert!( + gone.to_string().contains("cannot read the x file"), + "{gone}" + ); + } + + #[test] + fn a_small_file_gets_the_defaults() { + let (_dir, path) = write( + "public_url = \"https://cloud.example.org/\"\n[smtp]\nhost = \"mail\"\nuser = \"u\"\n\ + password_file = \"p\"\nfrom = \"Botloft \"\n", + ); + let config = Config::load(&path).expect("load"); + assert_eq!(config.keep, 5); + assert_eq!(config.quota_bytes, 200 << 20); + assert_eq!(config.max_copy_bytes, 50 << 20); + assert_eq!(config.smtp.port, 587); + assert_eq!(config.base_url(), "https://cloud.example.org"); + } + + #[test] + fn a_bucket_needs_its_section() { + let (_dir, path) = write( + "public_url = \"https://cloud.example.org\" +storage = \"bucket\" +[smtp] +host = \"m\" + user = \"u\" +password_file = \"p\" +from = \"a@b.c\" +", + ); + assert!(Config::load(&path).is_err()); + let (_dir, path) = write( + "public_url = \"https://cloud.example.org\" +storage = \"bucket\" +[bucket] + endpoint = \"https://x.r2.cloudflarestorage.com\" +name = \"b\" +access_key_id = \"k\" + secret_file = \"s\" +[smtp] +host = \"m\" +user = \"u\" +password_file = \"p\" + from = \"a@b.c\" +", + ); + let config = Config::load(&path).expect("load"); + assert_eq!(config.bucket.expect("bucket").region, "auto"); + } + + #[test] + fn the_whole_config_can_be_text() { + let text = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../deploy/cloud/cloud.example.toml"), + ) + .expect("the example"); + let config = Config::parse(&text, "text").expect("parses"); + assert_eq!(config.keep, 5); + let broken = Config::parse("public_url = 3", "the variable").expect_err("not valid"); + assert!( + broken.to_string().starts_with("the variable is not valid"), + "{broken}" + ); + } + + /// The file the deploy folder ships is a config the server accepts. + #[test] + fn the_example_config_is_valid() { + let path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../deploy/cloud/cloud.example.toml"); + let config = Config::load(&path).expect("the example loads"); + assert_eq!(config.storage, Storage::Disk); + assert_eq!(config.quota_bytes, 200 << 20); + assert_eq!(config.max_copy_bytes, 50 << 20); + assert_eq!(config.keep, 5); + assert!(config.behind_proxy); + } + + #[test] + fn the_client_ip_header_must_be_a_header_name() { + let with = |name: &str| { + let (_dir, path) = write(&format!( + "public_url = \"https://cloud.example.org\"\nclient_ip_header = \"{name}\"\n\ + [smtp]\nhost = \"m\"\nuser = \"u\"\nfrom = \"a@b.c\"\n" + )); + Config::load(&path) + }; + assert_eq!( + with("CF-Connecting-IP") + .expect("a header") + .client_ip_header + .as_deref(), + Some("CF-Connecting-IP") + ); + assert!(with("not a header").is_err()); + } + + #[test] + fn an_http_address_is_refused() { + let (_dir, path) = write( + "public_url = \"http://cloud.example.org\"\n[smtp]\nhost = \"m\"\nuser = \"u\"\n\ + password_file = \"p\"\nfrom = \"a@b.c\"\n", + ); + assert!(Config::load(&path).is_err()); + } +} diff --git a/crates/botloft-cloud/src/copies.rs b/crates/botloft-cloud/src/copies.rs new file mode 100644 index 00000000..c530b724 --- /dev/null +++ b/crates/botloft-cloud/src/copies.rs @@ -0,0 +1,237 @@ +//! The copies (spec 27.4): send one, list them, download (with `Range`), +//! delete. The server never opens a copy; it keeps what the owner sealed. + +use std::path::PathBuf; + +use axum::Json; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::Response; +use futures_util::StreamExt; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; + +use crate::app::AppState; +use crate::copy_rows::{self, Copy, blob_key}; +use crate::error::ApiError; +use crate::session::Authed; +use crate::tokens; + +/// The upload's temp file goes away however the request ends. +struct Temp(PathBuf); + +impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// `PUT /v1/copies` +pub async fn put( + State(state): State, + Authed(who): Authed, + headers: HeaderMap, + body: Body, +) -> Result<(StatusCode, Json), ApiError> { + let declared = header_text(&headers, CONTENT_LENGTH.as_str()) + .and_then(|text| text.parse::().ok()) + .ok_or_else(ApiError::length_required)?; + let wanted = header_text(&headers, "x-botloft-sha256") + .map(str::to_ascii_lowercase) + .filter(|hash| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())) + .ok_or_else(ApiError::bad_hash)?; + if declared == 0 { + return Err(ApiError::bad_length()); + } + if declared > state.config.max_copy_bytes { + return Err(ApiError::too_big()); + } + let (keep, quota) = (state.config.keep, state.config.quota_bytes); + let room = state + .db + .run(|conn| copy_rows::fits(conn, who.account_id, keep, declared, quota))?; + if !room { + return Err(ApiError::quota()); + } + + // To a temp file first, checking size and hash as it comes: a cut or a + // wrong upload never reaches the store. + let folder = state.config.data_dir.join("uploads"); + tokio::fs::create_dir_all(&folder).await.map_err(io)?; + let temp = Temp(folder.join(format!("{}.part", &tokens::random()?[..20]))); + let mut file = tokio::fs::File::create(&temp.0).await.map_err(io)?; + let (mut hasher, mut total) = (Sha256::new(), 0u64); + let mut stream = body.into_data_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| ApiError::bad_length())?; + total += chunk.len() as u64; + if total > declared { + return Err(ApiError::bad_length()); + } + hasher.update(&chunk); + file.write_all(&chunk).await.map_err(io)?; + } + file.flush().await.map_err(io)?; + drop(file); + if total != declared { + return Err(ApiError::bad_length()); + } + if hex::encode(hasher.finalize()) != wanted { + return Err(ApiError::bad_hash()); + } + + let id = format!("cpy_{}", &tokens::random()?[..16]); + let key = blob_key(who.account_id, &id); + state.store.put_file(&key, &temp.0, declared).await?; + let copy = Copy { + id, + size: declared as i64, + sha256: wanted, + created_at: state.clock.now(), + }; + let pruned = state + .db + .run(|conn| copy_rows::add_and_prune(conn, who.account_id, ©, keep)); + let pruned = match pruned { + Ok(pruned) => pruned, + Err(err) => { + state.store.delete(&key).await; + return Err(err); + } + }; + // Only now, with the new one safe, do the oldest go. + for old in pruned { + state.store.delete(&old).await; + } + Ok(( + StatusCode::CREATED, + Json(json!({ "id": copy.id, "size": copy.size, "created": copy.created_at })), + )) +} + +/// `GET /v1/copies`, newest first. +pub async fn list( + State(state): State, + Authed(who): Authed, +) -> Result, ApiError> { + let copies = state.db.run(|conn| copy_rows::list(conn, who.account_id))?; + let copies: Vec = copies + .into_iter() + .map(|copy| json!({ "id": copy.id, "size": copy.size, "created": copy.created_at })) + .collect(); + Ok(Json(json!({ "copies": copies }))) +} + +/// `GET /v1/copies/`, all of it or one `Range: bytes=`. +pub async fn get( + State(state): State, + Authed(who): Authed, + Path(id): Path, + headers: HeaderMap, +) -> Result { + let copy = state + .db + .run(|conn| copy_rows::find(conn, who.account_id, &id))? + .ok_or_else(ApiError::not_found)?; + let size = copy.size as u64; + let wanted = match header_text(&headers, RANGE.as_str()) { + Some(text) => Some(parse_range(text, size).ok_or_else(ApiError::range_not_satisfiable)?), + None => None, + }; + let (first, last) = wanted.unwrap_or((0, size - 1)); + let range = wanted.map(|(first, last)| first..last + 1); + let stream = state + .store + .read(&blob_key(who.account_id, ©.id), range) + .await?; + + let mut response = Response::new(Body::from_stream(stream)); + let headers = response.headers_mut(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + headers.insert(CONTENT_LENGTH, HeaderValue::from(last - first + 1)); + if let Ok(hash) = HeaderValue::from_str(©.sha256) { + headers.insert("x-botloft-sha256", hash); + } + if wanted.is_some() { + *response.status_mut() = StatusCode::PARTIAL_CONTENT; + if let Ok(value) = HeaderValue::from_str(&format!("bytes {first}-{last}/{}", copy.size)) { + response.headers_mut().insert(CONTENT_RANGE, value); + } + } + Ok(response) +} + +/// `DELETE /v1/copies/` +pub async fn remove( + State(state): State, + Authed(who): Authed, + Path(id): Path, +) -> Result { + let removed = state + .db + .run(|conn| copy_rows::delete(conn, who.account_id, &id))?; + if !removed { + return Err(ApiError::not_found()); + } + state.store.delete(&blob_key(who.account_id, &id)).await; + Ok(StatusCode::NO_CONTENT) +} + +fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +fn io(err: std::io::Error) -> ApiError { + tracing::error!("upload file failed: {}", err.kind()); + ApiError::internal() +} + +/// One range of `bytes=a-b`, `a-` or `-n`, as inclusive `(first, last)` inside +/// `size` bytes. `None` for anything else or outside the copy. +fn parse_range(text: &str, size: u64) -> Option<(u64, u64)> { + let spec = text.trim().strip_prefix("bytes=")?; + if spec.contains(',') { + return None; + } + let (from, to) = spec.split_once('-')?; + let (first, last) = match (from.trim(), to.trim()) { + ("", n) => { + let n: u64 = n.parse().ok().filter(|n| *n > 0)?; + (size.saturating_sub(n), size - 1) + } + (a, "") => (a.parse().ok()?, size - 1), + (a, b) => (a.parse().ok()?, b.parse::().ok()?.min(size - 1)), + }; + (first <= last && first < size).then_some((first, last)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranges_are_read_inside_the_copy() { + assert_eq!(parse_range("bytes=0-9", 100), Some((0, 9))); + assert_eq!(parse_range("bytes=90-", 100), Some((90, 99))); + assert_eq!(parse_range("bytes=-10", 100), Some((90, 99))); + assert_eq!(parse_range("bytes=50-5000", 100), Some((50, 99))); + assert_eq!(parse_range("bytes=-500", 100), Some((0, 99))); + for bad in [ + "bytes=100-", + "bytes=5-2", + "bytes=0-1,4-5", + "items=0-1", + "bytes=-0", + "bytes=a-b", + ] { + assert_eq!(parse_range(bad, 100), None, "{bad}"); + } + } +} diff --git a/crates/botloft-cloud/src/copy_rows.rs b/crates/botloft-cloud/src/copy_rows.rs new file mode 100644 index 00000000..78a27f86 --- /dev/null +++ b/crates/botloft-cloud/src/copy_rows.rs @@ -0,0 +1,111 @@ +//! Queries for the copies (spec 27.4). + +use rusqlite::{Connection, OptionalExtension, params}; + +pub struct Copy { + pub id: String, + pub size: i64, + pub sha256: String, + pub created_at: i64, +} + +/// Where the bytes of a copy are kept. +pub fn blob_key(account_id: i64, id: &str) -> String { + format!("{account_id}/{id}") +} + +pub fn used(conn: &Connection, account_id: i64) -> rusqlite::Result { + conn.query_row( + "SELECT COALESCE(SUM(size), 0) FROM copies WHERE account_id = ?1", + [account_id], + |row| row.get(0), + ) +} + +/// Newest first. +pub fn list(conn: &Connection, account_id: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, size, sha256, created_at FROM copies WHERE account_id = ?1 \ + ORDER BY created_at DESC, rowid DESC", + )?; + stmt.query_map([account_id], row)?.collect() +} + +fn row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Copy { + id: row.get(0)?, + size: row.get(1)?, + sha256: row.get(2)?, + created_at: row.get(3)?, + }) +} + +pub fn find(conn: &Connection, account_id: i64, id: &str) -> rusqlite::Result> { + conn.query_row( + "SELECT id, size, sha256, created_at FROM copies WHERE id = ?1 AND account_id = ?2", + params![id, account_id], + row, + ) + .optional() +} + +/// False when the account has no such copy. +pub fn delete(conn: &Connection, account_id: i64, id: &str) -> rusqlite::Result { + let removed = conn.execute( + "DELETE FROM copies WHERE id = ?1 AND account_id = ?2", + params![id, account_id], + )?; + Ok(removed > 0) +} + +/// Whether a copy of `size` bytes fits: what the account holds, less the +/// oldest copies that the new one pushes past `keep`, plus the new one. +pub fn fits( + conn: &Connection, + account_id: i64, + keep: u32, + size: u64, + quota: u64, +) -> rusqlite::Result { + let held = used(conn, account_id)?; + let freed: i64 = list(conn, account_id)? + .iter() + .skip(keep.saturating_sub(1) as usize) + .map(|copy| copy.size) + .sum(); + Ok(held - freed + size as i64 <= quota as i64) +} + +/// Records the new copy and drops the oldest past `keep`; the keys of those +/// are what the caller removes from the store. +pub fn add_and_prune( + conn: &mut Connection, + account_id: i64, + copy: &Copy, + keep: u32, +) -> rusqlite::Result> { + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO copies (id, account_id, size, sha256, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![copy.id, account_id, copy.size, copy.sha256, copy.created_at], + )?; + let old: Vec = list(&tx, account_id)? + .into_iter() + .skip(keep.max(1) as usize) + .map(|copy| copy.id) + .collect(); + for id in &old { + tx.execute("DELETE FROM copies WHERE id = ?1", [id])?; + } + tx.commit()?; + Ok(old.iter().map(|id| blob_key(account_id, id)).collect()) +} + +/// Every key the account's copies use. +pub fn keys(conn: &Connection, account_id: i64) -> rusqlite::Result> { + Ok(list(conn, account_id)? + .iter() + .map(|copy| blob_key(account_id, ©.id)) + .collect()) +} diff --git a/crates/botloft-cloud/src/db.rs b/crates/botloft-cloud/src/db.rs new file mode 100644 index 00000000..74cf4a08 --- /dev/null +++ b/crates/botloft-cloud/src/db.rs @@ -0,0 +1,125 @@ +//! The server's own SQLite database: one connection behind a mutex, queries +//! short, migrations numbered in `migrations/`. + +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rusqlite::Connection; + +use crate::error::ApiError; + +/// `(version, sql)`; versions are consecutive and match the file prefix. +const MIGRATIONS: &[(u32, &str)] = &[ + (1, include_str!("../migrations/0001_accounts.sql")), + (2, include_str!("../migrations/0002_copies.sql")), +]; + +#[derive(Clone)] +pub struct Db { + conn: Arc>, +} + +impl Db { + pub fn open(path: &Path) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.busy_timeout(Duration::from_secs(5))?; + Self::ready(conn) + } + + pub fn memory() -> rusqlite::Result { + Self::ready(Connection::open_in_memory()?) + } + + fn ready(mut conn: Connection) -> rusqlite::Result { + conn.pragma_update(None, "foreign_keys", "ON")?; + migrate(&mut conn)?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// A consistent copy of the database at `path`, which must not exist yet + /// (`botloft-cloud backup`): safe while the server runs. + pub fn snapshot_to(&self, path: &Path) -> rusqlite::Result<()> { + let conn = self + .conn + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + conn.execute("VACUUM INTO ?1", [path.to_string_lossy().as_ref()])?; + Ok(()) + } + + /// Runs `work` with the connection. Keep it short: the lock is shared. + pub(crate) fn run( + &self, + work: impl FnOnce(&mut Connection) -> rusqlite::Result, + ) -> Result { + let mut conn = self + .conn + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + work(&mut conn).map_err(ApiError::from) + } +} + +fn migrate(conn: &mut Connection) -> rusqlite::Result<()> { + let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + for (version, sql) in MIGRATIONS.iter().filter(|(version, _)| *version > current) { + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", version)?; + tx.commit()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_snapshot_is_a_database_of_its_own() { + let dir = tempfile::tempdir().expect("dir"); + let db = Db::memory().expect("db"); + db.run(|conn| { + conn.execute( + "INSERT INTO accounts (email, created_at) VALUES ('a@b.c', 1)", + [], + ) + }) + .expect("row"); + let copy = dir.path().join("copy.db"); + db.snapshot_to(©).expect("snapshot"); + let back = Db::open(©).expect("open the copy"); + let email: String = back + .run(|conn| conn.query_row("SELECT email FROM accounts", [], |row| row.get(0))) + .expect("email"); + assert_eq!(email, "a@b.c"); + // It will not overwrite a copy that is there. + assert!(db.snapshot_to(©).is_err()); + } + + #[test] + fn a_new_database_has_every_table() { + let db = Db::memory().expect("db"); + let tables: Vec = db + .run(|conn| { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")?; + stmt.query_map([], |row| row.get(0))?.collect() + }) + .expect("tables"); + for name in [ + "accounts", + "attempts", + "copies", + "deletions", + "devices", + "logins", + ] { + assert!(tables.iter().any(|table| table == name), "{name}"); + } + } +} diff --git a/crates/botloft-cloud/src/error.rs b/crates/botloft-cloud/src/error.rs new file mode 100644 index 00000000..a623e021 --- /dev/null +++ b/crates/botloft-cloud/src/error.rs @@ -0,0 +1,148 @@ +//! What every endpoint answers when it fails: `{reason, message}` (spec 27.4). + +use axum::Json; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +#[derive(Debug)] +pub struct ApiError { + status: StatusCode, + reason: &'static str, + message: &'static str, + retry_after: Option, +} + +impl ApiError { + const fn new(status: StatusCode, reason: &'static str, message: &'static str) -> Self { + Self { + status, + reason, + message, + retry_after: None, + } + } + + pub fn unauthorized() -> Self { + Self::new( + StatusCode::UNAUTHORIZED, + "unauthorized", + "Sign in again to use this account.", + ) + } + + pub fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", "There is nothing here.") + } + + pub fn bad_email() -> Self { + Self::new( + StatusCode::BAD_REQUEST, + "bad_email", + "That does not look like an e-mail address.", + ) + } + + pub fn rate_limited(retry_after: u64) -> Self { + Self { + retry_after: Some(retry_after), + ..Self::new( + StatusCode::TOO_MANY_REQUESTS, + "rate_limited", + "Too many tries. Wait a little and try again.", + ) + } + } + + pub fn mail_failed() -> Self { + Self::new( + StatusCode::BAD_GATEWAY, + "mail_failed", + "The e-mail could not be sent. Try again in a moment.", + ) + } + + pub fn length_required() -> Self { + Self::new( + StatusCode::LENGTH_REQUIRED, + "length_required", + "The upload must say how big it is.", + ) + } + + pub fn bad_length() -> Self { + Self::new( + StatusCode::BAD_REQUEST, + "bad_length", + "The upload is not the size it said, or it was cut off.", + ) + } + + pub fn bad_hash() -> Self { + Self::new( + StatusCode::BAD_REQUEST, + "bad_hash", + "The upload does not match its SHA-256.", + ) + } + + pub fn too_big() -> Self { + Self::new( + StatusCode::PAYLOAD_TOO_LARGE, + "too_big", + "That copy is bigger than a copy may be.", + ) + } + + pub fn quota() -> Self { + Self::new( + StatusCode::PAYLOAD_TOO_LARGE, + "quota", + "The account has no room for this copy. Delete an old one first.", + ) + } + + pub fn range_not_satisfiable() -> Self { + Self::new( + StatusCode::RANGE_NOT_SATISFIABLE, + "bad_range", + "That part of the copy does not exist.", + ) + } + + pub fn internal() -> Self { + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + "Something went wrong on our side.", + ) + } + + pub fn reason(&self) -> &'static str { + self.reason + } +} + +impl From for ApiError { + fn from(err: rusqlite::Error) -> Self { + // Never the query or the values: they can hold an e-mail address. + tracing::error!("database: {err}"); + Self::internal() + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let mut body = json!({ "reason": self.reason, "message": self.message }); + if let Some(seconds) = self.retry_after { + body["retry_after"] = seconds.into(); + } + let mut response = (self.status, Json(body)).into_response(); + if let Some(seconds) = self.retry_after + && let Ok(value) = HeaderValue::from_str(&seconds.to_string()) + { + response.headers_mut().insert(header::RETRY_AFTER, value); + } + response + } +} diff --git a/crates/botloft-cloud/src/html.rs b/crates/botloft-cloud/src/html.rs new file mode 100644 index 00000000..faf51171 --- /dev/null +++ b/crates/botloft-cloud/src/html.rs @@ -0,0 +1,54 @@ +//! What a page or an e-mail needs of HTML: escaping what a person or the app +//! typed, and the flame. + +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use axum::response::IntoResponse; + +/// The Botloft mascot, the flame of the app's icon. +const FLAME: &[u8] = include_bytes!("../assets/flame.png"); + +/// `text` as it can sit in HTML, in a text or in an attribute. +pub fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +/// `GET /brand/flame.png`: the same image for the pages and the e-mails. +pub async fn flame() -> impl IntoResponse { + ( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=86400"), + ], + FLAME, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn what_a_person_typed_cannot_become_markup() { + assert_eq!( + escape("&'"), + "<b onclick="x">&'" + ); + assert_eq!(escape("PC da Ana"), "PC da Ana"); + } + + #[test] + fn the_flame_is_a_png() { + assert_eq!(&FLAME[..8], b"\x89PNG\r\n\x1a\n"); + } +} diff --git a/crates/botloft-cloud/src/lib.rs b/crates/botloft-cloud/src/lib.rs new file mode 100644 index 00000000..84529ae1 --- /dev/null +++ b/crates/botloft-cloud/src/lib.rs @@ -0,0 +1,30 @@ +//! The Botloft cloud server (spec 27): accounts by e-mail link, and the sealed +//! backup copies each account keeps. It never opens a copy. + +mod accounts; +mod app; +mod clock; +mod config; +mod copies; +mod copy_rows; +mod db; +mod error; +mod html; +mod limits; +mod login; +mod mail_html; +mod mail_text; +mod mailer; +mod pages; +mod removal; +mod session; +mod store; +mod tokens; + +pub use app::{AppState, router}; +pub use clock::Clock; +pub use config::{Bucket, Config, Smtp, Storage}; +pub use db::Db; +pub use error::ApiError; +pub use mailer::{Mail, Mailer, Outbox, SmtpMailer}; +pub use store::CopyStore; diff --git a/crates/botloft-cloud/src/limits.rs b/crates/botloft-cloud/src/limits.rs new file mode 100644 index 00000000..543d7642 --- /dev/null +++ b/crates/botloft-cloud/src/limits.rs @@ -0,0 +1,114 @@ +//! Rate limits of sign-in (spec 27.3): so many tries per e-mail and per +//! address in an hour, and a gap between two e-mails to the same person. + +use rusqlite::{Connection, params}; + +pub struct Rule { + pub kind: &'static str, + pub limit: u32, + pub window_ms: i64, +} + +pub const PER_EMAIL: Rule = Rule { + kind: "email", + limit: 5, + window_ms: 3_600_000, +}; +pub const PER_ADDRESS: Rule = Rule { + kind: "address", + limit: 20, + window_ms: 3_600_000, +}; +pub const GAP: Rule = Rule { + kind: "gap", + limit: 1, + window_ms: 60_000, +}; + +/// Counts the tries of every `(rule, key)` and, if none is over, records one +/// more for each. When one is over, nothing is recorded and the answer is how +/// many seconds until it frees up. `key` is hashed by the caller. +pub fn take( + conn: &mut Connection, + now: i64, + checks: &[(&Rule, &str)], +) -> rusqlite::Result> { + let tx = conn.transaction()?; + let mut wait: Option = None; + for (rule, key) in checks { + let since = now - rule.window_ms; + let (count, oldest): (u32, Option) = tx.query_row( + "SELECT COUNT(*), MIN(at) FROM attempts WHERE kind = ?1 AND key = ?2 AND at > ?3", + params![rule.kind, key, since], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if count >= rule.limit { + let free = oldest.map_or(rule.window_ms, |at| at + rule.window_ms - now); + wait = Some(wait.map_or(free, |other| other.max(free))); + } + } + if let Some(ms) = wait { + return Ok(Err(((ms + 999) / 1000).max(1) as u64)); + } + for (rule, key) in checks { + tx.execute( + "INSERT INTO attempts (kind, key, at) VALUES (?1, ?2, ?3)", + params![rule.kind, key, now], + )?; + } + tx.commit()?; + Ok(Ok(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Db; + + #[test] + fn the_sixth_try_in_an_hour_waits_for_the_first_to_age() { + let db = Db::memory().expect("db"); + for second in 0..5 { + let now = second * 1000; + let taken = db + .run(|conn| take(conn, now, &[(&PER_EMAIL, "k")])) + .expect("take"); + assert_eq!(taken, Ok(())); + } + let taken = db + .run(|conn| take(conn, 10_000, &[(&PER_EMAIL, "k")])) + .expect("take"); + assert_eq!(taken, Err(3590)); + // Another key is not held back, and an hour later the first is free. + let other = db + .run(|conn| take(conn, 10_000, &[(&PER_EMAIL, "z")])) + .expect("take"); + assert_eq!(other, Ok(())); + let later = db + .run(|conn| take(conn, 3_601_000, &[(&PER_EMAIL, "k")])) + .expect("take"); + assert_eq!(later, Ok(())); + } + + #[test] + fn a_refused_try_records_nothing() { + let db = Db::memory().expect("db"); + let first = db.run(|conn| take(conn, 0, &[(&GAP, "k")])).expect("take"); + assert_eq!(first, Ok(())); + // The gap holds, so the address count must not move either. + let refused = db + .run(|conn| take(conn, 1000, &[(&PER_ADDRESS, "a"), (&GAP, "k")])) + .expect("take"); + assert_eq!(refused, Err(59)); + let count: u32 = db + .run(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM attempts WHERE kind = 'address'", + [], + |row| row.get(0), + ) + }) + .expect("count"); + assert_eq!(count, 0); + } +} diff --git a/crates/botloft-cloud/src/login.rs b/crates/botloft-cloud/src/login.rs new file mode 100644 index 00000000..31e90097 --- /dev/null +++ b/crates/botloft-cloud/src/login.rs @@ -0,0 +1,273 @@ +//! Signing in by e-mail link (spec 27.3): ask, confirm in the e-mail, collect. + +use axum::extract::{Extension, Form, Path, Query, State}; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use axum::{Json, extract::ConnectInfo}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::net::SocketAddr; + +use crate::accounts::{self, LOGIN_TTL_MS, NewLogin, Request}; +use crate::app::AppState; +use crate::error::ApiError; +use crate::limits::{self, GAP, PER_ADDRESS, PER_EMAIL}; +use crate::mail_text::{Locale, login_mail}; +use crate::mailer::Mail; +use crate::{pages, tokens}; + +#[derive(Deserialize)] +pub struct Ask { + email: String, + #[serde(default)] + device_name: String, + #[serde(default)] + locale: String, +} + +/// `POST /v1/login`: always the same answer, whoever has an account. +pub async fn ask( + State(state): State, + headers: HeaderMap, + connection: Option>>, + Json(ask): Json, +) -> Result<(axum::http::StatusCode, Json), ApiError> { + let email = clean_email(&ask.email).ok_or_else(ApiError::bad_email)?; + let device = clean_device(&ask.device_name); + let locale = Locale::parse(&ask.locale); + let address = client_address(&state, &headers, connection.map(|Extension(info)| info)); + let (email_key, address_key) = (tokens::hash(&email), tokens::hash(&address)); + let now = state.clock.now(); + + let taken = state.db.run(|conn| { + limits::take( + conn, + now, + &[ + (&PER_EMAIL, &email_key), + (&PER_ADDRESS, &address_key), + (&GAP, &email_key), + ], + ) + })?; + taken.map_err(ApiError::rate_limited)?; + + let (request, code) = (tokens::random()?, tokens::random()?); + let request_hash = tokens::hash(&request); + state.db.run(|conn| { + accounts::purge(conn, now)?; + accounts::insert_login( + conn, + &NewLogin { + request_hash: &request_hash, + code_hash: &tokens::hash(&code), + email: &email, + device_name: &device, + locale: locale.tag(), + }, + now, + ) + })?; + + let link = format!("{}/v1/login/confirm?code={code}", state.config.base_url()); + let letter = login_mail(locale, &link, &device, state.config.base_url()); + let sent = state + .mailer + .send(Mail { + to: email, + subject: letter.subject, + body: letter.text, + html: letter.html, + }) + .await; + if sent.is_err() { + state + .db + .run(|conn| accounts::drop_login(conn, &request_hash))?; + return Err(ApiError::mail_failed()); + } + Ok(( + axum::http::StatusCode::ACCEPTED, + Json(json!({ "request": request, "wait": LOGIN_TTL_MS / 1000 })), + )) +} + +/// `GET /v1/login/`: the app asks whether the link was opened. +pub async fn poll( + State(state): State, + Path(request): Path, +) -> Result, ApiError> { + let now = state.clock.now(); + let request_hash = tokens::hash(&request); + let found = state + .db + .run(|conn| accounts::read_request(conn, &request_hash, now))?; + match found { + Request::Gone => Ok(Json(json!({ "status": "expired" }))), + Request::Pending => Ok(Json(json!({ "status": "pending" }))), + Request::Approved(approved) => { + let (token, device) = ( + tokens::random()?, + format!("dev_{}", &tokens::random()?[..16]), + ); + let token_hash = tokens::hash(&token); + let finished = state.db.run(|conn| { + accounts::finish_login(conn, &request_hash, &approved, &device, &token_hash, now) + })?; + if !finished { + // Two polls at once: the other one took it. + return Ok(Json(json!({ "status": "expired" }))); + } + Ok(Json(json!({ + "status": "approved", + "token": token, + "device": device, + "email": approved.email, + }))) + } + } +} + +#[derive(Deserialize)] +pub struct Code { + code: String, +} + +/// `GET /v1/login/confirm?code=`: the page the e-mail link opens. It only +/// asks; nothing is confirmed until the button posts. +pub async fn confirm_page( + State(state): State, + headers: HeaderMap, + Query(Code { code }): Query, +) -> Result { + let now = state.clock.now(); + let waiting = state + .db + .run(|conn| accounts::peek_login(conn, &tokens::hash(&code), now))?; + Ok(match waiting { + Some(waiting) => { + pages::confirm(Locale::parse(&waiting.locale), &waiting.device_name, &code) + } + None => pages::gone(Locale::of_request(&headers)), + }) +} + +/// `POST /v1/login/confirm`: the button. +pub async fn confirm( + State(state): State, + headers: HeaderMap, + Form(Code { code }): Form, +) -> Result { + let now = state.clock.now(); + let code_hash = tokens::hash(&code); + let locale = state.db.run(|conn| { + let waiting = accounts::peek_login(conn, &code_hash, now)?; + let Some(waiting) = waiting else { + return Ok(None); + }; + Ok(accounts::confirm_login(conn, &code_hash, now)?.then(|| Locale::parse(&waiting.locale))) + })?; + Ok(match locale { + Some(locale) => pages::done(locale).into_response(), + None => pages::gone(Locale::of_request(&headers)), + }) +} + +/// Where the request really comes from: the header the config names, if it +/// holds an address; else, behind a proxy, the last `X-Forwarded-For`; else +/// the connection. +fn client_address( + state: &AppState, + headers: &HeaderMap, + connection: Option>, +) -> String { + if let Some(name) = &state.config.client_ip_header + && let Some(address) = headers + .get(name.trim()) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + { + return address.to_string(); + } + if state.config.behind_proxy + && let Some(forwarded) = headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(|list| list.rsplit(',').next()) + { + return forwarded.trim().to_owned(); + } + connection.map_or_else( + || "unknown".to_owned(), + |ConnectInfo(addr)| addr.ip().to_string(), + ) +} + +/// The address as it is stored: trimmed, lower case, one `@`, a domain with a +/// dot, and none of the characters that could break a header or a link. +fn clean_email(raw: &str) -> Option { + let email = raw.trim().to_lowercase(); + let (local, domain) = email.split_once('@')?; + let bad = |c: char| c.is_whitespace() || c.is_control() || "<>()[]\\,;:\"@".contains(c); + let ok = email.len() <= 254 + && !local.is_empty() + && local.len() <= 64 + && !local.contains(bad) + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + && !domain.contains("..") + && !domain.contains(bad); + ok.then_some(email) +} + +/// The name of the computer: printable, at most 60 characters. +fn clean_device(raw: &str) -> String { + let name: String = raw + .chars() + .filter(|c| !c.is_control()) + .take(60) + .collect::() + .trim() + .to_owned(); + if name.is_empty() { + "Botloft".to_owned() + } else { + name + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addresses_are_cleaned_or_refused() { + assert_eq!( + clean_email(" Ana@Exemplo.COM "), + Some("ana@exemplo.com".to_owned()) + ); + for bad in [ + "", + "ana", + "ana@", + "@x.com", + "a b@x.com", + "a@b", + "a@@b.com", + "a@b..com", + "a\"@b.com", + "a@b.com\r\nBcc: x@y.z", + "@b.com", + ] { + assert_eq!(clean_email(bad), None, "{bad:?}"); + } + } + + #[test] + fn a_device_name_is_printable_and_short() { + assert_eq!(clean_device(""), "Botloft"); + assert_eq!(clean_device(" PC\u{7} da Ana "), "PC da Ana"); + assert_eq!(clean_device(&"x".repeat(100)).len(), 60); + } +} diff --git a/crates/botloft-cloud/src/mail_html.rs b/crates/botloft-cloud/src/mail_html.rs new file mode 100644 index 00000000..a2df314e --- /dev/null +++ b/crates/botloft-cloud/src/mail_html.rs @@ -0,0 +1,161 @@ +//! The HTML of the e-mails (spec 27.2): one card, the flame, a title, a few +//! words, a big button and the link as text for whoever cannot press it. +//! +//! E-mail clients draw HTML their own way, so it is a table with inline +//! styles, nothing that needs a script or a web font, and a dark version for +//! the clients that ask for one. The text version goes in the same message. + +use crate::html::escape; + +/// What one e-mail says. +pub struct Card<'a> { + pub lang: &'a str, + /// What an inbox shows next to the subject. + pub preheader: &'a str, + pub title: &'a str, + pub intro: &'a str, + pub button: &'a str, + /// The button is red: what it does cannot be undone. + pub danger: bool, + pub link: &'a str, + pub fallback: &'a str, + pub note: &'a str, + pub device_line: &'a str, + pub footer: &'a str, + /// The address of the flame, whole, because a mail client has no site. + pub flame_url: &'a str, +} + +pub fn render(card: &Card<'_>) -> String { + let (button, button_dark, button_ink_dark) = if card.danger { + ("#bd3529", "#ff6d60", "#121212") + } else { + ("#b83a14", "#ff7a59", "#121212") + }; + format!( + r##" + + + + + + +{title} + + + +
{preheader}
+
+ + + + +"##, + lang = escape(card.lang), + title = escape(card.title), + preheader = escape(card.preheader), + flame = escape(card.flame_url), + intro = escape(card.intro), + button = button, + button_dark = button_dark, + button_ink_dark = button_ink_dark, + button_label = escape(card.button), + link = escape(card.link), + fallback = escape(card.fallback), + note = escape(card.note), + device_line = escape(card.device_line), + footer = escape(card.footer), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn card() -> Card<'static> { + Card { + lang: "pt-BR", + preheader: "Vale por 10 minutos.", + title: "Entrar no Botloft", + intro: "Aperte o botão em \"PC Ana\".", + button: "Entrar no Botloft", + danger: false, + link: "https://x.example/v1/login/confirm?code=abc&n=1", + fallback: "Se o botão não abrir:", + note: "Vale por 10 minutos.", + device_line: "Pedido do computador: PC Ana", + footer: "Botloft", + flame_url: "https://x.example/brand/flame.png", + } + } + + #[test] + fn the_button_the_link_and_the_flame_are_there() { + let html = render(&card()); + assert!(html.contains(r#"Entrar no Botloft")); + assert!(html.contains(r#"src="https://x.example/brand/flame.png""#)); + assert!(html.contains(r#""#)); + // The link also sits as text, for a client that does not draw the button. + assert!(html.contains(">https://x.example/v1/login/confirm?code=abc&n=1")); + assert!(html.contains("prefers-color-scheme: dark")); + } + + #[test] + fn what_a_person_typed_cannot_become_markup_in_the_mail() { + let html = render(&card()); + assert!(html.contains("PC <da> Ana")); + assert!(!html.contains("")); + } + + #[test] + fn an_irreversible_button_is_red() { + let danger = render(&Card { + danger: true, + ..card() + }); + assert!(danger.contains(r##"bgcolor="#bd3529""##)); + assert!(render(&card()).contains(r##"bgcolor="#b83a14""##)); + } +} diff --git a/crates/botloft-cloud/src/mail_text.rs b/crates/botloft-cloud/src/mail_text.rs new file mode 100644 index 00000000..542371ab --- /dev/null +++ b/crates/botloft-cloud/src/mail_text.rs @@ -0,0 +1,294 @@ +//! The words of the e-mails, in the three languages of the app (spec 27.2, +//! 15.6): each one as text and as HTML (`mail_html.rs`). + +use crate::mail_html::{self, Card}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locale { + En, + PtBr, + Es, +} + +impl Locale { + /// The tag the app sends (`en`, `pt-BR`, `es`); anything else is English. + pub fn parse(tag: &str) -> Self { + Self::from_tag(tag).unwrap_or(Self::En) + } + + fn from_tag(tag: &str) -> Option { + let tag = tag.trim().to_ascii_lowercase(); + match tag.split(['-', '_']).next()? { + "pt" => Some(Self::PtBr), + "es" => Some(Self::Es), + "en" => Some(Self::En), + _ => None, + } + } + + /// The first language a browser lists that we speak. + pub fn from_accept_language(header: &str) -> Self { + header + .split(',') + .filter_map(|part| Self::from_tag(part.split(';').next().unwrap_or(""))) + .next() + .unwrap_or(Self::En) + } + + /// The language the browser asked for. + pub fn of_request(headers: &axum::http::HeaderMap) -> Self { + headers + .get(axum::http::header::ACCEPT_LANGUAGE) + .and_then(|value| value.to_str().ok()) + .map_or(Self::En, Self::from_accept_language) + } + + /// The tag to store and send back. + pub fn tag(self) -> &'static str { + match self { + Self::En => "en", + Self::PtBr => "pt-BR", + Self::Es => "es", + } + } +} + +/// An e-mail ready to send: what the inbox lists, and its two bodies. +pub struct Letter { + pub subject: String, + pub text: String, + pub html: String, +} + +/// What one kind of e-mail says in one language. `{device}` is where the name +/// of the computer goes. +struct Words { + subject: &'static str, + preheader: &'static str, + title: &'static str, + intro: &'static str, + button: &'static str, + note: &'static str, + /// The first line of the text version, before the link. + lead: &'static str, + /// The line of the text version about what to do when it was not them. + text_note: &'static str, +} + +struct Common { + fallback: &'static str, + device_line: &'static str, + footer: &'static str, +} + +fn common(locale: Locale) -> Common { + match locale { + Locale::En => Common { + fallback: "If the button does not open, copy this address into your browser:", + device_line: "Asked from the computer \"{device}\".", + footer: "You got this e-mail because someone asked to use Botloft with this address.", + }, + Locale::PtBr => Common { + fallback: "Se o botão não abrir, copie este endereço no navegador:", + device_line: "Pedido feito do computador \"{device}\".", + footer: "Você recebeu este e-mail porque alguém pediu para usar o Botloft com este endereço.", + }, + Locale::Es => Common { + fallback: "Si el botón no se abre, copia esta dirección en el navegador:", + device_line: "Pedido desde el equipo \"{device}\".", + footer: "Recibiste este correo porque alguien pidió usar Botloft con esta dirección.", + }, + } +} + +fn sign_in_words(locale: Locale) -> Words { + match locale { + Locale::En => Words { + subject: "Your Botloft sign-in link", + preheader: "Press the button to sign in. It works for 10 minutes.", + title: "Sign in to Botloft", + intro: "You asked to sign in to Botloft on \"{device}\". Press the button to confirm.", + button: "Sign in to Botloft", + note: "The link works once and expires in 10 minutes. If you did not ask for it, ignore this e-mail: nothing happens.", + lead: "Open this link to sign in to Botloft on \"{device}\":", + text_note: "It works once and expires in 10 minutes. If you did not ask for it, ignore this e-mail: nothing happens.", + }, + Locale::PtBr => Words { + subject: "Seu link de entrada no Botloft", + preheader: "Aperte o botão para entrar. Ele vale por 10 minutos.", + title: "Entrar no Botloft", + intro: "Você pediu para entrar no Botloft em \"{device}\". Aperte o botão para confirmar.", + button: "Entrar no Botloft", + note: "O link vale uma vez e expira em 10 minutos. Se não foi você que pediu, ignore este e-mail: nada acontece.", + lead: "Abra este link para entrar no Botloft em \"{device}\":", + text_note: "Ele vale uma vez e expira em 10 minutos. Se não foi você que pediu, ignore este e-mail: nada acontece.", + }, + Locale::Es => Words { + subject: "Tu enlace para entrar en Botloft", + preheader: "Pulsa el botón para entrar. Vale 10 minutos.", + title: "Entrar en Botloft", + intro: "Pediste entrar en Botloft en \"{device}\". Pulsa el botón para confirmar.", + button: "Entrar en Botloft", + note: "El enlace sirve una vez y caduca en 10 minutos. Si no lo pediste tú, ignora este correo: no pasa nada.", + lead: "Abre este enlace para entrar en Botloft en \"{device}\":", + text_note: "Sirve una vez y caduca en 10 minutos. Si no lo pediste tú, ignora este correo: no pasa nada.", + }, + } +} + +fn delete_words(locale: Locale) -> Words { + match locale { + Locale::En => Words { + subject: "Delete your Botloft account?", + preheader: "This deletes your account and every copy saved in it.", + title: "Delete your Botloft account?", + intro: "You asked to delete your account and every copy saved in it. This cannot be undone.", + button: "Delete my account", + note: "The link expires in 10 minutes. If you did not ask for this, ignore this e-mail: nothing is deleted.", + lead: "Open this link and press the button to delete your Botloft account and every copy saved in it:", + text_note: "It expires in 10 minutes. If you did not ask for this, ignore this e-mail: nothing is deleted.", + }, + Locale::PtBr => Words { + subject: "Apagar sua conta do Botloft?", + preheader: "Isto apaga a sua conta e todas as cópias guardadas nela.", + title: "Apagar sua conta do Botloft?", + intro: "Você pediu para apagar a sua conta e todas as cópias guardadas nela. Não dá para desfazer.", + button: "Apagar minha conta", + note: "O link expira em 10 minutos. Se não foi você que pediu, ignore este e-mail: nada é apagado.", + lead: "Abra este link e aperte o botão para apagar sua conta do Botloft e todas as cópias guardadas nela:", + text_note: "Ele expira em 10 minutos. Se não foi você que pediu, ignore este e-mail: nada é apagado.", + }, + Locale::Es => Words { + subject: "¿Borrar tu cuenta de Botloft?", + preheader: "Esto borra tu cuenta y todas las copias guardadas en ella.", + title: "¿Borrar tu cuenta de Botloft?", + intro: "Pediste borrar tu cuenta y todas las copias guardadas en ella. No se puede deshacer.", + button: "Borrar mi cuenta", + note: "El enlace caduca en 10 minutos. Si no lo pediste tú, ignora este correo: no se borra nada.", + lead: "Abre este enlace y pulsa el botón para borrar tu cuenta de Botloft y todas las copias guardadas en ella:", + text_note: "Caduca en 10 minutos. Si no lo pediste tú, ignora este correo: no se borra nada.", + }, + } +} + +fn letter( + locale: Locale, + words: &Words, + link: &str, + device: Option<&str>, + brand_base: &str, + danger: bool, +) -> Letter { + let common = common(locale); + let name = device.unwrap_or(""); + let fill = |text: &str| text.replace("{device}", name); + let device_line = if device.is_some() { + fill(common.device_line) + } else { + String::new() + }; + let mut text = format!("{}\n\n{link}\n\n{}\n", fill(words.lead), words.text_note); + if !device_line.is_empty() { + text.push_str(&format!("\n{device_line}\n")); + } + let html = mail_html::render(&Card { + lang: locale.tag(), + preheader: words.preheader, + title: words.title, + intro: &fill(words.intro), + button: words.button, + danger, + link, + fallback: common.fallback, + note: words.note, + device_line: &device_line, + footer: common.footer, + flame_url: &format!("{}/brand/flame.png", brand_base.trim_end_matches('/')), + }); + Letter { + subject: words.subject.to_owned(), + text, + html, + } +} + +/// The e-mail with the sign-in link; `brand_base` is the server's address. +pub fn login_mail(locale: Locale, link: &str, device: &str, brand_base: &str) -> Letter { + letter( + locale, + &sign_in_words(locale), + link, + Some(device), + brand_base, + false, + ) +} + +/// The e-mail that confirms deleting an account. +pub fn delete_mail(locale: Locale, link: &str, brand_base: &str) -> Letter { + letter(locale, &delete_words(locale), link, None, brand_base, true) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "https://cloud.example.org/"; + + #[test] + fn tags_and_browser_headers_pick_a_language() { + assert_eq!(Locale::parse("pt-BR"), Locale::PtBr); + assert_eq!(Locale::parse("es_MX"), Locale::Es); + assert_eq!(Locale::parse("fr"), Locale::En); + assert_eq!( + Locale::from_accept_language("fr, es;q=0.8, en;q=0.5"), + Locale::Es + ); + assert_eq!(Locale::from_accept_language(""), Locale::En); + } + + #[test] + fn every_language_carries_the_link_the_device_and_both_bodies() { + for locale in [Locale::En, Locale::PtBr, Locale::Es] { + let letter = login_mail(locale, "https://x/y?code=1&n=2", "Casa ", BASE); + assert!(!letter.subject.is_empty()); + assert!( + letter.text.contains("https://x/y?code=1&n=2") && letter.text.contains("Casa ") + ); + assert!(letter.html.contains("https://x/y?code=1&n=2")); + assert!(letter.html.contains("Casa <PC>") && !letter.html.contains("Casa ")); + assert!( + letter + .html + .contains("https://cloud.example.org/brand/flame.png") + ); + assert!( + letter + .html + .contains(&format!("", locale.tag())) + ); + assert!(!letter.html.contains("{device}") && !letter.text.contains("{device}")); + } + } + + #[test] + fn deleting_an_account_asks_in_red_and_names_no_device() { + for locale in [Locale::En, Locale::PtBr, Locale::Es] { + let letter = delete_mail(locale, "https://x/del?code=2", BASE); + assert!(letter.html.contains(r##"bgcolor="#bd3529""##)); + assert!(letter.text.contains("https://x/del?code=2")); + assert!(!letter.text.contains("{device}") && !letter.html.contains("{device}")); + } + // The words the app and its tests rely on. + assert!( + login_mail(Locale::PtBr, "l", "d", BASE) + .text + .contains("Abra este link") + ); + assert!( + login_mail(Locale::PtBr, "l", "d", BASE) + .text + .contains("expira em 10 minutos") + ); + } +} diff --git a/crates/botloft-cloud/src/mailer.rs b/crates/botloft-cloud/src/mailer.rs new file mode 100644 index 00000000..ab102e96 --- /dev/null +++ b/crates/botloft-cloud/src/mailer.rs @@ -0,0 +1,115 @@ +//! Sending e-mail goes through here, so tests read an outbox instead of +//! talking to a mail server (spec 27.2). + +use std::sync::{Arc, Mutex}; + +use lettre::message::MultiPart; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; + +use crate::config::{self, Smtp}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mail { + pub to: String, + pub subject: String, + /// The plain text version. + pub body: String, + /// The same message drawn, for the clients that show HTML. + pub html: String, +} + +/// Mail kept in memory, for tests. +#[derive(Clone, Default)] +pub struct Outbox { + mails: Arc>>, + broken: bool, +} + +impl Outbox { + /// An outbox whose mail never goes, to see what the server does then. + pub fn broken() -> Self { + Self { + broken: true, + ..Self::default() + } + } + + pub fn sent(&self) -> Vec { + self.mails + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +pub struct SmtpMailer { + transport: AsyncSmtpTransport, + from: String, +} + +impl SmtpMailer { + pub fn new(smtp: &Smtp) -> anyhow::Result { + let password = config::secret( + smtp.password_file.as_deref(), + config::SMTP_PASSWORD_VAR, + "smtp password", + )?; + let builder = if smtp.port == 465 { + AsyncSmtpTransport::::relay(&smtp.host)? + } else { + AsyncSmtpTransport::::starttls_relay(&smtp.host)? + }; + let transport = builder + .port(smtp.port) + .credentials(Credentials::new(smtp.user.clone(), password)) + .build(); + Ok(Self { + transport, + from: smtp.from.clone(), + }) + } + + async fn send(&self, mail: &Mail) -> anyhow::Result<()> { + let message = Message::builder() + .from(self.from.parse()?) + .to(mail.to.parse()?) + .subject(mail.subject.as_str()) + .multipart(MultiPart::alternative_plain_html( + mail.body.clone(), + mail.html.clone(), + ))?; + self.transport.send(message).await?; + Ok(()) + } +} + +/// The mail did not go. Nothing more: a mail server may repeat the address. +#[derive(Debug)] +pub struct SendFailed; + +pub enum Mailer { + Smtp(Box), + Outbox(Outbox), +} + +impl Mailer { + pub async fn send(&self, mail: Mail) -> Result<(), SendFailed> { + match self { + Self::Outbox(outbox) if outbox.broken => Err(SendFailed), + Self::Outbox(outbox) => { + outbox + .mails + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(mail); + Ok(()) + } + Self::Smtp(smtp) => smtp.send(&mail).await.map_err(|_| { + // Nothing of the error: a mail server may repeat the address. + tracing::error!("sending an e-mail failed"); + SendFailed + }), + } + } +} diff --git a/crates/botloft-cloud/src/main.rs b/crates/botloft-cloud/src/main.rs new file mode 100644 index 00000000..eb4ec683 --- /dev/null +++ b/crates/botloft-cloud/src/main.rs @@ -0,0 +1,100 @@ +//! `botloft-cloud serve --config cloud.toml` (spec 27.2), and +//! `botloft-cloud backup --to ` for the database. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Mailer, SmtpMailer, Storage, router}; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "botloft-cloud", version, about)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Runs the server. + Serve { + #[arg(long, default_value = "cloud.toml")] + config: PathBuf, + }, + /// Writes a consistent copy of the database, which is safe while the + /// server runs. The copies of the accounts are in the bucket or the + /// folder, and are not part of it. + Backup { + #[arg(long, default_value = "cloud.toml")] + config: PathBuf, + /// The file to write; it must not exist yet. + #[arg(long)] + to: PathBuf, + }, +} + +/// Waits for Ctrl+C, or for the `SIGTERM` that Docker and systemd send. +async fn shutdown() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + if let Ok(mut terminate) = signal(SignalKind::terminate()) { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "botloft_cloud=info,warn".into()), + ) + .init(); + match Cli::parse().command { + Command::Serve { config } => serve(Config::load(&config)?).await, + Command::Backup { config, to } => { + let config = Config::load(&config)?; + if let Some(folder) = to.parent().filter(|folder| !folder.as_os_str().is_empty()) { + std::fs::create_dir_all(folder).context("cannot create the folder")?; + } + let db = + Db::open(&config.data_dir.join("cloud.db")).context("cannot open the database")?; + db.snapshot_to(&to).context("cannot write the copy")?; + println!("database copied to {}", to.display()); + Ok(()) + } + } +} + +async fn serve(config: Config) -> anyhow::Result<()> { + std::fs::create_dir_all(&config.data_dir).context("cannot create data_dir")?; + let store = match (config.storage, &config.bucket) { + (Storage::Bucket, Some(bucket)) => CopyStore::bucket(bucket)?, + _ => CopyStore::disk(&config.data_dir.join("copies"))?, + }; + let state = AppState { + store, + db: Db::open(&config.data_dir.join("cloud.db")).context("cannot open the database")?, + mailer: Arc::new(Mailer::Smtp(Box::new(SmtpMailer::new(&config.smtp)?))), + clock: Clock::default(), + config: Arc::new(config.clone()), + }; + let listener = tokio::net::TcpListener::bind(config.listen).await?; + tracing::info!("listening on {}", config.listen); + axum::serve( + listener, + router(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown()) + .await?; + Ok(()) +} diff --git a/crates/botloft-cloud/src/pages.rs b/crates/botloft-cloud/src/pages.rs new file mode 100644 index 00000000..ddbed0b7 --- /dev/null +++ b/crates/botloft-cloud/src/pages.rs @@ -0,0 +1,348 @@ +//! The pages behind the e-mail links (spec 27.3): a card with the flame, in +//! the colors and the type of the app, light or dark as the device is. The +//! link only shows a button; the sign-in is confirmed by the button, because +//! mail scanners open links on their own and must not sign anyone in. + +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{Html, IntoResponse, Response}; + +use crate::html::escape; +use crate::mail_text::Locale; + +struct Words { + sign_title: &'static str, + sign_ask: &'static str, + sign_button: &'static str, + sign_once: &'static str, + done_title: &'static str, + done_text: &'static str, + done_hint: &'static str, + gone_title: &'static str, + gone_text: &'static str, + del_title: &'static str, + /// `{}` is where the e-mail address goes. + del_ask: &'static str, + del_warning: &'static str, + del_button: &'static str, + deleted_title: &'static str, + deleted_text: &'static str, +} + +fn words(locale: Locale) -> Words { + match locale { + Locale::En => Words { + sign_title: "Sign in to Botloft", + sign_ask: "Confirm that you are signing in on this computer:", + sign_button: "Sign in", + sign_once: "This link works once.", + done_title: "Done", + done_text: "You can go back to Botloft now.", + done_hint: "You can close this tab.", + gone_title: "This link no longer works", + gone_text: "It was already used or it expired. Ask for a new one in Botloft.", + del_title: "Delete your account", + del_ask: "This deletes the account of {} and every copy saved in it.", + del_warning: "It cannot be undone.", + del_button: "Delete everything", + deleted_title: "Account deleted", + deleted_text: "Your account and its copies are deleted.", + }, + Locale::PtBr => Words { + sign_title: "Entrar no Botloft", + sign_ask: "Confirme que você está entrando neste computador:", + sign_button: "Entrar", + sign_once: "Este link vale uma vez.", + done_title: "Pronto", + done_text: "Pode voltar ao Botloft.", + done_hint: "Você já pode fechar esta aba.", + gone_title: "Este link não vale mais", + gone_text: "Ele já foi usado ou expirou. Peça outro no Botloft.", + del_title: "Apagar sua conta", + del_ask: "Isto apaga a conta de {} e todas as cópias guardadas nela.", + del_warning: "Não dá para desfazer.", + del_button: "Apagar tudo", + deleted_title: "Conta apagada", + deleted_text: "Sua conta e as cópias foram apagadas.", + }, + Locale::Es => Words { + sign_title: "Entrar en Botloft", + sign_ask: "Confirma que estás entrando en este equipo:", + sign_button: "Entrar", + sign_once: "Este enlace sirve una vez.", + done_title: "Listo", + done_text: "Ya puedes volver a Botloft.", + done_hint: "Puedes cerrar esta pestaña.", + gone_title: "Este enlace ya no sirve", + gone_text: "Ya se usó o caducó. Pide otro en Botloft.", + del_title: "Borrar tu cuenta", + del_ask: "Esto borra la cuenta de {} y todas las copias guardadas en ella.", + del_warning: "No se puede deshacer.", + del_button: "Borrar todo", + deleted_title: "Cuenta borrada", + deleted_text: "Tu cuenta y sus copias se han borrado.", + }, + } +} + +const CHECK: &str = r#""#; +const ALERT: &str = r#""#; + +/// The same card for every page: the flame, the name, then `inner`. +fn shell(locale: Locale, title: &str, inner: &str) -> String { + format!( + r##" + + + + + + +{title} + + + +
+ +

Botloft

+{inner} +
+ + +"##, + lang = locale.tag(), + title = escape(title), + ) +} + +const CSS: &str = r#" +:root{color-scheme:light dark;--canvas:#f3f3f0;--panel:#fff;--line:#d8d8d2;--ink:#121212;--soft:#3b3b38;--muted:#5f5f59;--accent:#b83a14;--on-accent:#fff;--ok:#17703d;--danger:#bd3529} +@media (prefers-color-scheme:dark){:root{--canvas:#0b0b0b;--panel:#141414;--line:#262626;--ink:#eeeeea;--soft:#c6c6c0;--muted:#8c8c86;--accent:#ff7a59;--on-accent:#121212;--ok:#4fc382;--danger:#ff6d60}} +*{box-sizing:border-box} +body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:var(--canvas);color:var(--ink);font:16px/1.55 "IBM Plex Sans","Segoe UI Variable Text","Segoe UI",system-ui,sans-serif} +main{width:100%;max-width:420px;padding:36px 32px 32px;text-align:center;background:var(--panel);border:1px solid var(--line);border-radius:20px;box-shadow:0 10px 36px rgb(0 0 0/.07)} +.flame{display:block;width:88px;height:88px;margin:0 auto} +.brand{margin:4px 0 24px;color:var(--muted);font-size:14px;font-weight:700;letter-spacing:.04em} +h1{margin:0 0 10px;font-size:24px;line-height:1.25} +p{margin:0 0 20px;color:var(--soft)} +.chip{display:inline-block;max-width:100%;margin:0 0 22px;padding:5px 14px;color:var(--ink);font-weight:600;overflow-wrap:anywhere;background:var(--canvas);border:1px solid var(--line);border-radius:999px} +form{margin:0} +button{width:100%;padding:14px 20px;font:inherit;font-weight:600;color:var(--on-accent);cursor:pointer;background:var(--accent);border:0;border-radius:12px} +button:hover{filter:brightness(1.07)} +button:focus-visible{outline:3px solid var(--accent);outline-offset:3px} +button.danger{background:var(--danger);color:#fff} +.small{margin:14px 0 0;font-size:13px;color:var(--muted)} +.mark{display:grid;place-items:center;width:56px;height:56px;margin:0 auto 14px;border-radius:50%} +.mark.ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 16%,transparent)} +.mark.alert{color:var(--danger);background:color-mix(in srgb,var(--danger) 14%,transparent)} +.warn{margin:-8px 0 22px;font-weight:600;color:var(--danger)} +@media (max-width:480px){main{padding:28px 20px 24px}} +"#; + +/// The page with the sign-in button. `device` is what the app sent: escaped. +fn confirm_html(locale: Locale, device: &str, code: &str) -> String { + let w = words(locale); + let inner = format!( + r#"

{title}

+

{ask}

+

{device}

+
+ + +
+

{once}

"#, + title = w.sign_title, + ask = w.sign_ask, + device = escape(device), + code = escape(code), + button = w.sign_button, + once = w.sign_once, + ); + shell(locale, w.sign_title, &inner) +} + +fn done_html(locale: Locale) -> String { + let w = words(locale); + let inner = format!( + r#"
{CHECK}
+

{title}

+

{text}

+

{hint}

"#, + title = w.done_title, + text = w.done_text, + hint = w.done_hint, + ); + shell(locale, w.done_title, &inner) +} + +fn gone_html(locale: Locale) -> String { + let w = words(locale); + let inner = format!( + r#"
{ALERT}
+

{title}

+

{text}

"#, + title = w.gone_title, + text = w.gone_text, + ); + shell(locale, w.gone_title, &inner) +} + +fn delete_html(locale: Locale, email: &str, code: &str) -> String { + let w = words(locale); + let inner = format!( + r#"

{title}

+

{ask}

+

{warning}

+
+ + +
"#, + title = w.del_title, + ask = w + .del_ask + .replace("{}", &format!("{}", escape(email))), + warning = w.del_warning, + code = escape(code), + button = w.del_button, + ); + shell(locale, w.del_title, &inner) +} + +fn deleted_html(locale: Locale) -> String { + let w = words(locale); + let inner = format!( + r#"
{CHECK}
+

{title}

+

{text}

"#, + title = w.deleted_title, + text = w.deleted_text, + ); + shell(locale, w.deleted_title, &inner) +} + +/// The button page. The device name is what the app sent: it is escaped. +pub fn confirm(locale: Locale, device: &str, code: &str) -> Response { + page(confirm_html(locale, device, code)) +} + +/// The button page for deleting an account. +pub fn delete_ask(locale: Locale, email: &str, code: &str) -> Response { + page(delete_html(locale, email, code)) +} + +pub fn done(locale: Locale) -> Response { + page(done_html(locale)) +} + +pub fn deleted(locale: Locale) -> Response { + page(deleted_html(locale)) +} + +pub fn gone(locale: Locale) -> Response { + let mut response = page(gone_html(locale)); + *response.status_mut() = StatusCode::GONE; + response +} + +fn page(html: String) -> Response { + let mut response = Html(html).into_response(); + let headers = response.headers_mut(); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'", + ), + ); + response +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: [Locale; 3] = [Locale::En, Locale::PtBr, Locale::Es]; + + #[test] + fn every_page_has_the_flame_its_language_and_no_script() { + for locale in ALL { + for html in [ + confirm_html(locale, "PC", "c"), + done_html(locale), + gone_html(locale), + delete_html(locale, "a@b.c", "c"), + deleted_html(locale), + ] { + assert!(html.contains(r#""#, locale.tag()))); + assert!(!html.contains("Entrar") && sign.contains("Entrar no Botloft")); + let del = delete_html(Locale::En, "ana@exemplo.com", "abc"); + assert!(del.contains(r#"action="/v1/account/delete/confirm""#)); + assert!(del.contains(r#""#)); + assert!(del.contains("ana@exemplo.com")); + } + + #[test] + fn what_the_app_sent_cannot_become_markup() { + let html = confirm_html(Locale::En, "", "\">"); + assert!(html.contains("<script>x()</script>")); + assert!(!html.contains("", "locale": "en" }); + s.ask_with(body, "1.1.1.1").await; + let (path, _) = s.last_link(); + let page = s.get(&path).await; + assert!(page.body.contains("<script>x()</script>")); + assert!(!page.body.contains("