Skip to content

feat(cloud): optional account and light cloud backup (spec 27) - #216

Merged
httpsphl merged 15 commits into
mainfrom
feat/cloud-server-account
Oct 7, 2026
Merged

httpsphl merged 15 commits into
mainfrom
feat/cloud-server-account

Conversation

@httpsphl

@httpsphl httpsphl commented Oct 7, 2026

Copy link
Copy Markdown
Owner

What

An optional Botloft account and a light cloud backup (spec 27). It is off until you press Sign in, and nothing is sent without a click.

  • Server (crates/botloft-cloud): axum + SQLite. E-mail magic-link sign-in (no password), devices, quota, five copies kept per account, account deletion confirmed by e-mail. Copies live on disk or in an S3-compatible bucket (Cloudflare R2). Tokens are stored only as SHA-256, and rate limits are per e-mail and per address (client_ip_header for use behind Cloudflare). E-mails (text + HTML) and the link pages are drawn in Botloft's look, in en / pt-BR / es.
  • Daemon: cloud.* RPC methods (status, sign-in, upload, list, download, delete, delete account) with progress events, and backup.export {scope: full | light}. The light copy holds the crews' structure, the bots' memory and the routines, without chats or files.
  • App: Settings > Backup screen for sign-in, upload, the list of copies and restore.
  • Privacy: the copy is sealed on the computer with the backup password, which never leaves it, so the server stores bytes it cannot open. README and docs/code-signing-policy.md now say so.
  • Default server: [cloud] url defaults to https://botloft.comitium.com.br; an empty value turns the account off, and a signed-out computer never touches the network.
  • Deploy: Dockerfile, Compose (plain and Coolify), Caddy, systemd unit, database backup script, docs/cloud-deploy.md.

Checked

  • cargo fmt, clippy -D warnings, cargo test --workspace (620 tests) and pnpm check (454 tests) pass; bindings are up to date.
  • Real end to end on the production setup: sign-in e-mail received in Gmail's main inbox, link page, upload and download of a copy through an R2 bucket (spec 19).

Manual steps for the reviewer

  • Not tried yet: the e-mail in Outlook and other providers.
  • After merging, the next release ships the screen; the server is already running the image built from this branch.

A new crate, botloft-cloud, with its own SQLite: POST /v1/login, the page
the link opens (a button confirms, so mail scanners sign nobody in), the
app's poll, /v1/me, logout and removing a device. Tokens are kept as
SHA-256, sign-in is rate limited by e-mail and by address, and the e-mail
goes through a Mailer (SMTP, or an outbox in tests).

The spec (27.2-27.4) now says the button confirms, that the 60 s gap and
bad addresses answer 429 and 400, and adds behind_proxy.
Only the crews' structure, the bots' memory and the routines go up; chats and
files stay on the computer. A copy is a few MB, so the defaults drop to 50 MiB
a copy, 200 MiB an account and 5 copies.
The server's disk is shared with other work, so the copies go to a bucket
(R2) and the VPS keeps only cloud.db and a short-lived temp file per upload.
…ng an account (C3)

PUT/GET/DELETE /v1/copies with size and SHA-256 checked while the upload
lands in a temp file, Range downloads, a per-account quota that does not
count the copy the new one pushes out, and the oldest copies dropped only
after the new one is safe. Bytes go through CopyStore (a folder or an
S3-compatible bucket, via object_store). Deleting an account asks by e-mail
and a button deletes the rows and then the bytes.
backup.export takes a scope: light keeps the structure, each bot's memory
and rules and the routines, and leaves out chats, runs, usage and files,
in a folder of its own so a full export is never replaced. The new cloud
module signs in by the e-mail link in the background, keeps the token in
secrets/cloud.json, and sends, lists, brings down and deletes copies with
SHA-256 checked on both ends, plus cloud.* methods, notifications and the
fake for the app. Answered aside, because a transfer takes minutes.

Tested against the real botloft-cloud server running in the test.
An Account block above the copy saved on this computer: ask for the e-mail
link and wait for it, then send a light copy with its passphrase and a
progress bar, list the copies in the cloud to restore or delete (with a
confirmation), sign out, and delete the account by e-mail. Restoring a
cloud copy reuses the restore flow, which now says the chats and files are
not in a light copy. Texts in the three languages.
…database (C4)

deploy/cloud has the Dockerfile, a compose file that only publishes on
127.0.0.1 and runs read-only without privileges, a systemd unit, a Caddyfile,
an example cloud.toml (a test keeps it valid) and a script that backs the
database up with rclone. The server gains GET /health, botloft-cloud backup
(VACUUM INTO, safe while it runs) and a clean exit on SIGTERM. The guide is
docs/cloud-deploy.md: bucket, e-mail, proxy, backup and restore, known limits.

Checked with the real image: it builds, the container is healthy, /health is
200 and /v1/me is 401, the backup works while it runs, the database restores
and the container stops in under a second.
… Coolify compose

For a platform that keeps settings rather than files: the SMTP password and
the bucket secret come from BOTLOFT_CLOUD_SMTP_PASSWORD and
BOTLOFT_CLOUD_BUCKET_SECRET (the variable wins over password_file and
secret_file), and the whole cloud.toml can be BOTLOFT_CLOUD_CONFIG. The
secrets are never in an error or the log. deploy/cloud/docker-compose.coolify.yml
runs the image without a published port or a file on the machine, and is plain
Compose, so docker compose config accepts it.

Checked with the real image: it loads from a docker save tarball and starts
from that compose with only variables, /health is 200, /v1/me is 401, and
without the secrets it refuses to start.
…loudflare lesson in the guide

A scratch daemon signed in by the e-mail link, sent a light copy to the R2
bucket, brought it back, and an empty second daemon restored the crew, the
bot, the routine and the memory from it. The VPS firewall only lets Cloudflare
reach 80 and 443, so the server's record has to be proxied.
…dflare

Behind Cloudflare the server saw the edge's address as the client, so the
per-address sign-in limit was shared by everyone through that edge. With
client_ip_header = "CF-Connecting-IP" the header that holds the real address
wins over X-Forwarded-For; a value that is not an address is ignored, and
the option is off by default because anyone could write the header if they
could reach the server. The e-mail landing in the main inbox is recorded in
spec 19.
The sign-in and delete-account e-mails go out as text and as HTML: a card with
the flame, a title, a big button, the link written out for a client that does
not draw the button, and a dark version. The pages behind the links are a card
in the app's colors and type, light or dark as the device is, with a check, an
alert or a red button as the case asks. The flame is the app icon without its
black square, served at /brand/flame.png. The pages still run no script and may
load the flame only.
@httpsphl
httpsphl enabled auto-merge October 7, 2026 18:15
@httpsphl
httpsphl merged commit 3f05016 into main Oct 7, 2026
7 of 8 checks passed
@httpsphl httpsphl mentioned this pull request Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant