feat: vault-aligned server v1 + UX polish
All checks were successful
CI / Windows build (push) Successful in 7m47s

Redesign the optional FastAPI companion around vault files (manifest /
PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a
client Server settings panel for health/login. Polish shell UX: l10n for
settings/home/board, sticky-board empty state, and a narrow-screen
diagnostics FAB.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 19:04:48 +08:00
parent d346cc2670
commit 198da00ecd
20 changed files with 1325 additions and 90 deletions

View File

@@ -68,6 +68,9 @@ class Settings:
jwt_secret: str = _resolve_jwt_secret()
jwt_expiry_hours: int = int(os.environ.get("BADNOTE_JWT_EXPIRY_HOURS", "720"))
cors_origins: list[str] = _resolve_cors_origins()
# Per-user vault trees (notebook folders + sidecars), independent of legacy
# notes.strokes_json storage.
vault_path: str = os.environ.get("BADNOTE_VAULT_PATH", "./data/vaults")
settings = Settings()

View File

@@ -1,4 +1,12 @@
"""BadNote FastAPI server — main application."""
"""BadNote FastAPI server — main application.
Architecture (v1):
- Vault files are the source of truth (same layout as the Flutter vault).
- ``/api/v1/vault/*`` assists multi-device sync (manifest + PUT/GET/DELETE).
- ``/api/v1/ocr/*`` accepts ink rasters for deferred server OCR.
- Legacy ``/api/notes`` etc. remain mounted under ``/api/legacy/*`` for
compatibility with old experiments; new clients must not use them.
"""
import os
from contextlib import asynccontextmanager
@@ -13,12 +21,15 @@ from .routers.notes_router import router as notes_router
from .routers.documents_router import router as documents_router
from .routers.ocr_router import router as ocr_router
from .routers.sync_router import router as sync_router
from .routers.v1_vault_router import router as v1_vault_router
from .routers.v1_ocr_router import router as v1_ocr_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: create directories and init DB. Shutdown: close DB."""
os.makedirs(settings.storage_path, exist_ok=True)
os.makedirs(settings.vault_path, exist_ok=True)
for subdir in ("pending", "processing", "done", "failed"):
os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True)
await init_db()
@@ -26,12 +37,17 @@ async def lifespan(app: FastAPI):
await close_db()
app = FastAPI(title="BadNote Server", version="1.0.0", lifespan=lifespan)
app = FastAPI(
title="BadNote Server",
version="2.0.0",
description=(
"Self-hosted companion for BadNote. "
"Primary API is /api/v1 (vault + OCR). "
"Legacy notes CRUD lives under /api/legacy."
),
lifespan=lifespan,
)
# Authentication is Bearer-token based, so cookies/credentials are not needed.
# `allow_origins=["*"]` together with `allow_credentials=True` is an invalid and
# insecure combination, so we keep credentials disabled. Set BADNOTE_CORS_ORIGINS
# (comma-separated) to lock the API down to specific front-end origins.
_cors_origins = settings.cors_origins or ["*"]
app.add_middleware(
@@ -42,14 +58,34 @@ app.add_middleware(
allow_headers=["*"],
)
# --- Auth (shared by v1 + legacy) ---
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(notes_router, prefix="/api/notes", tags=["notes"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
app.include_router(ocr_router, prefix="/api/ocr", tags=["ocr"])
app.include_router(sync_router, prefix="/api/sync", tags=["sync"])
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth-v1"])
# --- Primary v1 API ---
app.include_router(v1_vault_router, prefix="/api/v1/vault", tags=["vault-v1"])
app.include_router(v1_ocr_router, prefix="/api/v1/ocr", tags=["ocr-v1"])
# --- Legacy (deprecated) ---
app.include_router(notes_router, prefix="/api/legacy/notes", tags=["legacy-notes"])
app.include_router(documents_router, prefix="/api/legacy/documents", tags=["legacy-documents"])
app.include_router(ocr_router, prefix="/api/legacy/ocr", tags=["legacy-ocr"])
app.include_router(sync_router, prefix="/api/legacy/sync", tags=["legacy-sync"])
# Keep old paths temporarily so existing bookmarks to /docs experiments still work,
# but they are the same legacy routers.
app.include_router(notes_router, prefix="/api/notes", tags=["legacy-notes"], include_in_schema=False)
app.include_router(documents_router, prefix="/api/documents", tags=["legacy-documents"], include_in_schema=False)
app.include_router(ocr_router, prefix="/api/ocr", tags=["legacy-ocr"], include_in_schema=False)
app.include_router(sync_router, prefix="/api/sync", tags=["legacy-sync"], include_in_schema=False)
@app.get("/api/ping")
async def ping() -> dict:
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/api/v1/health")
async def health() -> dict:
"""Liveness probe for clients and reverse proxies."""
return {
"status": "ok",
"version": "2.0.0",
"api": "v1",
"features": ["vault", "ocr", "auth"],
}

View File

@@ -0,0 +1,74 @@
"""v1 OCR job API — upload ink raster, poll status, fetch text."""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from ..auth import get_current_user
from ..config import settings
from ..ocr import queue as ocr_queue
router = APIRouter()
@router.post("/jobs", status_code=status.HTTP_202_ACCEPTED)
async def create_ocr_job(
image: UploadFile = File(...),
source_path: str = Form(""),
page_index: int = Form(0),
user_id: str = Depends(get_current_user),
) -> dict:
"""Enqueue an OCR job from an uploaded PNG/JPEG of handwriting."""
job_id = str(uuid.uuid4())
blob_dir = os.path.join(settings.storage_path, "ocr_blobs", user_id)
os.makedirs(blob_dir, exist_ok=True)
ext = os.path.splitext(image.filename or "ink.png")[1] or ".png"
blob_path = os.path.join(blob_dir, f"{job_id}{ext}")
data = await image.read()
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty image")
with open(blob_path, "wb") as f:
f.write(data)
ocr_queue.enqueue(
{
"id": job_id,
"user_id": user_id,
"source_path": source_path,
"note_id": source_path or job_id,
"page_index": page_index,
"image_path": blob_path,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return {
"job_id": job_id,
"status": "pending",
"source_path": source_path,
"page_index": page_index,
}
@router.get("/jobs/{job_id}")
async def get_ocr_job(
job_id: str,
user_id: str = Depends(get_current_user),
) -> dict:
job = ocr_queue.get_status(job_id)
if job is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="job not found")
if job.get("user_id") not in (None, user_id):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="forbidden")
return {
"job_id": job_id,
"status": job.get("status"),
"result": job.get("result_text") or job.get("result") or job.get("text"),
"error": job.get("error_message") or job.get("error"),
"source_path": job.get("source_path"),
"page_index": job.get("page_index"),
}

View File

@@ -0,0 +1,63 @@
"""v1 vault sync-assist API — vault files are the source of truth."""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from ..auth import get_current_user
from .. import vault_store
router = APIRouter()
@router.get("/manifest")
async def get_manifest(user_id: str = Depends(get_current_user)) -> dict:
"""List all files in the user's vault with size/mtime/sha256."""
return vault_store.build_manifest(user_id)
@router.get("/files/{file_path:path}")
async def download_file(
file_path: str,
user_id: str = Depends(get_current_user),
) -> Response:
try:
data = vault_store.read_file(user_id, file_path)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except FileNotFoundError:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found")
return Response(
content=data,
media_type="application/octet-stream",
headers={"X-Vault-Path": file_path},
)
@router.put("/files/{file_path:path}")
async def upload_file(
file_path: str,
upload: UploadFile = File(...),
user_id: str = Depends(get_current_user),
) -> dict:
try:
data = await upload.read()
entry = vault_store.write_file(user_id, file_path, data)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return entry.to_dict()
@router.delete("/files/{file_path:path}")
async def remove_file(
file_path: str,
user_id: str = Depends(get_current_user),
) -> dict:
try:
vault_store.delete_file(user_id, file_path, tombstone=True)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except FileNotFoundError:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found")
return {"deleted": file_path, "tombstone": True}

View File

@@ -0,0 +1,124 @@
"""Per-user vault file store — mirrors the client vault layout."""
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from .config import settings
@dataclass
class VaultFileEntry:
path: str
size: int
mtime: float
sha256: str
def to_dict(self) -> dict:
return {
"path": self.path,
"size": self.size,
"mtime": self.mtime,
"sha256": self.sha256,
}
def vault_root(user_id: str) -> Path:
root = Path(settings.vault_path) / user_id / "files"
root.mkdir(parents=True, exist_ok=True)
return root
def _safe_relpath(rel: str) -> str:
"""Normalize and reject path escape attempts."""
cleaned = rel.replace("\\", "/").lstrip("/")
if not cleaned or cleaned.startswith("..") or "/../" in f"/{cleaned}/":
raise ValueError(f"invalid vault path: {rel!r}")
parts = Path(cleaned).parts
if ".." in parts:
raise ValueError(f"invalid vault path: {rel!r}")
return "/".join(parts)
def resolve_path(user_id: str, rel: str) -> Path:
rel_n = _safe_relpath(rel)
full = (vault_root(user_id) / rel_n).resolve()
root = vault_root(user_id).resolve()
if not str(full).startswith(str(root) + os.sep) and full != root:
raise ValueError(f"path escapes vault: {rel!r}")
return full
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def build_manifest(user_id: str) -> dict:
root = vault_root(user_id)
entries: list[VaultFileEntry] = []
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
if path.name.startswith("."):
continue
rel = path.relative_to(root).as_posix()
st = path.stat()
entries.append(
VaultFileEntry(
path=rel,
size=st.st_size,
mtime=st.st_mtime,
sha256=_sha256_file(path),
)
)
return {
"user_id": user_id,
"generated_at": time.time(),
"files": [e.to_dict() for e in entries],
}
def write_file(user_id: str, rel: str, data: bytes) -> VaultFileEntry:
path = resolve_path(user_id, rel)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_bytes(data)
tmp.replace(path)
st = path.stat()
return VaultFileEntry(
path=_safe_relpath(rel),
size=st.st_size,
mtime=st.st_mtime,
sha256=_sha256_file(path),
)
def read_file(user_id: str, rel: str) -> bytes:
path = resolve_path(user_id, rel)
if not path.is_file():
raise FileNotFoundError(rel)
return path.read_bytes()
def delete_file(user_id: str, rel: str, *, tombstone: bool = True) -> None:
path = resolve_path(user_id, rel)
if not path.exists():
raise FileNotFoundError(rel)
if tombstone:
tomb = path.parent / f".tombstone-{path.name}"
meta = {
"path": _safe_relpath(rel),
"deleted_at": time.time(),
"sha256": _sha256_file(path) if path.is_file() else None,
}
tomb.write_text(json.dumps(meta), encoding="utf-8")
path.unlink()