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 && (
- <>
-
-
- {b.pick}
-
- {file && (
-
- {file}
-
- )}
-
- {file && (
-
-
- setPassphrase(event.target.value)}
- />
-
-
- {busy ? b.opening : b.open}
-
-
- )}
- >
- )}
- {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}
-
-
- {busy ? b.restoring : b.restore}
-
-
- {b.cancel}
-
-
-
- )}
- {failed && (
-
- {failed.text}
-
- )}
+
+
+ {b.pick}
+
+ {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}
-
-
+
+
{busy ? b.exporting : b.export}
{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))}
+
+ bring(copy)}>
+ {bringing === copy.id ? t.bringing : t.restore}
+
+ setRemoving(copy)}
+ >
+ {t.removeCopy}
+
+
+ ))}
+
+ )}
+ {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}
+
+
+ sentTo && ask(sentTo)}>
+ {t.resend}
+
+
+ {t.cancel}
+
+
+
+ ) : (
+
+ )}
+ {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))}
+
+ )}
+
+
+ {t.signOut}
+
+
+
+
{
+ setVersion((current) => current + 1);
+ void refresh();
+ }}
+ />
+
+
+
+ {asked ? (
+
+ {t.removeAccountSent}
+
+ ) : (
+
setRemoving(true)}
+ >
+ {t.removeAccount}
+
+ )}
+ {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 ? t.uploading : t.upload}
+
+ {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)}
+ />
+
+
+ {busy ? b.opening : b.open}
+
+
+ )}
+ {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}
+
+
+ {busy ? b.restoring : b.restore}
+
+
+ {b.cancel}
+
+
+
+ )}
+ {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}
+
+
+
+
+
+
+ Botloft
+
+
+ {title}
+ {intro}
+
+
+
+ {fallback}
+
+
+
+ {note}
+ {device_line}
+
+
+
+
+
+
+"##,
+ 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("", "\">");
+ 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("