Fix bugs across app + server, optimize UI/UX, add Gitea CI
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled

Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
  deletePageData, OCR FTS merge, migrations) in transactions to prevent data
  loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
  aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
  pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
  and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.

UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
  and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
  save/find), toolbar overflow handling, friendlier empty states, semantic OCR
  status badges, relative timestamps, 1-based page indicators, large-deck PPT
  navigation, and a scratchpad-scope label in split view.

Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
  fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
  guard, constant-time login, and split out heavy OCR deps so the API/tests run
  without them.

CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 03:18:00 +08:00
commit 72428dc075
210 changed files with 18171 additions and 0 deletions

View File

View File

@@ -0,0 +1,73 @@
"""JWT authentication utilities for BadNote."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
bearer_scheme = HTTPBearer()
def hash_password(password: str) -> str:
"""Hash a plaintext password with bcrypt."""
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against its hash."""
return pwd_context.verify(password, password_hash)
# A precomputed hash used to spend roughly the same time verifying a password
# for a non-existent user as for an existing one, so login response timing does
# not leak whether a username exists.
_DUMMY_HASH = pwd_context.hash("badnote-dummy-password")
def dummy_verify() -> None:
"""Run a throwaway bcrypt verification to equalise login timing."""
pwd_context.verify("badnote-dummy-password", _DUMMY_HASH)
def create_access_token(user_id: str) -> str:
"""Create a JWT access token for the given user_id."""
expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expiry_hours)
payload = {
"sub": user_id,
"exp": expire,
"iat": datetime.now(timezone.utc),
"jti": str(uuid4()),
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
def decode_access_token(token: str) -> dict:
"""Decode and validate a JWT token. Returns the payload dict."""
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
return payload
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
) from exc
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> str:
"""FastAPI dependency: extract user_id from Bearer token."""
payload = decode_access_token(credentials.credentials)
user_id: str | None = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing subject",
)
return user_id

View File

@@ -0,0 +1,73 @@
"""BadNote server configuration via environment variables."""
import os
import secrets
import warnings
def _resolve_jwt_secret() -> str:
"""Resolve the JWT signing secret.
Priority:
1. ``BADNOTE_JWT_SECRET`` environment variable (recommended for prod).
2. A persisted secret file (so the secret survives restarts and is shared
across worker processes).
3. A freshly generated secret, persisted to that file.
A per-process random secret (the previous behaviour) invalidated every
token on restart and gave each worker a different secret in multi-worker
deployments, so tokens were rejected at random. We persist instead.
"""
env_secret = os.environ.get("BADNOTE_JWT_SECRET")
if env_secret:
return env_secret
db_path = os.environ.get("BADNOTE_DB_PATH", "./data/badnote_server.db")
default_secret_file = os.path.join(os.path.dirname(db_path) or ".", ".jwt_secret")
secret_path = os.environ.get("BADNOTE_JWT_SECRET_FILE", default_secret_file)
try:
if os.path.exists(secret_path):
with open(secret_path, "r", encoding="utf-8") as f:
existing = f.read().strip()
if existing:
return existing
secret = secrets.token_urlsafe(48)
os.makedirs(os.path.dirname(secret_path) or ".", exist_ok=True)
# Restrictive permissions: only the owner may read the secret.
fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(secret)
return secret
except OSError:
warnings.warn(
"Could not persist a JWT secret; using an ephemeral one. "
"Set BADNOTE_JWT_SECRET to keep tokens valid across restarts.",
RuntimeWarning,
)
return secrets.token_urlsafe(48)
def _resolve_cors_origins() -> list[str]:
"""Parse the allowed CORS origins from ``BADNOTE_CORS_ORIGINS``.
Comma-separated list of origins. Empty by default; the app falls back to a
permissive ``*`` (without credentials) when none are configured.
"""
raw = os.environ.get("BADNOTE_CORS_ORIGINS", "")
return [o.strip() for o in raw.split(",") if o.strip()]
class Settings:
host: str = os.environ.get("BADNOTE_HOST", "0.0.0.0")
port: int = int(os.environ.get("BADNOTE_PORT", "8080"))
db_path: str = os.environ.get("BADNOTE_DB_PATH", "./data/badnote_server.db")
storage_path: str = os.environ.get("BADNOTE_STORAGE_PATH", "./data/storage")
queue_path: str = os.environ.get("BADNOTE_QUEUE_PATH", "./data/queue")
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()
settings = Settings()

