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

52
server/README.md Normal file
View File

@@ -0,0 +1,52 @@
# BadNote Server (Optional)
This directory contains an **optional** Python/FastAPI backend. The BadNote desktop app does **not** depend on it.
The Flutter client is local-first:
- Notes and documents are stored in SQLite on device
- OCR runs locally via Windows built-in OCR
- Full-text search uses on-device FTS5
## Why this exists
This server was an early experiment for:
- Multi-device note sync (push/pull)
- Server-side OCR with EasyOCR
- JWT authentication
These features are **not wired into the current client**. The client previously had incomplete sync/OCR scaffolding that has been removed in favor of local processing.
## Running (if you want to experiment)
```bash
cd server
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080
```
API docs: http://localhost:8080/docs
The OCR worker has heavy extra dependencies (EasyOCR + torch). Install them only
if you want to run it:
```bash
pip install -r requirements-ocr.txt
python -m badnote_server.ocr.worker
```
### Security notes
- Set `BADNOTE_JWT_SECRET` in production. If unset, a secret is generated once
and persisted to `<data>/.jwt_secret` so tokens survive restarts.
- Restrict origins with `BADNOTE_CORS_ORIGINS` (comma-separated). The default is
permissive (`*`, without credentials) for local development.
## Status
- Kept for reference and future optional sync work
- Not part of the primary development path
- No guarantee of API compatibility with future client versions

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)

View File

@@ -0,0 +1,8 @@
# Optional OCR worker dependencies. Heavy (pulls in torch). Only required to
# run `python -m badnote_server.ocr.worker`. The API server and tests do not
# need these.
-r requirements.txt
easyocr>=1.7.0
opencv-python-headless>=4.10.0
Pillow>=10.0.0

17
server/requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
pydantic>=2.10.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
aiosqlite>=0.21.0
python-multipart>=0.0.20
aiofiles>=24.0.0
httpx>=0.28.0
# Testing
pytest>=8.0.0
pytest-asyncio>=0.25.0
# OCR dependencies (easyocr, opencv, torch) are heavy and optional. They are
# only needed to run the background OCR worker, not the API server or its tests.
# Install them with: pip install -r requirements-ocr.txt

4
server/scripts/run.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")/.."
source .venv/bin/activate 2>/dev/null || { echo "Run setup.sh first"; exit 1; }
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080 --workers 1

10
server/scripts/setup.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/.."
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
mkdir -p data/storage data/queue/pending data/queue/processing data/queue/done data/queue/failed
echo "BadNote server setup complete."
echo "Run: source .venv/bin/activate && uvicorn badnote_server.main:app --reload"
echo "To run the OCR worker, also install OCR deps: pip install -r requirements-ocr.txt"

4
server/scripts/worker.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")/.."
source .venv/bin/activate 2>/dev/null || { echo "Run setup.sh first"; exit 1; }
nice -n 10 python -m badnote_server.ocr.worker

0
server/tests/__init__.py Normal file
View File

43
server/tests/conftest.py Normal file
View File

