"""M9: taking a verified copy of the whole database, from the browser. Two endpoints and no third. `app/backup.py` owns the procedure and every guarantee it makes; these only decide who may ask. ## Why there is no restore endpoint, and no download **Restore** means replacing the database file the running process has open. Doing that from inside that process is how someone loses both copies at once: the connection pool still holds handles on the old file, the WAL belongs to the old file, and a half-swapped database is not something a running application can notice. The supported procedure is in `DEVELOPMENT.md` — stop the application, move the file into place, start it — and it is a procedure precisely because each step needs the application not to be running. Campaign-level recovery, the common case and the only one that crosses machines, is the export bundle. **Download** is not offered either. The file is a copy of every campaign on the machine, and streaming it through the browser would put it in the download directory, in the browser's own cache, and in whatever the reader does with it next — for a local single-user application whose whole premise is that the story does not leave the machine, that is a worse default than a path the reader can copy. So the response names the directory and the reader takes it from there. ## Where the file goes Nowhere a request can name. The destination is derived from the database the application is already using, and the filename is generated from the clock. No part of either comes from the caller, so there is no traversal to attempt (H08), and the endpoints below accept no body at all. """ import logging from fastapi import APIRouter, Depends, HTTPException from .. import auth, backup, models router = APIRouter(prefix="/api/backups", tags=["backups"]) log = logging.getLogger(__name__) @router.get("") def list_backups(_user: models.User = Depends(auth.get_current_user)): """The backups already on disk, newest first, and where they are. The directory is reported once here rather than on every row, because it is the same for all of them and it is what the reader needs in order to find the files at all. """ return { "directory": str(backup.directory()), "backups": backup.existing(), } @router.post("", status_code=201) def create_backup(_user: models.User = Depends(auth.get_current_user)): """Takes one verified backup, and reports what it wrote. Synchronous. A backup of a local single-user database is a page copy that finishes in well under a second, and a reader who pressed the button is entitled to be told whether it worked rather than to be told it started. A failure is a 500 carrying the reason. There is nothing for the caller to fix by retrying differently — the request has no parameters — so the useful thing is the message, and `backup.create` guarantees that the source database is untouched and no partial file is left behind. """ try: result = backup.create() except backup.BackupError as exc: log.error("Backup failed: %s", exc) raise HTTPException(500, str(exc)) from exc return {"directory": str(result.path.parent), **result.as_dict()}