View File

@@ -0,0 +1,90 @@
"""Async SQLite database layer for BadNote."""
import aiosqlite
from .config import settings
_db: aiosqlite.Connection | None = None
async def get_db() -> aiosqlite.Connection:
"""Return the global database connection."""
global _db
if _db is None:
_db = await aiosqlite.connect(settings.db_path)
_db.row_factory = aiosqlite.Row
await _db.execute("PRAGMA journal_mode=WAL")
await _db.execute("PRAGMA foreign_keys=ON")
return _db
async def close_db() -> None:
"""Close the global database connection."""
global _db
if _db is not None:
await _db.close()
_db = None
async def init_db() -> None:
"""Create all tables if they do not exist."""
db = await get_db()
await db.executescript("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
strokes_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
filename TEXT NOT NULL,
doc_type TEXT NOT NULL,
file_path TEXT NOT NULL,
page_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS annotations (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
annotation_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
label TEXT NOT NULL DEFAULT '',
color INTEGER NOT NULL DEFAULT 4283215696,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ocr_jobs (
id TEXT PRIMARY KEY,
note_id TEXT,
document_id TEXT,
page_number INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
result_text TEXT,
error_message TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
""")
await db.commit()

View File

@@ -0,0 +1,55 @@
"""BadNote FastAPI server — main application."""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .database import close_db, init_db
from .routers.auth_router import router as auth_router
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
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: create directories and init DB. Shutdown: close DB."""
os.makedirs(settings.storage_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()
yield
await close_db()
app = FastAPI(title="BadNote Server", version="1.0.0", 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(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
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.get("/api/ping")
async def ping() -> dict:
"""Health check endpoint."""
return {"status": "ok"}

View File

@@ -0,0 +1,136 @@
"""Pydantic request/response models for BadNote."""
from pydantic import BaseModel, Field
# ── Auth ────────────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=4, max_length=128)
class UserLogin(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
token: str
user_id: str
# ── Notes ───────────────────────────────────────────────────────────────────
class NoteCreate(BaseModel):
id: str
title: str = ""
tags: list[str] = Field(default_factory=list)
strokes_json: str = "[]"
class NoteUpdate(BaseModel):
title: str | None = None
tags: list[str] | None = None
strokes_json: str | None = None
class NoteResponse(BaseModel):
id: str
user_id: str
title: str
created_at: str
updated_at: str
tags: list[str]
strokes_json: str
# ── Documents ───────────────────────────────────────────────────────────────
class DocumentResponse(BaseModel):
id: str
user_id: str
filename: str
doc_type: str
page_count: int
created_at: str
updated_at: str
class AnnotationUpdate(BaseModel):
annotation_json: list[dict] = Field(default_factory=list)
class AnnotationResponse(BaseModel):
id: str
document_id: str
page_number: int
annotation_json: list[dict]
created_at: str
updated_at: str
class BookmarkCreate(BaseModel):
page_number: int
label: str = ""
color: int = 4283215696
class BookmarkResponse(BaseModel):
id: str
document_id: str
page_number: int
label: str
color: int
created_at: str
# ── OCR ─────────────────────────────────────────────────────────────────────
class OcrJobRequest(BaseModel):
note_id: str | None = None
document_id: str | None = None
page_number: int | None = None
class OcrJobStatus(BaseModel):
id: str
status: str
result_text: str | None = None
error_message: str | None = None
created_at: str
completed_at: str | None = None
class OcrResult(BaseModel):
id: str
note_id: str | None
document_id: str | None
page_number: int | None
status: str
result_text: str | None
error_message: str | None
created_at: str
completed_at: str | None
# ── Sync ────────────────────────────────────────────────────────────────────
class SyncNote(BaseModel):
id: str
title: str = ""
tags: list[str] = Field(default_factory=list)
strokes_json: str = "[]"
updated_at: str
class SyncPushRequest(BaseModel):
notes: list[SyncNote]
class SyncPullRequest(BaseModel):
since: str
class SyncResponse(BaseModel):
synced_count: int

View File

View File

@@ -0,0 +1,78 @@
"""OCR engine for BadNote using EasyOCR.
Lightweight handwriting-capable OCR using EasyOCR with CPU-only inference.
Suitable for Zen2 APU 25W / 16GB RAM (~200MB memory once loaded).
"""
import asyncio
import logging
logger = logging.getLogger(__name__)
# NOTE: `easyocr` (and its torch dependency) is heavy and optional. It is
# imported lazily inside the engine so that importing the FastAPI app — and
# running its test suite — does not require the OCR dependencies. Install them
# with `pip install -r requirements-ocr.txt` when running the OCR worker.
class OcrEngine:
"""EasyOCR-based text recognition engine.
Lazy-loads the reader on first use to avoid startup overhead.
Supports Chinese (simplified) + English. Runs on CPU only.
"""
def __init__(self):
self._reader = None
def _ensure_reader(self):
"""Lazy-initialize the EasyOCR reader."""
if self._reader is None:
import easyocr # imported lazily; see module docstring note
logger.info("Loading EasyOCR reader (ch_sim + en, CPU)...")
self._reader = easyocr.Reader(['ch_sim', 'en'], gpu=False)
logger.info("EasyOCR reader loaded")
async def recognize(self, image_bytes: bytes) -> str:
"""Recognize text from image bytes.
Args:
image_bytes: Raw image file bytes (PNG, JPEG, etc.)
Returns:
Recognized text as a single string, or empty string on failure.
"""
if not image_bytes:
return ""
loop = asyncio.get_event_loop()
try:
self._ensure_reader()
results = await loop.run_in_executor(
None, self._reader.readtext, image_bytes
)
# results is a list of (bbox, text, confidence) tuples
text_parts = [text for _, text, _ in results if text.strip()]
return ' '.join(text_parts)
except Exception as exc:
logger.error("OCR recognition failed: %s", exc)
return ""
async def recognize_file(self, image_path: str) -> str:
"""Recognize text from an image file path.
Args:
image_path: Path to the image file on disk.
Returns:
Recognized text as a single string, or empty string on failure.
"""
try:
with open(image_path, 'rb') as f:
image_bytes = f.read()
return await self.recognize(image_bytes)
except Exception as exc:
logger.error("Failed to read image file %s: %s", image_path, exc)
return ""

View File

@@ -0,0 +1,136 @@
"""File-based OCR job queue for BadNote."""
import json
import os
import shutil
from datetime import datetime, timezone
from uuid import uuid4
from ..config import settings
def _queue_dir(subdir: str) -> str:
path = os.path.join(settings.queue_path, subdir)
os.makedirs(path, exist_ok=True)
return path
def _job_path(job_id: str, subdir: str) -> str:
return os.path.join(_queue_dir(subdir), f"{job_id}.json")
def _read_job(path: str) -> dict | None:
try:
with open(path, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def _write_job(path: str, data: dict) -> None:
with open(path, "w") as f:
json.dump(data, f, indent=2)
def enqueue(job_data: dict) -> str:
"""Add a job to the pending queue. Returns job_id."""
job_id = job_data.get("id", str(uuid4()))
job_data["id"] = job_id
job_data["status"] = "pending"
job_data["created_at"] = datetime.now(timezone.utc).isoformat()
_write_job(_job_path(job_id, "pending"), job_data)
return job_id
def dequeue() -> dict | None:
"""Move the first pending job to processing. Returns job dict or None."""
pending_dir = _queue_dir("pending")
try:
files = sorted(os.listdir(pending_dir))
except FileNotFoundError:
return None
for fname in files:
if not fname.endswith(".json"):
continue
src = os.path.join(pending_dir, fname)
job = _read_job(src)
if job is None:
continue
job["status"] = "processing"
dst = _job_path(job["id"], "processing")
shutil.move(src, dst)
return job
return None
def complete(job_id: str, result: str) -> None:
"""Mark a job as done with result text."""
src = _job_path(job_id, "processing")
job = _read_job(src)
if job is None:
return
job["status"] = "done"
job["result_text"] = result
job["completed_at"] = datetime.now(timezone.utc).isoformat()
dst = _job_path(job_id, "done")
if os.path.exists(src):
os.remove(src)
_write_job(dst, job)
def fail(job_id: str, error: str) -> None:
"""Mark a job as failed with error message."""
src = _job_path(job_id, "processing")
job = _read_job(src)
if job is None:
return
job["status"] = "failed"
job["error_message"] = error
job["completed_at"] = datetime.now(timezone.utc).isoformat()
dst = _job_path(job_id, "failed")
if os.path.exists(src):
os.remove(src)
_write_job(dst, job)
def get_status(job_id: str) -> dict | None:
"""Check all directories for a job and return its data."""
for subdir in ("pending", "processing", "done", "failed"):
job = _read_job(_job_path(job_id, subdir))
if job is not None:
return job
return None
def get_jobs_for_note(note_id: str) -> list[dict]:
"""Return all completed OCR jobs for a given note_id."""
results = []
for subdir in ("done", "pending", "processing", "failed"):
dir_path = _queue_dir(subdir)
try:
for fname in os.listdir(dir_path):
if not fname.endswith(".json"):
continue
job = _read_job(os.path.join(dir_path, fname))
if job and job.get("note_id") == note_id:
results.append(job)
except FileNotFoundError:
pass
return results
def get_jobs_for_document(document_id: str) -> list[dict]:
"""Return all OCR jobs for a given document_id."""
results = []
for subdir in ("done", "pending", "processing", "failed"):
dir_path = _queue_dir(subdir)
try:
for fname in os.listdir(dir_path):
if not fname.endswith(".json"):
continue
job = _read_job(os.path.join(dir_path, fname))
if job and job.get("document_id") == document_id:
results.append(job)
except FileNotFoundError:
pass
return results

View File

@@ -0,0 +1,69 @@
"""Background OCR worker for BadNote.
Polls the file-based queue and processes jobs using the OcrEngine.
"""
import asyncio
import logging
import os
from ..config import settings
from .engine import OcrEngine
from . import queue as job_queue
logger = logging.getLogger(__name__)
async def run_worker(poll_interval: int = 5) -> None:
"""Poll the queue and process OCR jobs.
Loads images from the job's image_path and runs them through EasyOCR.
"""
# Ensure queue dirs exist
for subdir in ("pending", "processing", "done", "failed"):
os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True)
engine = OcrEngine()
logger.info("OCR worker started (poll_interval=%ds)", poll_interval)
while True:
job = job_queue.dequeue()
if job is not None:
job_id = job["id"]
logger.info("Processing OCR job %s", job_id)
try:
# Read image from the path specified in the job
image_path = job.get("image_path", "")
if image_path and os.path.exists(image_path):
result = await engine.recognize_file(image_path)
else:
# Fall back to image_bytes if provided inline
image_bytes = job.get("image_bytes", b"")
if isinstance(image_bytes, str):
import base64
image_bytes = base64.b64decode(image_bytes)
result = await engine.recognize(image_bytes)
job_queue.complete(job_id, result)
logger.info("OCR job %s completed: %d chars", job_id, len(result))
except Exception as exc:
logger.error("OCR job %s failed: %s", job_id, exc)
job_queue.fail(job_id, str(exc))
else:
await asyncio.sleep(poll_interval)
def main() -> None:
"""Entry point for `python -m badnote_server.ocr.worker`."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
try:
asyncio.run(run_worker())
except KeyboardInterrupt:
logger.info("OCR worker stopped")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,78 @@
"""Auth router for BadNote."""
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from ..auth import (
create_access_token,
dummy_verify,
get_current_user,
hash_password,
verify_password,
)
from ..database import get_db
from ..models import TokenResponse, UserCreate, UserLogin
router = APIRouter()
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: UserCreate) -> TokenResponse:
"""Register a new user."""
db = await get_db()
existing = await db.execute(
"SELECT id FROM users WHERE username = ?", (body.username,)
)
if await existing.fetchone() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken",
)
user_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
(user_id, body.username, hash_password(body.password), now),
)
await db.commit()
token = create_access_token(user_id)
return TokenResponse(token=token, user_id=user_id)
@router.post("/login", response_model=TokenResponse)
async def login(body: UserLogin) -> TokenResponse:
"""Authenticate and return a token."""
db = await get_db()
row = await (
await db.execute(
"SELECT id, password_hash FROM users WHERE username = ?", (body.username,)
)
).fetchone()
if row is None:
# Spend comparable time hashing so timing does not reveal whether the
# username exists.
dummy_verify()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
if not verify_password(body.password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
token = create_access_token(row["id"])
return TokenResponse(token=token, user_id=row["id"])
@router.post("/refresh", response_model=TokenResponse)
async def refresh(user_id: str = Depends(get_current_user)) -> TokenResponse:
"""Refresh an existing valid token."""
token = create_access_token(user_id)
return TokenResponse(token=token, user_id=user_id)

View File

@@ -0,0 +1,310 @@
"""Documents router for BadNote — upload, download, annotations, bookmarks."""
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from fastapi.responses import FileResponse
from ..auth import get_current_user
from ..database import get_db
from ..models import (
AnnotationResponse,
AnnotationUpdate,
BookmarkCreate,
BookmarkResponse,
DocumentResponse,
)
from ..storage import delete_document, get_document_path, save_document
router = APIRouter()
def _row_to_doc(row) -> DocumentResponse:
return DocumentResponse(
id=row["id"],
user_id=row["user_id"],
filename=row["filename"],
doc_type=row["doc_type"],
page_count=row["page_count"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
# ── Documents ───────────────────────────────────────────────────────────────
@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(
file: UploadFile = File(...),
doc_type: str = Form("pdf"),
page_count: int = Form(0),
user_id: str = Depends(get_current_user),
) -> DocumentResponse:
"""Upload a document file."""
doc_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
filename = file.filename or "document"
file_bytes = await file.read()
file_path = save_document(file_bytes, doc_id, filename)
db = await get_db()
await db.execute(
"""INSERT INTO documents (id, user_id, filename, doc_type, file_path, page_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(doc_id, user_id, filename, doc_type, file_path, page_count, now, now),
)
await db.commit()
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
row = await cursor.fetchone()
return _row_to_doc(row)
@router.get("", response_model=list[DocumentResponse])
async def list_documents(
user_id: str = Depends(get_current_user),
) -> list[DocumentResponse]:
"""List all documents for the current user."""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM documents WHERE user_id = ? ORDER BY created_at DESC",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_doc(r) for r in rows]
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_document(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> DocumentResponse:
"""Get document metadata."""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
row = await cursor.fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
return _row_to_doc(row)
@router.get("/{doc_id}/file")
async def download_document(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> FileResponse:
"""Stream document file download."""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
row = await cursor.fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
file_path = row["file_path"]
return FileResponse(path=file_path, filename=row["filename"], media_type="application/octet-stream")
@router.delete("/{doc_id}", status_code=status.HTTP_200_OK)
async def delete_document_endpoint(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> dict:
"""Delete document, its file, annotations, and bookmarks."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
delete_document(doc_id)
await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
await db.commit()
return {"deleted": doc_id}
# ── Annotations ─────────────────────────────────────────────────────────────
@router.get("/{doc_id}/annotations", response_model=list[AnnotationResponse])
async def list_annotations(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> list[AnnotationResponse]:
"""Get all annotations for a document."""
db = await get_db()
# Verify document ownership
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
cursor = await db.execute(
"SELECT * FROM annotations WHERE document_id = ? ORDER BY page_number",
(doc_id,),
)
rows = await cursor.fetchall()
return [
AnnotationResponse(
id=r["id"],
document_id=r["document_id"],
page_number=r["page_number"],
annotation_json=json.loads(r["annotation_json"]),
created_at=r["created_at"],
updated_at=r["updated_at"],
)
for r in rows
]
@router.put("/{doc_id}/annotations/{page}", response_model=AnnotationResponse, status_code=status.HTTP_200_OK)
async def update_annotation(
doc_id: str,
page: int,
body: AnnotationUpdate,
user_id: str = Depends(get_current_user),
) -> AnnotationResponse:
"""Create or replace annotations for a page."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
now = datetime.now(timezone.utc).isoformat()
annotation_json = json.dumps(body.annotation_json)
# Check if annotation for this page already exists
cursor = await db.execute(
"SELECT id FROM annotations WHERE document_id = ? AND page_number = ?",
(doc_id, page),
)
existing = await cursor.fetchone()
if existing:
ann_id = existing["id"]
await db.execute(
"UPDATE annotations SET annotation_json = ?, updated_at = ? WHERE id = ?",
(annotation_json, now, ann_id),
)
else:
ann_id = str(uuid4())
await db.execute(
"""INSERT INTO annotations (id, document_id, page_number, annotation_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(ann_id, doc_id, page, annotation_json, now, now),
)
await db.commit()
cursor = await db.execute("SELECT * FROM annotations WHERE id = ?", (ann_id,))
row = await cursor.fetchone()
return AnnotationResponse(
id=row["id"],
document_id=row["document_id"],
page_number=row["page_number"],
annotation_json=json.loads(row["annotation_json"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
# ── Bookmarks ───────────────────────────────────────────────────────────────
@router.get("/{doc_id}/bookmarks", response_model=list[BookmarkResponse])
async def list_bookmarks(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> list[BookmarkResponse]:
"""Get all bookmarks for a document."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
cursor = await db.execute(
"SELECT * FROM bookmarks WHERE document_id = ? ORDER BY page_number",
(doc_id,),
)
rows = await cursor.fetchall()
return [
BookmarkResponse(
id=r["id"],
document_id=r["document_id"],
page_number=r["page_number"],
label=r["label"],
color=r["color"],
created_at=r["created_at"],
)
for r in rows
]
@router.post("/{doc_id}/bookmarks", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED)
async def create_bookmark(
doc_id: str,
body: BookmarkCreate,
user_id: str = Depends(get_current_user),
) -> BookmarkResponse:
"""Add a bookmark to a document."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
bookmark_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"""INSERT INTO bookmarks (id, document_id, page_number, label, color, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(bookmark_id, doc_id, body.page_number, body.label, body.color, now),
)
await db.commit()
return BookmarkResponse(
id=bookmark_id,
document_id=doc_id,
page_number=body.page_number,
label=body.label,
color=body.color,
created_at=now,
)
@router.delete("/{doc_id}/bookmarks/{bookmark_id}", status_code=status.HTTP_200_OK)
async def delete_bookmark(
doc_id: str,
bookmark_id: str,
user_id: str = Depends(get_current_user),
) -> dict:
"""Delete a bookmark."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
cursor = await db.execute(
"SELECT id FROM bookmarks WHERE id = ? AND document_id = ?",
(bookmark_id, doc_id),
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
await db.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
await db.commit()
return {"deleted": bookmark_id}

View File

@@ -0,0 +1,117 @@
"""Notes CRUD router for BadNote."""
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, status
from ..auth import get_current_user
from ..database import get_db
from ..models import NoteCreate, NoteResponse, NoteUpdate
router = APIRouter()
def _row_to_note(row) -> NoteResponse:
return NoteResponse(
id=row["id"],
user_id=row["user_id"],
title=row["title"],
created_at=row["created_at"],
updated_at=row["updated_at"],
tags=json.loads(row["tags"]),
strokes_json=row["strokes_json"],
)
@router.get("", response_model=list[NoteResponse])
async def list_notes(
since: str | None = Query(None, description="ISO8601 timestamp filter"),
user_id: str = Depends(get_current_user),
) -> list[NoteResponse]:
"""List notes, optionally filtered by updated_at > since."""
db = await get_db()
if since:
cursor = await db.execute(
"SELECT * FROM notes WHERE user_id = ? AND updated_at > ? ORDER BY updated_at",
(user_id, since),
)
else:
cursor = await db.execute(
"SELECT * FROM notes WHERE user_id = ? ORDER BY updated_at",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_note(r) for r in rows]
@router.get("/{note_id}", response_model=NoteResponse)
async def get_note(
note_id: str,
user_id: str = Depends(get_current_user),
) -> NoteResponse:
"""Get a single note by ID."""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM notes WHERE id = ? AND user_id = ?", (note_id, user_id)
)
row = await cursor.fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
return _row_to_note(row)
@router.post("", response_model=NoteResponse, status_code=status.HTTP_201_CREATED)
async def upsert_note(
body: NoteCreate,
user_id: str = Depends(get_current_user),
) -> NoteResponse:
"""Create or update a note (upsert by id)."""
db = await get_db()
now = datetime.now(timezone.utc).isoformat()
tags_json = json.dumps(body.tags)
existing = await (
await db.execute(
"SELECT id FROM notes WHERE id = ? AND user_id = ?", (body.id, user_id)
)
).fetchone()
if existing:
await db.execute(
"""UPDATE notes SET title = ?, tags = ?, strokes_json = ?, updated_at = ?
WHERE id = ? AND user_id = ?""",
(body.title, tags_json, body.strokes_json, now, body.id, user_id),
)
else:
await db.execute(
"""INSERT INTO notes (id, user_id, title, created_at, updated_at, tags, strokes_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(body.id, user_id, body.title, now, now, tags_json, body.strokes_json),
)
await db.commit()
cursor = await db.execute(
"SELECT * FROM notes WHERE id = ? AND user_id = ?", (body.id, user_id)
)
row = await cursor.fetchone()
return _row_to_note(row)
@router.delete("/{note_id}", status_code=status.HTTP_200_OK)
async def delete_note(
note_id: str,
user_id: str = Depends(get_current_user),
) -> dict:
"""Delete a note."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM notes WHERE id = ? AND user_id = ?", (note_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
await db.execute("DELETE FROM notes WHERE id = ? AND user_id = ?", (note_id, user_id))
await db.commit()
return {"deleted": note_id}

View File

@@ -0,0 +1,99 @@
"""OCR router for BadNote."""
from fastapi import APIRouter, Depends, HTTPException, status
from ..auth import get_current_user
from ..database import get_db
from ..models import OcrJobRequest, OcrJobStatus, OcrResult
from ..ocr import queue as job_queue
router = APIRouter()
@router.post("/process", status_code=status.HTTP_201_CREATED)
async def submit_ocr_job(
body: OcrJobRequest,
user_id: str = Depends(get_current_user),
) -> dict:
"""Enqueue an OCR job."""
job_data: dict = {
"user_id": user_id,
"note_id": body.note_id,
"document_id": body.document_id,
"page_number": body.page_number,
}
job_id = job_queue.enqueue(job_data)
return {"job_id": job_id}
@router.get("/status/{job_id}", response_model=OcrJobStatus)
async def get_job_status(
job_id: str,
user_id: str = Depends(get_current_user),
) -> OcrJobStatus:
"""Get OCR job status and result."""
job = job_queue.get_status(job_id)
# Treat jobs owned by another user as not found to avoid leaking their data.
if job is None or job.get("user_id") != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
return OcrJobStatus(
id=job["id"],
status=job["status"],
result_text=job.get("result_text"),
error_message=job.get("error_message"),
created_at=job["created_at"],
completed_at=job.get("completed_at"),
)
@router.get("/results/{note_id}", response_model=list[OcrResult])
async def get_ocr_results(
note_id: str,
user_id: str = Depends(get_current_user),
) -> list[OcrResult]:
"""Get all OCR results for a note."""
jobs = [
j for j in job_queue.get_jobs_for_note(note_id) if j.get("user_id") == user_id
]
return [
OcrResult(
id=j["id"],
note_id=j.get("note_id"),
document_id=j.get("document_id"),
page_number=j.get("page_number"),
status=j["status"],
result_text=j.get("result_text"),
error_message=j.get("error_message"),
created_at=j["created_at"],
completed_at=j.get("completed_at"),
)
for j in jobs
]
@router.get("/results/document/{document_id}", response_model=list[OcrResult])
async def get_document_ocr_results(
document_id: str,
user_id: str = Depends(get_current_user),
) -> list[OcrResult]:
"""Get all OCR results for a document."""
jobs = [
j
for j in job_queue.get_jobs_for_document(document_id)
if j.get("user_id") == user_id
]
return [
OcrResult(
id=j["id"],
note_id=j.get("note_id"),
document_id=j.get("document_id"),
page_number=j.get("page_number"),
status=j["status"],
result_text=j.get("result_text"),
error_message=j.get("error_message"),
created_at=j["created_at"],
completed_at=j.get("completed_at"),
)
for j in jobs
]

View File

@@ -0,0 +1,90 @@
"""Sync router for BadNote — push/pull notes."""
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, status
from ..auth import get_current_user
from ..database import get_db
from ..models import NoteResponse, SyncPullRequest, SyncPushRequest, SyncResponse
router = APIRouter()
@router.post("/push", response_model=SyncResponse, status_code=status.HTTP_200_OK)
async def sync_push(
body: SyncPushRequest,
user_id: str = Depends(get_current_user),
) -> SyncResponse:
"""Upsert notes from client."""
db = await get_db()
synced = 0
for note in body.notes:
now = datetime.now(timezone.utc).isoformat()
tags_json = json.dumps(note.tags)
existing = await (
await db.execute(
"SELECT id FROM notes WHERE id = ? AND user_id = ?", (note.id, user_id)
)
).fetchone()
if existing:
# Last-writer-wins by timestamp: only apply the client's version if
# it is newer than what the server already has, so a stale client
# cannot overwrite a more recent note (data loss).
await db.execute(
"""UPDATE notes SET title = ?, tags = ?, strokes_json = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND updated_at < ?""",
(
note.title,
tags_json,
note.strokes_json,
note.updated_at,
note.id,
user_id,
note.updated_at,
),
)
else:
await db.execute(
"""INSERT INTO notes (id, user_id, title, created_at, updated_at, tags, strokes_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(note.id, user_id, note.title, now, note.updated_at, tags_json, note.strokes_json),
)
synced += 1
await db.commit()
return SyncResponse(synced_count=synced)
@router.post("/pull", status_code=status.HTTP_200_OK)
async def sync_pull(
body: SyncPullRequest,
user_id: str = Depends(get_current_user),
) -> dict:
"""Pull notes updated since a timestamp."""
db = await get_db()
cursor = await db.execute(
"""SELECT * FROM notes WHERE user_id = ? AND updated_at > ? ORDER BY updated_at""",
(user_id, body.since),
)
rows = await cursor.fetchall()
notes = [
NoteResponse(
id=r["id"],
user_id=r["user_id"],
title=r["title"],
created_at=r["created_at"],
updated_at=r["updated_at"],
tags=json.loads(r["tags"]),
strokes_json=r["strokes_json"],
).model_dump()
for r in rows
]
return {"notes": notes}

View File

@@ -0,0 +1,56 @@
"""File-system document storage for BadNote."""
import os
import shutil
from .config import settings
def _safe_filename(filename: str) -> str:
"""Reduce a client-supplied filename to a safe basename.
Prevents path traversal (e.g. ``../../etc/passwd``) by stripping any
directory components and parent references before the name is joined onto
the storage path.
"""
name = os.path.basename(filename or "")
name = name.replace("\\", "").replace("/", "").strip()
if not name or name in (".", ".."):
name = "document"
return name
def _resolve_within(base: str, *parts: str) -> str:
"""Join ``parts`` onto ``base`` and ensure the result stays inside ``base``."""
base_abs = os.path.abspath(base)
target = os.path.abspath(os.path.join(base_abs, *parts))
if os.path.commonpath([base_abs, target]) != base_abs:
raise ValueError("Resolved path escapes the storage directory")
return target
def save_document(file_bytes: bytes, doc_id: str, filename: str) -> str:
"""Save uploaded file bytes to storage. Returns the stored file path."""
safe_doc_id = _safe_filename(doc_id)
safe_name = _safe_filename(filename)
doc_dir = _resolve_within(settings.storage_path, safe_doc_id)
os.makedirs(doc_dir, exist_ok=True)
file_path = _resolve_within(doc_dir, safe_name)
with open(file_path, "wb") as f:
f.write(file_bytes)
return file_path
def get_document_path(doc_id: str, filename: str) -> str:
"""Return the full path to a stored document file."""
safe_doc_id = _safe_filename(doc_id)
safe_name = _safe_filename(filename)
return _resolve_within(settings.storage_path, safe_doc_id, safe_name)
def delete_document(doc_id: str) -> None:
"""Remove a document's directory and all its contents."""
safe_doc_id = _safe_filename(doc_id)
doc_dir = _resolve_within(settings.storage_path, safe_doc_id)
if os.path.isdir(doc_dir):
shutil.rmtree(doc_dir)