@@ -0,0 +1,43 @@
"""Shared test fixtures for BadNote tests."""
import os
import shutil
import sys
import tempfile
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
# Ensure the server package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["BADNOTE_STORAGE_PATH"] = "/tmp/badnote_test_storage"
os.environ["BADNOTE_QUEUE_PATH"] = "/tmp/badnote_test_queue"
os.environ["BADNOTE_JWT_SECRET"] = "test-secret-key-for-testing-only"
from badnote_server.config import settings # noqa: E402
from badnote_server.main import app # noqa: E402
from badnote_server.database import get_db, close_db, init_db # noqa: E402
@pytest_asyncio.fixture(autouse=True)
async def setup_db(tmp_path):
"""Fresh DB and clean queue for each test."""
db_path = str(tmp_path / "test.db")
settings.db_path = db_path
# Clean the queue directory before each test
queue_path = settings.queue_path
if os.path.exists(queue_path):
shutil.rmtree(queue_path)
await init_db()
yield
await close_db()
@pytest_asyncio.fixture
async def client():
"""Async test client for the FastAPI app."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac

18
server/tests/helpers.py Normal file
View File

@@ -0,0 +1,18 @@
"""Shared test helpers for BadNote tests."""
from httpx import AsyncClient
async def register_and_login(client: AsyncClient) -> tuple[str, str]:
"""Helper: register a user and return (token, user_id)."""
resp = await client.post(
"/api/auth/register",
json={"username": "testuser", "password": "testpass123"},
)
data = resp.json()
return data["token"], data["user_id"]
def auth_header(token: str) -> dict:
"""Return Authorization header dict."""
return {"Authorization": f"Bearer {token}"}

82
server/tests/test_auth.py Normal file
View File

@@ -0,0 +1,82 @@
"""Tests for auth endpoints."""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
import pytest
import pytest_asyncio
from httpx import AsyncClient
from helpers import auth_header, register_and_login
@pytest.mark.asyncio
async def test_register(client: AsyncClient):
resp = await client.post(
"/api/auth/register",
json={"username": "newuser", "password": "password123"},
)
assert resp.status_code == 201
data = resp.json()
assert "token" in data
assert "user_id" in data
@pytest.mark.asyncio
async def test_register_duplicate(client: AsyncClient):
await client.post(
"/api/auth/register",
json={"username": "dupuser", "password": "password123"},
)
resp = await client.post(
"/api/auth/register",
json={"username": "dupuser", "password": "password123"},
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_login(client: AsyncClient):
await client.post(
"/api/auth/register",
json={"username": "loginuser", "password": "mypassword"},
)
resp = await client.post(
"/api/auth/login",
json={"username": "loginuser", "password": "mypassword"},
)
assert resp.status_code == 200
data = resp.json()
assert "token" in data
assert "user_id" in data
@pytest.mark.asyncio
async def test_login_wrong_password(client: AsyncClient):
await client.post(
"/api/auth/register",
json={"username": "wrongpw", "password": "correct"},
)
resp = await client.post(
"/api/auth/login",
json={"username": "wrongpw", "password": "incorrect"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_refresh(client: AsyncClient):
token, user_id = await register_and_login(client)
resp = await client.post(
"/api/auth/refresh",
headers=auth_header(token),
)
assert resp.status_code == 200
data = resp.json()
assert "token" in data
assert data["user_id"] == user_id
@pytest.mark.asyncio
async def test_refresh_no_token(client: AsyncClient):
resp = await client.post("/api/auth/refresh")
assert resp.status_code in (401, 403)

146
server/tests/test_notes.py Normal file
View File

@@ -0,0 +1,146 @@
"""Tests for notes CRUD and sync endpoints."""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
import pytest
import pytest_asyncio
from httpx import AsyncClient
from helpers import auth_header, register_and_login
@pytest.mark.asyncio
async def test_create_note(client: AsyncClient):
token, _ = await register_and_login(client)
resp = await client.post(
"/api/notes",
json={
"id": "note-001",
"title": "Test Note",
"tags": ["tag1", "tag2"],
"strokes_json": "[{\"x\":1,\"y\":2}]",
},
headers=auth_header(token),
)
assert resp.status_code == 201
data = resp.json()
assert data["id"] == "note-001"
assert data["title"] == "Test Note"
assert data["tags"] == ["tag1", "tag2"]
@pytest.mark.asyncio
async def test_get_note(client: AsyncClient):
token, _ = await register_and_login(client)
await client.post(
"/api/notes",
json={"id": "note-002", "title": "Fetch Me", "tags": [], "strokes_json": "[]"},
headers=auth_header(token),
)
resp = await client.get("/api/notes/note-002", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["title"] == "Fetch Me"
@pytest.mark.asyncio
async def test_get_note_not_found(client: AsyncClient):
token, _ = await register_and_login(client)
resp = await client.get("/api/notes/nonexistent", headers=auth_header(token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_note(client: AsyncClient):
token, _ = await register_and_login(client)
await client.post(
"/api/notes",
json={"id": "note-003", "title": "Original", "tags": [], "strokes_json": "[]"},
headers=auth_header(token),
)
resp = await client.post(
"/api/notes",
json={"id": "note-003", "title": "Updated", "tags": ["new"], "strokes_json": "[1]"},
headers=auth_header(token),
)
assert resp.status_code == 201
assert resp.json()["title"] == "Updated"
assert resp.json()["tags"] == ["new"]
@pytest.mark.asyncio
async def test_delete_note(client: AsyncClient):
token, _ = await register_and_login(client)
await client.post(
"/api/notes",
json={"id": "note-004", "title": "Delete Me", "tags": [], "strokes_json": "[]"},
headers=auth_header(token),
)
resp = await client.delete("/api/notes/note-004", headers=auth_header(token))
assert resp.status_code == 200
resp = await client.get("/api/notes/note-004", headers=auth_header(token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_list_notes_with_since(client: AsyncClient):
token, _ = await register_and_login(client)
await client.post(
"/api/notes",
json={"id": "note-005", "title": "Old", "tags": [], "strokes_json": "[]"},
headers=auth_header(token),
)
resp = await client.get("/api/notes", headers=auth_header(token))
assert resp.status_code == 200
assert len(resp.json()) >= 1
resp = await client.get(
"/api/notes?since=2099-01-01T00:00:00+00:00",
headers=auth_header(token),
)
assert resp.status_code == 200
assert len(resp.json()) == 0
@pytest.mark.asyncio
async def test_notes_require_auth(client: AsyncClient):
resp = await client.get("/api/notes")
assert resp.status_code in (401, 403)
# ── Sync ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_sync_push(client: AsyncClient):
token, _ = await register_and_login(client)
resp = await client.post(
"/api/sync/push",
json={
"notes": [
{"id": "sync-1", "title": "Synced", "tags": [], "strokes_json": "[]", "updated_at": "2024-01-01T00:00:00+00:00"},
{"id": "sync-2", "title": "Also", "tags": ["t"], "strokes_json": "[]", "updated_at": "2024-01-02T00:00:00+00:00"},
]
},
headers=auth_header(token),
)
assert resp.status_code == 200
assert resp.json()["synced_count"] == 2
@pytest.mark.asyncio
async def test_sync_pull(client: AsyncClient):
token, _ = await register_and_login(client)
await client.post(
"/api/sync/push",
json={"notes": [{"id": "pull-1", "title": "Pull Me", "tags": [], "strokes_json": "[]", "updated_at": "2024-06-01T00:00:00+00:00"}]},
headers=auth_header(token),
)
resp = await client.post(
"/api/sync/pull",
json={"since": "2024-01-01T00:00:00+00:00"},
headers=auth_header(token),
)
assert resp.status_code == 200
notes = resp.json()["notes"]
assert len(notes) >= 1
assert any(n["id"] == "pull-1" for n in notes)

82
server/tests/test_ocr.py Normal file
View File

@@ -0,0 +1,82 @@
"""Tests for OCR endpoints."""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
import pytest
import pytest_asyncio
from httpx import AsyncClient
from helpers import auth_header, register_and_login
@pytest.mark.asyncio
async def test_submit_ocr_job(client: AsyncClient):
token, _ = await register_and_login(client)
resp = await client.post(
"/api/ocr/process",
json={"note_id": "note-ocr-1", "document_id": None, "page_number": None},
headers=auth_header(token),
)
assert resp.status_code == 201
data = resp.json()
assert "job_id" in data
@pytest.mark.asyncio
async def test_get_job_status(client: AsyncClient):
token, _ = await register_and_login(client)
resp = await client.post(
"/api/ocr/process",
json={"note_id": "note-ocr-2", "document_id": None, "page_number": None},
headers=auth_header(token),
)
job_id = resp.json()["job_id"]
resp = await client.get(f"/api/ocr/status/{job_id}", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["id"] == job_id
assert data["status"] == "pending"
@pytest.mark.asyncio
async def test_get_job_status_not_found(client: AsyncClient):
token, _ = await register_and_login(client)
resp = await client.get("/api/ocr/status/nonexistent", headers=auth_header(token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_ocr_results_empty(client: AsyncClient):
token, _ = await register_and_login(client)
resp = await client.get("/api/ocr/results/note-no-jobs", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_get_ocr_results_with_jobs(client: AsyncClient):
token, _ = await register_and_login(client)
await client.post(
"/api/ocr/process",
json={"note_id": "note-ocr-3", "document_id": None, "page_number": None},
headers=auth_header(token),
)
await client.post(
"/api/ocr/process",
json={"note_id": "note-ocr-3", "document_id": None, "page_number": None},
headers=auth_header(token),
)
resp = await client.get("/api/ocr/results/note-ocr-3", headers=auth_header(token))
assert resp.status_code == 200
assert len(resp.json()) == 2
@pytest.mark.asyncio
async def test_ocr_requires_auth(client: AsyncClient):
resp = await client.post(
"/api/ocr/process",
json={"note_id": "x", "document_id": None, "page_number": None},
)
assert resp.status_code in (401, 403)