mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-23 02:22:20 -05:00
Rotating deletes the oldest restore point and shifts the rest down, so enough calls in quick succession leave nothing but snapshots of whatever state the database is in now. POST /backup takes no parameters, so a bodyless cross-origin form post reached it; the audit destroyed a full six-deep history that way (todo.md task 2). The origin guard from the previous commit already refuses that request, but the rotation itself should not be this easy to abuse, so two further changes: Rotation is skipped when the database has not actually changed. Not by comparing the live database file, which the audit suggested: under WAL the live file does not yet contain recent commits, so comparing it would skip backups that were genuinely needed. Instead the snapshot is written to a pending file first and compared against the newest backup -- sqlite's backup API reads through the connection, so it sees WAL content. Identical means the pending file is discarded and the history is left alone. This is fail-safe: if the comparison ever came out unequal for an unchanged database, the result is the old behaviour of rotating, never a missed backup. Rotation is also rate-limited to one per five minutes, returning the newest existing backup instead. Never applied when there is no backup to fall back on. BackupResponse gained a `created` field so a client can tell the difference. Additive, so v1-compatible, but load-bearing: telling a user they have a fresh restore point when they do not is exactly the wrong failure mode here. Verified against a real server -- six rapid calls now leave exactly one backup and never rotate.
118 lines
3.5 KiB
Python
118 lines
3.5 KiB
Python
"""Router setup for the v1 version of the API."""
|
|
|
|
# ruff: noqa: D103
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
from spoolman import env
|
|
from spoolman.database.database import backup_global_db
|
|
from spoolman.exceptions import ItemNotFoundError
|
|
from spoolman.externaldb import get_external_db_name
|
|
from spoolman.ws import websocket_manager
|
|
|
|
from . import export, externaldb, field, filament, models, other, search, setting, spool, vendor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(
|
|
title="Spoolman REST API v1",
|
|
version="1.0.0",
|
|
description="""
|
|
REST API for Spoolman.
|
|
|
|
The API is served on the path `/api/v1/`.
|
|
|
|
Some endpoints also serve a websocket on the same path. The websocket is used to listen for changes to the data
|
|
that the endpoint serves. The websocket messages are JSON objects. Additionally, there is a root-level websocket
|
|
endpoint that listens for changes to any data in the database.
|
|
""",
|
|
)
|
|
|
|
|
|
@app.exception_handler(ItemNotFoundError)
|
|
async def itemnotfounderror_exception_handler(_request: Request, exc: ItemNotFoundError) -> Response:
|
|
logger.debug(exc)
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"message": exc.args[0]},
|
|
)
|
|
|
|
|
|
# Add a general info endpoint
|
|
@app.get("/info")
|
|
async def info() -> models.Info:
|
|
"""Return general info about the API."""
|
|
return models.Info(
|
|
version=env.get_version(),
|
|
debug_mode=env.is_debug_mode(),
|
|
automatic_backups=env.is_automatic_backup_enabled(),
|
|
data_dir=str(env.get_data_dir().resolve()),
|
|
logs_dir=str(env.get_logs_dir().resolve()),
|
|
backups_dir=str(env.get_backups_dir().resolve()),
|
|
db_type=str(env.get_database_type() or "sqlite"),
|
|
external_db_name=get_external_db_name(),
|
|
git_commit=env.get_commit_hash(),
|
|
build_date=env.get_build_date(),
|
|
)
|
|
|
|
|
|
# Add health check endpoint
|
|
@app.get("/health")
|
|
async def health() -> models.HealthCheck:
|
|
"""Return a health check."""
|
|
return models.HealthCheck(status="healthy")
|
|
|
|
|
|
# Add endpoint for triggering a db backup
|
|
@app.post(
|
|
"/backup",
|
|
description="Trigger a database backup. Only applicable for SQLite databases.",
|
|
response_model=models.BackupResponse,
|
|
responses={500: {"model": models.Message}},
|
|
)
|
|
async def backup(): # noqa: ANN201
|
|
"""Trigger a database backup."""
|
|
result = await backup_global_db()
|
|
if result.path is None:
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"message": "Backup failed. See server logs for more information."},
|
|
)
|
|
return models.BackupResponse(path=str(result.path), created=result.created)
|
|
|
|
|
|
@app.websocket(
|
|
"/",
|
|
name="Listen to any changes",
|
|
)
|
|
async def notify(
|
|
websocket: WebSocket,
|
|
) -> None:
|
|
await websocket.accept()
|
|
websocket_manager.connect((), websocket)
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(0.5)
|
|
if await websocket.receive_text():
|
|
await websocket.send_json({"status": "healthy"})
|
|
except WebSocketDisconnect:
|
|
websocket_manager.disconnect((), websocket)
|
|
|
|
|
|
# Add routers
|
|
app.include_router(filament.router)
|
|
app.include_router(spool.router)
|
|
app.include_router(vendor.router)
|
|
app.include_router(setting.router)
|
|
app.include_router(field.router)
|
|
app.include_router(other.router)
|
|
app.include_router(externaldb.router)
|
|
app.include_router(export.router)
|
|
app.include_router(search.router)
|