Rescue Paperjet v1 implementation

This commit is contained in:
Elijah 2026-08-14 21:05:22 -07:00
parent 7ff5c8130d
commit db9e2ca51b
83 changed files with 6186 additions and 3894 deletions

View file

@ -2,9 +2,14 @@
from fastapi import APIRouter
from app.api.v1.annotations import router as annotations_router
from app.api.v1.assets import router as assets_router
from app.api.v1.auth import router as auth_router
from app.api.v1.documents import router as documents_router
from app.api.v1.export import router as export_router
from app.api.v1.health import router as health_router
from app.api.v1.versions import router as versions_router
from app.config import settings
router = APIRouter(prefix="/api/v1")
@ -15,17 +20,18 @@ router.include_router(auth_router)
router.include_router(documents_router)
# Assets
from app.api.v1.assets import router as assets_router
router.include_router(assets_router)
# Annotations
from app.api.v1.annotations import router as annotations_router
router.include_router(annotations_router)
# Versions and export
router.include_router(versions_router)
router.include_router(export_router)
# Health (unauthenticated)
router.include_router(health_router)
from app.config import settings
if settings.DEBUG:
from app.api.v1.debug import router as debug_router
router.include_router(debug_router)

View file

@ -1,91 +1,95 @@
from typing import Any
"""Opaque working-state annotation endpoints."""
import json
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from datetime import datetime, timezone
import json
from app.db import get_db
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.auth.dependencies import get_current_user, verify_csrf
from app.schemas.annotations import AnnotationStateResponse, AnnotationStateUpdateRequest, AnnotationStateUpdateResponse
from app.db import get_db
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.schemas.annotations import (
AnnotationPayload,
AnnotationStateResponse,
AnnotationStateUpdateRequest,
AnnotationStateUpdateResponse,
)
router = APIRouter(prefix="/documents", tags=["annotations"])
router = APIRouter(prefix="/documents", tags=["Annotations"])
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse)
async def get_annotations(
document_id: str,
db: Session = Depends(get_db),
user_id: Any = Depends(get_current_user)
):
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
if not doc:
def _utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _document_or_404(document_id: str, db: Session) -> Document:
document = db.scalar(
select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))
)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
return document
def _read_data(state: AnnotationState | None) -> list[AnnotationPayload]:
if not state:
return AnnotationStateResponse(data=[], updatedAt=datetime.now(timezone.utc))
return []
try:
data = json.loads(state.data)
except json.JSONDecodeError:
data = []
return AnnotationStateResponse(
data=data,
updatedAt=datetime.fromisoformat(state.updated_at)
)
return []
return data if isinstance(data, list) else []
@router.put("/{document_id}/annotations", response_model=AnnotationStateUpdateResponse, dependencies=[Depends(verify_csrf)])
async def update_annotations(
@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse)
def get_annotations(
document_id: str,
db: Session = Depends(get_db),
_user_id: int = Depends(get_current_user),
) -> AnnotationStateResponse:
_document_or_404(document_id, db)
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
updated_at = datetime.now(UTC) if not state else _utc(datetime.fromisoformat(state.updated_at))
return AnnotationStateResponse(data=_read_data(state), updatedAt=updated_at)
@router.put(
"/{document_id}/annotations",
response_model=AnnotationStateUpdateResponse,
dependencies=[Depends(verify_csrf)],
)
def update_annotations(
document_id: str,
request: AnnotationStateUpdateRequest,
db: Session = Depends(get_db),
user_id: Any = Depends(get_current_user)
):
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
_user_id: int = Depends(get_current_user),
) -> AnnotationStateUpdateResponse:
document = _document_or_404(document_id, db)
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
# Check for stale tab
if state and request.baseUpdatedAt:
current_updated_at = datetime.fromisoformat(state.updated_at).replace(tzinfo=timezone.utc)
request_updated_at = request.baseUpdatedAt.replace(tzinfo=timezone.utc)
# Allow a small grace period for timezone/parsing discrepancies
current_updated_at = _utc(datetime.fromisoformat(state.updated_at))
request_updated_at = _utc(request.baseUpdatedAt)
if abs((current_updated_at - request_updated_at).total_seconds()) > 1.0:
raise HTTPException(
status_code=409,
detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}"
status_code=409,
detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}",
)
now = _now_iso()
import json
# serialize request.data list of models to json string
data_json = json.dumps([a.model_dump(mode="json", by_alias=True) for a in request.data])
if not state:
state = AnnotationState(
document_id=document_id,
data=data_json,
updated_at=now
)
db.add(state)
else:
now = _now_iso()
data_json = json.dumps(request.data, separators=(",", ":"))
if state:
state.data = data_json
state.updated_at = now
# Bump document updated_at
doc.updated_at = now
else:
db.add(AnnotationState(document_id=document_id, data=data_json, updated_at=now))
document.updated_at = now
db.commit()
return AnnotationStateUpdateResponse(updatedAt=datetime.fromisoformat(now))

View file

@ -1,58 +1,101 @@
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
"""Authenticated binary assets used by annotations."""
import uuid
import shutil
from pathlib import Path
from app.db import get_db
from app.auth.dependencies import get_current_user
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.config import settings
from app.db import get_db
from app.models.document import Document
router = APIRouter(prefix="/documents", tags=["Assets"])
_ASSET_CHUNK_SIZE = 1024 * 1024
_MAX_ASSET_BYTES = 25 * 1024 * 1024
def get_asset_path(document_id: str, ref: str) -> Path:
# Ensure directory exists
dir_path = settings.PDF_STORAGE_PATH / "assets" / document_id
dir_path.mkdir(parents=True, exist_ok=True)
return dir_path / ref
@router.post("/{id}/assets")
def get_asset_path(document_id: str, ref: str, *, create: bool = False) -> Path:
"""Return a safe asset path; only writes are allowed to create directories."""
try:
asset_ref = str(uuid.UUID(ref))
except ValueError as err:
raise HTTPException(status_code=404, detail="Asset not found") from err
directory = settings.PDF_STORAGE_PATH / "assets" / document_id
if create:
directory.mkdir(parents=True, exist_ok=True)
return directory / asset_ref
def _is_supported_image(filepath: Path) -> bool:
with filepath.open("rb") as file:
header = file.read(12)
return (
header.startswith(b"\x89PNG\r\n\x1a\n")
or header.startswith(b"\xff\xd8\xff")
or header.startswith((b"GIF87a", b"GIF89a"))
or (header.startswith(b"RIFF") and header[8:12] == b"WEBP")
)
@router.post("/{id}/assets", dependencies=[Depends(verify_csrf)])
async def upload_asset(
id: str,
file: UploadFile = File(...),
user_id: int = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload a binary asset (like an image or signature) for a document."""
# In a real app we'd verify the user owns the document here
# For this single-user app, we trust the ID
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Only image assets are supported")
db: Session = Depends(get_db),
) -> dict[str, str]:
"""Upload a PNG, JPEG, GIF, or WebP annotation asset."""
document = db.scalar(select(Document).where(Document.id == id))
if not document:
raise HTTPException(status_code=404, detail="Document not found")
ref = str(uuid.uuid4())
filepath = get_asset_path(id, ref)
try:
with open(filepath, "wb") as f:
shutil.copyfileobj(file.file, f)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to save asset: {e}")
filepath = get_asset_path(id, ref, create=True)
total_bytes = 0
try:
with filepath.open("wb") as destination:
while chunk := file.file.read(_ASSET_CHUNK_SIZE):
total_bytes += len(chunk)
if total_bytes > _MAX_ASSET_BYTES:
raise HTTPException(status_code=413, detail="Image asset is too large")
destination.write(chunk)
except HTTPException:
filepath.unlink(missing_ok=True)
raise
except Exception as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Failed to save asset") from err
if not _is_supported_image(filepath):
filepath.unlink(missing_ok=True)
raise HTTPException(
status_code=400,
detail="Only PNG, JPEG, GIF, and WebP images are supported",
)
return {"ref": ref, "url": f"/api/v1/documents/{id}/assets/{ref}"}
return {
"ref": ref,
"url": f"/api/v1/documents/{id}/assets/{ref}"
}
@router.get("/{id}/assets/{ref}")
async def get_asset(
id: str,
ref: str,
):
"""Serve a binary asset."""
db: Session = Depends(get_db),
user_id: int = Depends(get_current_user),
) -> FileResponse:
"""Serve an annotation asset to an authenticated session."""
document = db.scalar(select(Document).where(Document.id == id))
if not document:
raise HTTPException(status_code=404, detail="Document not found")
filepath = get_asset_path(id, ref)
if not filepath.exists() or not filepath.is_file():
raise HTTPException(status_code=404, detail="Asset not found")
return FileResponse(filepath)

View file

@ -31,37 +31,37 @@ def get_auth_status(request: Request, db: Session = Depends(get_db)) -> AuthStat
app_settings = Settings()
db.add(app_settings)
db.commit()
setup_required = app_settings.password_hash is None
token = request.cookies.get(settings.COOKIE_NAME)
logged_in = False
if token:
user_id = verify_session(token)
if user_id is not None:
logged_in = True
return AuthStatusResponse(setupRequired=setup_required, loggedIn=logged_in)
@router.post("/setup", dependencies=[Depends(verify_csrf)])
def setup_password(
data: SetupRequest,
response: Response,
data: SetupRequest,
response: Response,
db: Session = Depends(get_db)
) -> dict[str, str]:
"""First-run setup."""
app_settings = db.query(Settings).first()
if app_settings and app_settings.password_hash:
raise HTTPException(status_code=409, detail="Password already set")
if not app_settings:
app_settings = Settings()
db.add(app_settings)
app_settings.password_hash = hash_password(data.password)
db.commit()
# Log them in automatically
create_session(response)
return {"status": "ok"}
@ -77,16 +77,16 @@ def login(
"""Validate password and issue session cookie."""
ip = get_client_ip(request)
check_rate_limit(ip)
app_settings = db.query(Settings).first()
if not app_settings or not app_settings.password_hash:
record_failed_attempt(ip)
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(app_settings.password_hash, data.password):
record_failed_attempt(ip)
raise HTTPException(status_code=401, detail="Invalid credentials")
clear_attempts(ip)
create_session(response)
return {"status": "ok"}
@ -108,17 +108,17 @@ def change_password(
"""Change an existing password."""
ip = get_client_ip(request)
check_rate_limit(ip)
app_settings = db.query(Settings).first()
if not app_settings or not app_settings.password_hash:
raise HTTPException(status_code=400, detail="Setup required first")
if not verify_password(app_settings.password_hash, data.current_password):
record_failed_attempt(ip)
raise HTTPException(status_code=401, detail="Invalid current password")
clear_attempts(ip)
app_settings.password_hash = hash_password(data.new_password)
db.commit()
return {"status": "ok"}

View file

@ -28,28 +28,28 @@ async def verify_coords(req: VerifyCoordsRequest):
try:
doc = pymupdf.open(filepath)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as error:
raise HTTPException(status_code=500, detail=str(error)) from error
if req.page < 0 or req.page >= len(doc):
doc.close()
raise HTTPException(status_code=400, detail="Invalid page number")
page = doc[req.page]
# Canonical space is defined as relative to the unrotated CropBox.
# PyMuPDF naturally draws relative to the current rotation.
# By temporarily setting rotation to 0, we can draw directly using canonical coordinates.
original_rotation = page.rotation
if original_rotation != 0:
page.set_rotation(0)
rect = pymupdf.Rect(req.x, req.y, req.x + req.width, req.y + req.height)
page.draw_rect(rect, color=(1, 0, 0), width=2, fill=(1, 0, 0), fill_opacity=0.3)
if original_rotation != 0:
page.set_rotation(original_rotation)
pdf_bytes = doc.write()
doc.close()

View file

@ -1,28 +1,31 @@
"""Document management endpoints."""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.config import settings
from app.db import get_db
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.version import Version
from app.schemas.document import (
DocumentListResponse, DocumentMeta, BulkDeleteRequest,
BulkRestoreRequest, DocumentUpdateRequest
BulkDeleteRequest,
BulkRestoreRequest,
DocumentListResponse,
DocumentMeta,
DocumentUpdateRequest,
)
from app.services.storage import save_upload_file, delete_pdf_file
from app.services.thumbnails import generate_thumbnail, delete_thumbnail
from app.config import settings
from app.services.assets import delete_asset_directory
from app.services.storage import delete_pdf_file, safe_filename, save_upload_file
from app.services.thumbnails import delete_thumbnail, generate_thumbnail
router = APIRouter(
prefix="/documents",
tags=["documents"],
dependencies=[Depends(get_current_user)]
prefix="/documents", tags=["documents"], dependencies=[Depends(get_current_user)]
)
@ -31,109 +34,135 @@ def list_documents(
query: str = "",
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
db: Session = Depends(get_db),
) -> DocumentListResponse:
"""List non-trashed documents."""
q = db.query(Document).filter(Document.deleted_at.is_(None))
if query:
q = q.filter(Document.title.ilike(f"%{query}%"))
total = q.count()
items = q.order_by(desc(Document.updated_at)).offset(skip).limit(limit).all()
return DocumentListResponse(items=items, total=total)
return DocumentListResponse(
items=[DocumentMeta.model_validate(item) for item in items], total=total
)
@router.get("/trash", response_model=DocumentListResponse)
def list_trash(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
db: Session = Depends(get_db),
) -> DocumentListResponse:
"""List trashed documents."""
q = db.query(Document).filter(Document.deleted_at.is_not(None))
total = q.count()
items = q.order_by(desc(Document.deleted_at)).offset(skip).limit(limit).all()
return DocumentListResponse(items=items, total=total)
return DocumentListResponse(
items=[DocumentMeta.model_validate(item) for item in items], total=total
)
@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
@router.post(
"",
dependencies=[Depends(verify_csrf)],
response_model=DocumentMeta,
status_code=status.HTTP_201_CREATED,
)
async def upload_document(
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
file: UploadFile = File(...), db: Session = Depends(get_db)
) -> DocumentMeta:
"""Upload a new PDF document."""
# Note: Starlette/FastAPI loads file into memory/spooled file.
# To truly enforce max size before reading we rely on Nginx and logic in storage.py.
# But checking size if available:
if file.size and file.size > settings.MAX_UPLOAD_MB * 1024 * 1024:
raise HTTPException(status_code=413, detail="File too large")
file_id, size_bytes, page_count = await save_upload_file(file)
# Generate thumbnail synchronously
generate_thumbnail(file_id)
now = datetime.now(timezone.utc).isoformat()
thumbnail_generated = generate_thumbnail(file_id)
now = datetime.now(UTC).isoformat()
filename = safe_filename(file.filename)
doc = Document(
id=file_id,
title=file.filename or "Untitled",
original_filename=file.filename or "Untitled.pdf",
title=filename,
original_filename=filename,
file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"),
thumbnail_path=(
str(settings.THUMBNAILS_PATH / f"{file_id}.png") if thumbnail_generated else None
),
page_count=page_count,
size_bytes=size_bytes,
created_at=now,
updated_at=now
updated_at=now,
)
db.add(doc)
db.commit()
try:
db.add(doc)
db.commit()
except Exception:
delete_pdf_file(file_id)
delete_thumbnail(file_id)
delete_asset_directory(file_id)
raise
db.refresh(doc)
return doc
return DocumentMeta.model_validate(doc)
@router.get("/{id}", response_model=DocumentMeta)
def get_document(id: str, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == id).first()
def get_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
doc = db.query(Document).filter(Document.id == id, Document.deleted_at.is_(None)).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
return DocumentMeta.model_validate(doc)
@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def update_document(id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)):
def update_document(
id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)
) -> DocumentMeta:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if update.title is not None:
doc.title = update.title
title = update.title.strip()
if not title:
raise HTTPException(status_code=422, detail="Title cannot be empty")
doc.title = title
if update.in_trash is not None:
if update.in_trash:
doc.deleted_at = datetime.now(timezone.utc).isoformat()
doc.deleted_at = datetime.now(UTC).isoformat()
else:
doc.deleted_at = None
doc.updated_at = datetime.now(timezone.utc).isoformat()
doc.updated_at = datetime.now(UTC).isoformat()
db.commit()
db.refresh(doc)
return doc
return DocumentMeta.model_validate(doc)
@router.delete("/{id}", dependencies=[Depends(verify_csrf)])
def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)):
def delete_document(
id: str, permanent: bool = False, db: Session = Depends(get_db)
) -> dict[str, str]:
"""Soft delete by default. Hard delete if permanent=True AND already in trash."""
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if permanent:
if doc.deleted_at is None:
raise HTTPException(status_code=400, detail="Must be in trash to delete permanently")
# Hard delete
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
@ -141,52 +170,57 @@ def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_
return {"status": "deleted"}
else:
# Soft delete
doc.deleted_at = datetime.now(timezone.utc).isoformat()
doc.deleted_at = datetime.now(UTC).isoformat()
doc.updated_at = doc.deleted_at
db.commit()
return {"status": "trashed"}
@router.post("/{id}/restore", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def restore_document(id: str, db: Session = Depends(get_db)):
def restore_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
doc.deleted_at = None
doc.updated_at = datetime.now(timezone.utc).isoformat()
doc.updated_at = datetime.now(UTC).isoformat()
db.commit()
db.refresh(doc)
return doc
return DocumentMeta.model_validate(doc)
@router.post("/bulk-delete", dependencies=[Depends(verify_csrf)])
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc).isoformat()
db.query(Document).filter(Document.id.in_(data.ids)).update({
Document.deleted_at: now
}, synchronize_session=False)
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
now = datetime.now(UTC).isoformat()
count = (
db.query(Document)
.filter(Document.id.in_(data.ids), Document.deleted_at.is_(None))
.update({Document.deleted_at: now}, synchronize_session=False)
)
db.commit()
return {"status": "ok", "count": len(data.ids)}
return {"status": "ok", "count": count}
@router.post("/bulk-restore", dependencies=[Depends(verify_csrf)])
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc).isoformat()
db.query(Document).filter(Document.id.in_(data.ids)).update({
Document.deleted_at: None,
Document.updated_at: now
}, synchronize_session=False)
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
now = datetime.now(UTC).isoformat()
count = (
db.query(Document)
.filter(Document.id.in_(data.ids), Document.deleted_at.is_not(None))
.update({Document.deleted_at: None, Document.updated_at: now}, synchronize_session=False)
)
db.commit()
return {"status": "ok", "count": len(data.ids)}
return {"status": "ok", "count": count}
@router.post("/trash/empty", dependencies=[Depends(verify_csrf)])
def empty_trash(db: Session = Depends(get_db)):
def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]:
docs = db.query(Document).filter(Document.deleted_at.is_not(None)).all()
count = 0
for doc in docs:
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
@ -196,26 +230,26 @@ def empty_trash(db: Session = Depends(get_db)):
@router.get("/{id}/file")
def get_document_file(id: str, db: Session = Depends(get_db)):
def get_document_file(id: str, db: Session = Depends(get_db)) -> FileResponse:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
path = settings.PDF_STORAGE_PATH / f"{doc.id}.pdf"
if not path.exists():
raise HTTPException(status_code=404, detail="File missing")
return FileResponse(path, media_type="application/pdf", filename=doc.filename)
@router.get("/{id}/thumbnail")
def get_document_thumbnail(id: str, db: Session = Depends(get_db)):
def get_document_thumbnail(id: str, db: Session = Depends(get_db)) -> FileResponse:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
path = settings.THUMBNAILS_PATH / f"{doc.id}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Thumbnail missing")
return FileResponse(path, media_type="image/png")

View file

@ -0,0 +1,94 @@
"""Flattened PDF export endpoint."""
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.config import settings
from app.db import get_db
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.version import Version
from app.schemas.export import ExportRequest
from app.services.export.renderer import export_annotations
from app.services.storage import safe_filename
router = APIRouter(
prefix="/documents",
tags=["export"],
dependencies=[Depends(get_current_user)],
)
def _document_or_404(document_id: str, db: Session) -> Document:
document = db.scalar(
select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))
)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return document
def _state_data(document_id: str, db: Session) -> list[dict[str, object]]:
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
if not state:
return []
try:
data = json.loads(state.data)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
@router.post("/{document_id}/export", dependencies=[Depends(verify_csrf)])
def export_document(
document_id: str,
request: ExportRequest,
db: Session = Depends(get_db),
) -> Response:
"""Export the working layer or a selected version as a downloadable PDF."""
document = _document_or_404(document_id, db)
pdf_path = settings.PDF_STORAGE_PATH / f"{document.id}.pdf"
if not pdf_path.is_file():
raise HTTPException(status_code=404, detail="Original PDF is missing")
if not request.flatten:
content = pdf_path.read_bytes()
elif request.versionId:
version = db.scalar(
select(Version).where(
Version.id == request.versionId,
Version.document_id == document_id,
)
)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
try:
version_data = json.loads(version.data)
except json.JSONDecodeError:
version_data = []
content = export_annotations(
pdf_path,
version_data if isinstance(version_data, list) else [],
settings.PDF_STORAGE_PATH / "assets" / document.id,
)
else:
content = export_annotations(
pdf_path,
_state_data(document_id, db),
settings.PDF_STORAGE_PATH / "assets" / document.id,
)
filename = safe_filename(document.original_filename)
if not filename.lower().endswith(".pdf"):
filename = f"{filename}.pdf"
filename = filename.replace('"', "'").replace("\r", "").replace("\n", "")
return Response(
content=content,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View file

@ -0,0 +1,203 @@
"""Annotation checkpoint and restore endpoints."""
import json
from datetime import UTC, datetime
from typing import Literal, cast
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.db import get_db
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.version import Version
from app.schemas.annotations import AnnotationPayload
from app.schemas.versions import (
VersionCreateRequest,
VersionDataResponse,
VersionListResponse,
VersionMeta,
VersionRestoreResponse,
)
router = APIRouter(
prefix="/documents",
tags=["versions"],
dependencies=[Depends(get_current_user)],
)
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _document_or_404(document_id: str, db: Session) -> Document:
document = db.scalar(
select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))
)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return document
def _read_state(document_id: str, db: Session) -> list[AnnotationPayload]:
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
if not state:
return []
try:
data = json.loads(state.data)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
def _version_meta(version: Version) -> VersionMeta:
try:
data = json.loads(version.data)
except json.JSONDecodeError:
data = []
return VersionMeta(
id=version.id,
documentId=version.document_id,
label=version.label,
kind=cast(
Literal["manual", "auto"],
version.kind if version.kind in {"manual", "auto"} else "auto",
),
createdAt=datetime.fromisoformat(version.created_at),
annotationCount=len(data) if isinstance(data, list) else 0,
)
def _create_snapshot(
document_id: str,
db: Session,
*,
label: str | None,
kind: str,
) -> Version:
version = Version(
document_id=document_id,
label=label,
kind=kind,
data=json.dumps(_read_state(document_id, db), separators=(",", ":")),
)
db.add(version)
db.flush()
return version
@router.get("/{document_id}/versions", response_model=VersionListResponse)
def list_versions(document_id: str, db: Session = Depends(get_db)) -> VersionListResponse:
_document_or_404(document_id, db)
versions = db.scalars(
select(Version).where(Version.document_id == document_id).order_by(desc(Version.created_at))
).all()
return VersionListResponse(items=[_version_meta(version) for version in versions])
@router.post(
"/{document_id}/versions",
response_model=VersionMeta,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(verify_csrf)],
)
def create_version(
document_id: str,
request: VersionCreateRequest,
db: Session = Depends(get_db),
) -> VersionMeta:
_document_or_404(document_id, db)
version = _create_snapshot(
document_id,
db,
label=request.label.strip() if request.label and request.label.strip() else None,
kind=request.kind,
)
db.commit()
return _version_meta(version)
@router.get("/{document_id}/versions/{version_id}", response_model=VersionDataResponse)
def get_version(
document_id: str,
version_id: str,
db: Session = Depends(get_db),
) -> VersionDataResponse:
_document_or_404(document_id, db)
version = db.scalar(
select(Version).where(Version.id == version_id, Version.document_id == document_id)
)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
try:
data = json.loads(version.data)
except json.JSONDecodeError:
data = []
return VersionDataResponse(
data=data if isinstance(data, list) else [],
meta=_version_meta(version),
)
@router.post(
"/{document_id}/versions/{version_id}/restore",
response_model=VersionRestoreResponse,
dependencies=[Depends(verify_csrf)],
)
def restore_version(
document_id: str,
version_id: str,
db: Session = Depends(get_db),
) -> VersionRestoreResponse:
document = _document_or_404(document_id, db)
target = db.scalar(
select(Version).where(Version.id == version_id, Version.document_id == document_id)
)
if not target:
raise HTTPException(status_code=404, detail="Version not found")
# Restoring is destructive to the current working layer, so make it undoable.
_create_snapshot(document_id, db, label="Before restore", kind="auto")
try:
restored_data = json.loads(target.data)
except json.JSONDecodeError:
restored_data = []
if not isinstance(restored_data, list):
restored_data = []
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
now = _now_iso()
if state:
state.data = json.dumps(restored_data, separators=(",", ":"))
state.updated_at = now
else:
db.add(
AnnotationState(
document_id=document_id,
data=json.dumps(restored_data, separators=(",", ":")),
updated_at=now,
)
)
document.updated_at = now
db.commit()
return VersionRestoreResponse(updatedAt=datetime.fromisoformat(now))
@router.delete(
"/{document_id}/versions/{version_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(verify_csrf)],
)
def delete_version(document_id: str, version_id: str, db: Session = Depends(get_db)) -> Response:
_document_or_404(document_id, db)
version = db.scalar(
select(Version).where(Version.id == version_id, Version.document_id == document_id)
)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
db.delete(version)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)

Binary file not shown.

View file

@ -0,0 +1,93 @@
Copyright 2015 The Great Vibes Pro Project Authors (https://github.com/googlefonts/great-vibes)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,10 +1,11 @@
"""FastAPI dependencies for authentication and security."""
from fastapi import Request, HTTPException
from fastapi import HTTPException, Request
from app.auth.session import verify_session
from app.config import settings
def get_current_user(request: Request) -> int:
"""
Dependency that extracts and validates the session cookie.
@ -14,11 +15,11 @@ def get_current_user(request: Request) -> int:
token = request.cookies.get(settings.COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = verify_session(token)
if not user_id:
raise HTTPException(status_code=401, detail="Session expired or invalid")
return user_id
@ -27,9 +28,11 @@ def verify_csrf(request: Request) -> None:
Dependency to mitigate CSRF for cookie-based auth.
Requires X-Requested-With header on all mutating requests.
"""
if request.method in ["POST", "PUT", "PATCH", "DELETE"]:
if request.headers.get("X-Requested-With") != "XMLHttpRequest":
raise HTTPException(
status_code=403,
detail="CSRF check failed: missing X-Requested-With header"
)
if (
request.method in ["POST", "PUT", "PATCH", "DELETE"]
and request.headers.get("X-Requested-With") != "XMLHttpRequest"
):
raise HTTPException(
status_code=403,
detail="CSRF check failed: missing X-Requested-With header",
)

View file

@ -1,20 +1,22 @@
"""Argon2id password hashing and verification."""
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from argon2.exceptions import InvalidHashError, VerifyMismatchError
# Argon2id is the default for PasswordHasher
ph = PasswordHasher()
def hash_password(password: str) -> str:
"""Hash a plaintext password."""
return ph.hash(password)
def verify_password(hashed: str, password: str) -> bool:
"""Verify a password against a hash. Returns True if matched."""
try:
ph.verify(hashed, password)
# Note: We skip check_needs_rehash() for simplicity in this single-user app.
return True
except VerifyMismatchError:
except (InvalidHashError, VerifyMismatchError):
return False

View file

@ -2,6 +2,7 @@
import time
from collections import defaultdict
from fastapi import HTTPException
from app.config import settings
@ -27,7 +28,7 @@ def check_rate_limit(ip: str) -> None:
detail=f"Too many failed attempts. Try again in {remaining} seconds.",
headers={"Retry-After": str(remaining)},
)
# If lockout has expired, reset
if record["lockout_until"] > 0 and record["lockout_until"] <= now:
record["attempts"] = 0

View file

@ -1,11 +1,13 @@
"""Session token generation and validation."""
from typing import Any
from fastapi import Response
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
def get_serializer() -> URLSafeTimedSerializer:
"""Return a configured URLSafeTimedSerializer."""
return URLSafeTimedSerializer(settings.SECRET_KEY)
@ -14,7 +16,7 @@ def create_session(response: Response, user_id: int = 1) -> None:
"""Create a new session token and set it as an HTTP-only cookie."""
serializer = get_serializer()
token = serializer.dumps({"user_id": user_id})
response.set_cookie(
key=settings.COOKIE_NAME,
value=token,
@ -41,7 +43,7 @@ def verify_session(token: str) -> int | None:
serializer = get_serializer()
try:
data: dict[str, Any] = serializer.loads(
token,
token,
max_age=settings.COOKIE_MAX_AGE_SECONDS
)
return data.get("user_id")

View file

@ -5,6 +5,7 @@ All settings are driven by environment variables (see .env.example).
"""
from pathlib import Path
from pydantic_settings import BaseSettings

View file

@ -4,11 +4,10 @@ Database engine, session management, and WAL mode setup.
SQLite with WAL mode for single-user concurrent read/write safety.
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from collections.abc import Generator
from typing import Any
from sqlalchemy import event, create_engine, Engine
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
@ -16,6 +15,7 @@ from app.config import settings
class Base(DeclarativeBase):
"""SQLAlchemy declarative base for all models."""
pass
@ -49,10 +49,10 @@ engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
def get_db() -> Session:
def get_db() -> Generator[Session, None, None]:
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db # type: ignore[misc]
yield db
finally:
db.close()

View file

@ -5,8 +5,8 @@ This is the main entry point. The lifespan handler initializes the database
and ensures required directories exist on startup.
"""
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Request
@ -14,7 +14,8 @@ from fastapi.responses import JSONResponse
from app.api.v1 import router as v1_router
from app.config import settings
from app.db import Base, engine
from app.db import Base, SessionLocal, engine
from app.services.trash_sweep import prune_auto_versions, sweep_trash
@asynccontextmanager
@ -29,6 +30,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
# Run retention cleanup on every startup. This keeps the single-container
# deployment self-maintaining without requiring a separate scheduler.
with SessionLocal() as db:
sweep_trash(db)
prune_auto_versions(db)
yield
# Shutdown: dispose of the engine
@ -48,6 +55,7 @@ app = FastAPI(
# --- Error handlers ---
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent 404 error envelope."""

View file

@ -5,9 +5,9 @@ All models use UUIDv4 string primary keys (except the singleton settings row).
Timestamps are stored as ISO8601 TEXT columns.
"""
from app.models.settings import Settings
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.settings import Settings
from app.models.version import Version
__all__ = ["Settings", "Document", "AnnotationState", "Version"]
__all__ = ["AnnotationState", "Document", "Settings", "Version"]

View file

@ -1,15 +1,19 @@
"""AnnotationState model — current working annotation layer per document."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
if TYPE_CHECKING:
from app.models.document import Document
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
class AnnotationState(Base):

View file

@ -1,20 +1,25 @@
"""Document model — uploaded PDFs with soft-delete support."""
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
if TYPE_CHECKING:
from app.models.annotation_state import AnnotationState
from app.models.version import Version
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
class Document(Base):

View file

@ -1,6 +1,6 @@
"""Settings model — singleton row for app-wide configuration."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
@ -22,10 +22,10 @@ class Settings(Base):
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(timezone.utc).isoformat(),
default=lambda: datetime.now(UTC).isoformat(),
)
updated_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(timezone.utc).isoformat(),
onupdate=lambda: datetime.now(timezone.utc).isoformat(),
default=lambda: datetime.now(UTC).isoformat(),
onupdate=lambda: datetime.now(UTC).isoformat(),
)

View file

@ -1,20 +1,24 @@
"""Version model — annotation state snapshots for history/recovery."""
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
if TYPE_CHECKING:
from app.models.document import Document
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
class Version(Base):
@ -40,6 +44,4 @@ class Version(Base):
# Relationships
document: Mapped["Document"] = relationship("Document", back_populates="versions")
__table_args__ = (
Index("ix_versions_document_created", "document_id", "created_at"),
)
__table_args__ = (Index("ix_versions_document_created", "document_id", "created_at"),)

View file

@ -1,14 +1,17 @@
from datetime import datetime
from typing import Literal, Union, List, Tuple, Optional
from pydantic import BaseModel, Field
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
class Rect(BaseModel):
x: float
y: float
width: float
height: float
class AnnotationBase(BaseModel):
id: UUID
page: int = Field(ge=0)
@ -19,6 +22,7 @@ class AnnotationBase(BaseModel):
createdAt: datetime
updatedAt: datetime
class TextProps(BaseModel):
text: str
fontFamily: str = "Liberation Sans"
@ -28,82 +32,105 @@ class TextProps(BaseModel):
bold: bool = False
italic: bool = False
lineHeight: float = 1.2
highlightColor: Optional[str] = None
styles: Optional[dict] = None
highlightColor: str | None = None
styles: dict[str, Any] | None = None
class TextAnnotation(AnnotationBase):
type: Literal["text"]
props: TextProps
class DrawProps(BaseModel):
paths: List[Tuple[float, float]] = []
svgPath: Optional[str] = None
paths: list[tuple[float, float]] = Field(default_factory=list)
svgPath: str | None = None
strokeColor: str = "#000000"
strokeWidth: float = 2
opacity: float = 1.0
class DrawAnnotation(AnnotationBase):
type: Literal["draw"]
props: DrawProps
class SignatureDrawProps(BaseModel):
mode: Literal["draw"]
ref: str
strokeColor: str = "#000000"
class SignatureTypeProps(BaseModel):
mode: Literal["type"]
text: str
fontFamily: str
color: str = "#000000"
class SignatureAnnotation(AnnotationBase):
type: Literal["signature"]
props: Union[SignatureDrawProps, SignatureTypeProps] = Field(discriminator="mode")
props: SignatureDrawProps | SignatureTypeProps = Field(discriminator="mode")
class ImageProps(BaseModel):
ref: str
naturalWidth: float
naturalHeight: float
class ImageAnnotation(AnnotationBase):
type: Literal["image"]
props: ImageProps
class HighlightProps(BaseModel):
color: str = "#FFEB3B"
opacity: float = 0.3
class HighlightAnnotation(AnnotationBase):
type: Literal["highlight"]
props: HighlightProps
class ShapeProps(BaseModel):
kind: Literal["rect", "ellipse", "line", "arrow"]
strokeColor: str = "#000000"
fillColor: str = "transparent"
strokeWidth: float = 2
start: tuple[float, float] | None = None
end: tuple[float, float] | None = None
class ShapeAnnotation(AnnotationBase):
type: Literal["shape"]
props: ShapeProps
Annotation = Union[
TextAnnotation,
DrawAnnotation,
SignatureAnnotation,
ImageAnnotation,
HighlightAnnotation,
ShapeAnnotation
]
KnownAnnotation = (
TextAnnotation
| DrawAnnotation
| SignatureAnnotation
| ImageAnnotation
| HighlightAnnotation
| ShapeAnnotation
)
# Working state is intentionally opaque. Keeping this payload as dictionaries
# lets newer clients round-trip annotation types that this server cannot export
# yet; the export registry skips those types with a warning.
AnnotationPayload = dict[str, Any]
class AnnotationStateResponse(BaseModel):
data: List[Annotation]
data: list[AnnotationPayload]
updatedAt: datetime
class AnnotationStateUpdateRequest(BaseModel):
data: List[Annotation]
data: list[AnnotationPayload]
baseUpdatedAt: datetime | None = None
class AnnotationStateUpdateResponse(BaseModel):
updatedAt: datetime

View file

@ -2,6 +2,7 @@
from pydantic import BaseModel, Field
class SetupRequest(BaseModel):
password: str = Field(..., min_length=8)

View file

@ -1,7 +1,8 @@
"""Pydantic schemas for Document APIs."""
from pydantic import BaseModel
from typing import Optional
class DocumentMeta(BaseModel):
id: str
@ -12,7 +13,7 @@ class DocumentMeta(BaseModel):
in_trash: bool
created_at: str
updated_at: str
deleted_at: Optional[str]
deleted_at: str | None
model_config = {"from_attributes": True}
@ -27,5 +28,5 @@ class BulkRestoreRequest(BaseModel):
ids: list[str]
class DocumentUpdateRequest(BaseModel):
title: Optional[str] = None
in_trash: Optional[bool] = None
title: str | None = None
in_trash: bool | None = None

View file

@ -0,0 +1,8 @@
"""PDF export request schema."""
from pydantic import BaseModel
class ExportRequest(BaseModel):
versionId: str | None = None
flatten: bool = True

View file

@ -0,0 +1,35 @@
"""Version checkpoint schemas."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from app.schemas.annotations import AnnotationPayload
class VersionCreateRequest(BaseModel):
label: str | None = Field(default=None, max_length=120)
kind: Literal["manual", "auto"] = "manual"
class VersionMeta(BaseModel):
id: str
documentId: str
label: str | None
kind: Literal["manual", "auto"]
createdAt: datetime
annotationCount: int
class VersionListResponse(BaseModel):
items: list[VersionMeta]
class VersionDataResponse(BaseModel):
data: list[AnnotationPayload]
meta: VersionMeta
class VersionRestoreResponse(BaseModel):
updatedAt: datetime

View file

@ -0,0 +1,12 @@
"""Storage helpers for binary annotation assets."""
import shutil
from app.config import settings
def delete_asset_directory(document_id: str) -> None:
"""Delete all uploaded annotation assets belonging to a document."""
directory = settings.PDF_STORAGE_PATH / "assets" / document_id
if directory.exists():
shutil.rmtree(directory)

View file

@ -0,0 +1 @@
"""PDF export services."""

View file

@ -0,0 +1,465 @@
"""Flatten canonical annotation data into a PDF with PyMuPDF."""
import logging
import math
import re
import uuid
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any
import pymupdf
logger = logging.getLogger(__name__)
Annotation = Mapping[str, Any]
Handler = Callable[[pymupdf.Page, Annotation, Path], None]
def _rect(annotation: Annotation) -> pymupdf.Rect | None:
value = annotation.get("rect")
if not isinstance(value, Mapping):
return None
try:
x = float(value["x"])
y = float(value["y"])
width = float(value["width"])
height = float(value["height"])
except (KeyError, TypeError, ValueError):
return None
if width < 0 or height < 0:
return None
return pymupdf.Rect(x, y, x + width, y + height)
def _props(annotation: Annotation) -> Mapping[str, Any]:
value = annotation.get("props")
return value if isinstance(value, Mapping) else {}
def _color(
value: Any,
fallback: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> tuple[float, float, float]:
if not isinstance(value, str):
return fallback
text = value.strip().lstrip("#")
if len(text) == 3:
text = "".join(char * 2 for char in text)
if len(text) != 6:
return fallback
try:
return tuple(int(text[index : index + 2], 16) / 255 for index in (0, 2, 4)) # type: ignore[return-value]
except ValueError:
return fallback
def _opacity(value: Any, fallback: float = 1.0) -> float:
try:
return max(0.0, min(1.0, float(value)))
except (TypeError, ValueError):
return fallback
def _font_name(family: Any, bold: Any = False, italic: Any = False) -> str:
name = str(family or "Liberation Sans").lower()
is_serif = "times" in name or "serif" in name
is_mono = "courier" in name or "mono" in name
if is_serif:
normal, bold_name, italic_name, bold_italic_name = "tiro", "tibo", "tiit", "tibi"
elif is_mono:
normal, bold_name, italic_name, bold_italic_name = "cour", "cobo", "coit", "cobi"
else:
normal, bold_name, italic_name, bold_italic_name = "helv", "hebo", "heit", "hebi"
if bold and italic:
return bold_italic_name
if bold:
return bold_name
if italic:
return italic_name
return normal
_SIGNATURE_FONT_FILES = {
"great vibes": "GreatVibes-Regular.ttf",
"allura": "allura.ttf",
"sacramento": "sacramento.ttf",
"dancing script": "dancing-script.ttf",
"caveat": "caveat.ttf",
}
def _signature_font_file(family: Any) -> Path | None:
filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower())
if not filename:
return None
path = Path(__file__).parents[2] / "assets" / "fonts" / filename
return path if path.is_file() else None
def _signature_font_size(
rect: pymupdf.Rect, text: str, font_file: Path | None, font_name: str
) -> float:
size = max(1.0, min(48.0, rect.height * 0.55))
try:
font = (
pymupdf.Font(fontfile=str(font_file)) if font_file else pymupdf.Font(fontname=font_name)
)
text_width = font.text_length(text, fontsize=size)
if text_width > rect.width and text_width > 0:
size *= max(0.1, rect.width / text_width) * 0.95
except Exception:
# A missing optional font must not prevent a PDF export.
pass
return max(1.0, size)
def _points_from_props(annotation: Annotation, rect: pymupdf.Rect) -> list[pymupdf.Point]:
props = _props(annotation)
raw_points = props.get("paths")
points: list[pymupdf.Point] = []
if isinstance(raw_points, Sequence) and not isinstance(raw_points, (str, bytes)):
for raw_point in raw_points:
if isinstance(raw_point, Sequence) and len(raw_point) == 2:
try:
points.append(
pymupdf.Point(
rect.x0 + float(raw_point[0]),
rect.y0 + float(raw_point[1]),
)
)
except (TypeError, ValueError):
continue
if len(points) >= 2:
return points
# Older clients stored a Fabric SVG path. Approximate its command end
# points and normalize them into the canonical annotation rectangle.
svg_path = props.get("svgPath")
if not isinstance(svg_path, str):
return []
raw = [float(value) for value in re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", svg_path)]
if len(raw) < 4:
return []
pairs = list(zip(raw[::2], raw[1::2], strict=False))
min_x = min(point[0] for point in pairs)
max_x = max(point[0] for point in pairs)
min_y = min(point[1] for point in pairs)
max_y = max(point[1] for point in pairs)
source_width = max(max_x - min_x, 1e-6)
source_height = max(max_y - min_y, 1e-6)
return [
pymupdf.Point(
rect.x0 + (x - min_x) / source_width * rect.width,
rect.y0 + (y - min_y) / source_height * rect.height,
)
for x, y in pairs
]
def _draw_polyline(
page: pymupdf.Page,
points: list[pymupdf.Point],
*,
color: tuple[float, float, float],
width: float,
opacity: float,
) -> None:
if len(points) < 2:
return
shape = page.new_shape()
shape.draw_polyline(points)
shape.finish(color=color, width=max(0.1, width), stroke_opacity=opacity)
shape.commit(overlay=True)
def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[str, Any]:
if not isinstance(styles, Mapping):
return {}
line_styles = styles.get(str(line_number), styles.get(line_number))
if not isinstance(line_styles, Mapping):
return {}
character_style = line_styles.get(str(character_number), line_styles.get(character_number))
return character_style if isinstance(character_style, Mapping) else {}
def _render_styled_text(
page: pymupdf.Page,
rect: pymupdf.Rect,
text: str,
props: Mapping[str, Any],
styles: Any,
) -> bool:
if not isinstance(styles, Mapping) or not styles:
return False
base_size = max(1.0, float(props.get("fontSize", 14)))
line_height = max(0.5, float(props.get("lineHeight", 1.2)))
lines = text.split("\n")
y = rect.y0
for line_number, line in enumerate(lines):
measured: list[tuple[str, float, str, tuple[float, float, float], float, str | None]] = []
for character_number, character in enumerate(line):
style = _style_at(styles, line_number, character_number)
font_size = max(1.0, float(style.get("fontSize", base_size)))
font_family = style.get("fontFamily", props.get("fontFamily"))
font_name = _font_name(
font_family,
style.get("fontWeight") == "bold",
style.get("fontStyle") == "italic",
)
color = _color(style.get("fill", props.get("color")))
font = pymupdf.Font(fontname=font_name)
width = float(font.text_length(character, fontsize=font_size))
background = style.get("textBackgroundColor", props.get("highlightColor"))
measured.append((character, font_size, font_name, color, width, background))
total_width = sum(item[4] for item in measured)
align = str(props.get("align", "left"))
x = rect.x0
if align == "center":
x += max(0.0, (rect.width - total_width) / 2)
elif align == "right":
x += max(0.0, rect.width - total_width)
max_size = max((item[1] for item in measured), default=base_size)
for character, font_size, font_name, color, width, background in measured:
if (
isinstance(background, str)
and background.lower() not in {"", "transparent", "none"}
):
page.draw_rect(
pymupdf.Rect(x, y, x + width, y + max_size * 1.15),
color=None,
fill=_color(background),
fill_opacity=0.3,
overlay=True,
)
if character != " ":
page.insert_text(
pymupdf.Point(x, y + font_size),
character,
fontsize=font_size,
fontname=font_name,
color=color,
overlay=True,
)
x += width
y += max_size * line_height
return True
def render_text(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
text = str(props.get("text", ""))
if not text:
return
highlight = props.get("highlightColor")
if isinstance(highlight, str) and highlight and highlight.lower() != "transparent":
page.draw_rect(rect, color=None, fill=_color(highlight), fill_opacity=0.3, overlay=True)
if _render_styled_text(page, rect, text, props, props.get("styles")):
return
align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0)
page.insert_textbox(
rect,
text,
fontsize=max(1.0, float(props.get("fontSize", 14))),
fontname=_font_name(props.get("fontFamily"), props.get("bold"), props.get("italic")),
color=_color(props.get("color")),
align=align,
overlay=True,
)
def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
_draw_polyline(
page,
_points_from_props(annotation, rect),
color=_color(props.get("strokeColor")),
width=float(props.get("strokeWidth", 2)),
opacity=_opacity(props.get("opacity")),
)
def _asset_path(assets_dir: Path, ref: Any) -> Path | None:
if not isinstance(ref, str):
return None
try:
safe_ref = str(uuid.UUID(ref))
except ValueError:
return None
path = assets_dir / safe_ref
return path if path.is_file() else None
def render_image(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None:
rect = _rect(annotation)
asset = _asset_path(assets_dir, _props(annotation).get("ref"))
if rect is None or asset is None:
return
page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True)
def render_signature(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None:
rect = _rect(annotation)
props = _props(annotation)
if rect is None:
return
if props.get("mode") == "draw":
asset = _asset_path(assets_dir, props.get("ref"))
if asset is not None:
page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True)
return
text = str(props.get("text", ""))
if text:
font_file = _signature_font_file(props.get("fontFamily"))
font_name = _font_name(props.get("fontFamily"), False, False)
options: dict[str, Any] = {
# Script fonts have a taller ascender than the built-in PDF fonts;
# leave room inside the editor's saved bounding rectangle so a
# valid signature is never silently dropped for not fitting.
"fontsize": _signature_font_size(rect, text, font_file, font_name),
"color": _color(props.get("color")),
"overlay": True,
}
if font_file:
options["fontname"] = f"paperjet-{font_file.stem.lower()}"
options["fontfile"] = str(font_file)
else:
options["fontname"] = font_name
page.insert_textbox(rect, text, **options)
def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
page.draw_rect(
rect,
color=None,
fill=_color(props.get("color"), (1.0, 0.92, 0.1)),
fill_opacity=_opacity(props.get("opacity"), 0.3),
overlay=True,
)
def render_shape(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
kind = str(props.get("kind", "rect"))
stroke = _color(props.get("strokeColor"))
fill_value = props.get("fillColor")
fill = None if fill_value in (None, "", "transparent", "none") else _color(fill_value)
width = max(0.1, float(props.get("strokeWidth", 2)))
if kind == "ellipse":
page.draw_oval(rect, color=stroke, fill=fill, width=width, overlay=True)
elif kind in {"line", "arrow"}:
start = props.get("start")
end = props.get("end")
if (
isinstance(start, Sequence)
and not isinstance(start, (str, bytes))
and len(start) == 2
and isinstance(end, Sequence)
and not isinstance(end, (str, bytes))
and len(end) == 2
):
try:
start_point = pymupdf.Point(
rect.x0 + float(start[0]) * rect.width,
rect.y0 + float(start[1]) * rect.height,
)
end_point = pymupdf.Point(
rect.x0 + float(end[0]) * rect.width,
rect.y0 + float(end[1]) * rect.height,
)
except (TypeError, ValueError):
start_point, end_point = rect.tl, rect.br
else:
start_point, end_point = rect.tl, rect.br
page.draw_line(start_point, end_point, color=stroke, width=width, overlay=True)
if kind == "arrow":
angle = math.atan2(end_point.y - start_point.y, end_point.x - start_point.x)
length = min(
12.0,
max(
5.0, math.hypot(end_point.x - start_point.x, end_point.y - start_point.y) * 0.2
),
)
left = pymupdf.Point(
end_point.x - length * math.cos(angle - math.pi / 6),
end_point.y - length * math.sin(angle - math.pi / 6),
)
right = pymupdf.Point(
end_point.x - length * math.cos(angle + math.pi / 6),
end_point.y - length * math.sin(angle + math.pi / 6),
)
page.draw_polyline([left, end_point, right], color=stroke, width=width, overlay=True)
else:
page.draw_rect(rect, color=stroke, fill=fill, width=width, overlay=True)
HANDLERS: dict[str, Handler] = {
"text": render_text,
"draw": render_draw,
"signature": render_signature,
"image": render_image,
"highlight": render_highlight,
"shape": render_shape,
}
def export_annotations(
pdf_path: Path,
annotations: Sequence[Annotation],
assets_dir: Path,
) -> bytes:
"""Return a flattened PDF while preserving the source page rotations."""
with pymupdf.open(pdf_path) as document:
rotations = [page.rotation for page in document]
try:
for page in document:
if page.rotation:
page.set_rotation(0)
page_annotations: dict[int, list[Annotation]] = {}
for annotation in annotations:
try:
page_number = int(annotation.get("page", -1))
except (TypeError, ValueError):
logger.warning("Skipping annotation with invalid page: %r", annotation)
continue
if 0 <= page_number < len(document):
page_annotations.setdefault(page_number, []).append(annotation)
else:
logger.warning("Skipping annotation outside document pages: %r", annotation)
for page_number, page in enumerate(document):
ordered = sorted(
page_annotations.get(page_number, []),
key=lambda item: int(item.get("z", 0)),
)
for annotation in ordered:
annotation_type = annotation.get("type")
handler = HANDLERS.get(str(annotation_type))
if handler is None:
logger.warning("Skipping unsupported annotation type %r", annotation_type)
continue
try:
handler(page, annotation, assets_dir)
except Exception:
logger.exception("Skipping malformed %s annotation", annotation_type)
finally:
for page, rotation in zip(document, rotations, strict=True):
if page.rotation != rotation:
page.set_rotation(rotation)
return document.tobytes(garbage=4, deflate=True)

View file

@ -1,53 +1,71 @@
"""Document storage service."""
"""Document and binary storage helpers."""
import shutil
import uuid
import pymupdf
from pathlib import Path
from fastapi import UploadFile, HTTPException
import pymupdf
from fastapi import HTTPException, UploadFile
from app.config import settings
def validate_pdf(filepath: Path) -> None:
"""Validate PDF magic bytes and PyMuPDF openability."""
with open(filepath, "rb") as f:
header = f.read(5)
if header != b"%PDF-":
_COPY_CHUNK_SIZE = 1024 * 1024
def validate_pdf(filepath: Path) -> int:
"""Validate a PDF's magic bytes and return its page count."""
with filepath.open("rb") as file:
if file.read(5) != b"%PDF-":
raise ValueError("Not a valid PDF file (missing magic bytes)")
try:
doc = pymupdf.open(filepath)
doc.close()
except Exception as e:
raise ValueError(f"Failed to open PDF with PyMuPDF: {e}")
with pymupdf.open(filepath) as document:
page_count = len(document)
except Exception as err:
raise ValueError(f"Failed to open PDF with PyMuPDF: {err}") from err
if page_count == 0:
raise ValueError("PDF does not contain any pages")
return page_count
def safe_filename(filename: str | None) -> str:
"""Return a display-safe basename without allowing path components."""
name = Path(filename or "Untitled.pdf").name.strip()
return name or "Untitled.pdf"
async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]:
"""Save an uploaded file to disk and validate it."""
"""Stream an uploaded PDF to storage, enforcing the configured size limit."""
file_id = str(uuid.uuid4())
settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
max_bytes = settings.MAX_UPLOAD_MB * 1024 * 1024
total_bytes = 0
try:
with open(filepath, "wb") as f:
shutil.copyfileobj(upload_file.file, f)
except Exception as e:
if filepath.exists():
filepath.unlink()
raise HTTPException(status_code=500, detail="Failed to save file")
with filepath.open("wb") as destination:
while chunk := upload_file.file.read(_COPY_CHUNK_SIZE):
total_bytes += len(chunk)
if total_bytes > max_bytes:
raise HTTPException(status_code=413, detail="File too large")
destination.write(chunk)
except HTTPException:
filepath.unlink(missing_ok=True)
raise
except Exception as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Failed to save file") from err
page_count = 0
try:
doc = pymupdf.open(filepath)
page_count = len(doc)
doc.close()
except Exception as e:
filepath.unlink()
raise HTTPException(status_code=400, detail=f"Failed to open PDF with PyMuPDF: {e}")
size = filepath.stat().st_size
return file_id, size, page_count
page_count = validate_pdf(filepath)
except ValueError as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=str(err)) from err
return file_id, total_bytes, page_count
def delete_pdf_file(file_id: str) -> None:
"""Delete a PDF file from storage."""
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
if filepath.exists():
filepath.unlink()
filepath.unlink(missing_ok=True)

View file

@ -1,38 +1,40 @@
"""Thumbnail generation service."""
import pymupdf
from pathlib import Path
from app.config import settings
def generate_thumbnail(file_id: str) -> None:
def generate_thumbnail(file_id: str) -> bool:
"""Generate a PNG thumbnail for the first page of a PDF."""
pdf_path = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
if not pdf_path.exists():
return
return False
try:
doc = pymupdf.open(pdf_path)
if len(doc) > 0:
with pymupdf.open(pdf_path) as doc:
if len(doc) == 0:
return False
page = doc[0]
# Zoom to approximately 600px width
rect = page.rect
zoom = 600.0 / rect.width if rect.width > 0 else 1.0
mat = pymupdf.Matrix(zoom, zoom)
# Render pixmap
pix = page.get_pixmap(matrix=mat, alpha=False)
# Save as PNG
pix.save(thumb_path, output="png")
doc.close()
except Exception as e:
print(f"Failed to generate thumbnail for {file_id}: {e}")
return True
except Exception as err:
print(f"Failed to generate thumbnail for {file_id}: {err}")
return False
def delete_thumbnail(file_id: str) -> None:
"""Delete a thumbnail file."""
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
if thumb_path.exists():
thumb_path.unlink()
thumb_path.unlink(missing_ok=True)

View file

@ -1,48 +1,50 @@
"""Background tasks for sweeping trash and old versions."""
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.document import Document
from app.config import settings
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.version import Version
from app.services.assets import delete_asset_directory
from app.services.storage import delete_pdf_file
from app.services.thumbnails import delete_thumbnail
from app.config import settings
def sweep_trash(db: Session) -> None:
"""Permanently delete documents that have been in the trash past the retention period."""
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.TRASH_RETENTION_DAYS)
cutoff = datetime.now(UTC) - timedelta(days=settings.TRASH_RETENTION_DAYS)
cutoff_iso = cutoff.isoformat()
docs_to_delete = db.query(Document).filter(
Document.in_trash == True,
Document.deleted_at <= cutoff_iso
).all()
docs_to_delete = (
db.query(Document)
.filter(Document.deleted_at.is_not(None), Document.deleted_at <= cutoff_iso)
.all()
)
for doc in docs_to_delete:
# Delete files from disk
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
# Delete DB associations
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
# Delete document record
db.delete(doc)
db.commit()
def prune_auto_versions(db: Session) -> None:
"""Delete automatic versions older than the retention period."""
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS)
cutoff = datetime.now(UTC) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS)
cutoff_iso = cutoff.isoformat()
db.query(Version).filter(
Version.kind == "auto",
Version.created_at <= cutoff_iso
).delete()
db.query(Version).filter(Version.kind == "auto", Version.created_at <= cutoff_iso).delete()
db.commit()

View file

@ -0,0 +1,121 @@
"""End-to-end coverage for the user-critical backend workflows."""
import os
import shutil
import uuid
from pathlib import Path
import pymupdf
from fastapi.testclient import TestClient
_ROOT = Path.cwd() / ".pytest-paperjet-api"
shutil.rmtree(_ROOT, ignore_errors=True)
_ROOT.mkdir()
os.environ.update(
{
"PAPERJET_SECRET_KEY": "test-secret-key",
"PAPERJET_DATABASE_PATH": str(_ROOT / "db" / "app.sqlite"),
"PAPERJET_PDF_STORAGE_PATH": str(_ROOT / "pdfs"),
"PAPERJET_THUMBNAILS_PATH": str(_ROOT / "thumbnails"),
"PAPERJET_DEBUG": "false",
}
)
from app.main import app # noqa: E402
def _pdf_bytes() -> bytes:
document = pymupdf.open()
document.new_page(width=240, height=320)
content = document.tobytes()
document.close()
return content
def test_setup_upload_annotate_version_export_and_trash_restore() -> None:
csrf = {"X-Requested-With": "XMLHttpRequest"}
annotation_id = str(uuid.uuid4())
annotation = {
"id": annotation_id,
"page": 0,
"type": "text",
"rect": {"x": 20, "y": 30, "width": 120, "height": 30},
"rotation": 0,
"z": 0,
"props": {
"text": "Persisted note",
"fontFamily": "Liberation Sans",
"fontSize": 14,
"color": "#000000",
},
"createdAt": "2026-08-14T00:00:00Z",
"updatedAt": "2026-08-14T00:00:00Z",
}
with TestClient(app) as client:
assert client.get("/api/v1/health").json()["status"] == "ok"
assert client.get("/api/v1/auth/status").json()["setupRequired"] is True
setup = client.post("/api/v1/auth/setup", json={"password": "correct horse"}, headers=csrf)
assert setup.status_code == 200
assert client.get("/api/v1/auth/status").json()["loggedIn"] is True
upload = client.post(
"/api/v1/documents",
files={"file": ("sample.pdf", _pdf_bytes(), "application/pdf")},
headers=csrf,
)
assert upload.status_code == 201
document_id = upload.json()["id"]
saved = client.put(
f"/api/v1/documents/{document_id}/annotations",
json={"data": [annotation]},
headers=csrf,
)
assert saved.status_code == 200
assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [
annotation
]
version = client.post(
f"/api/v1/documents/{document_id}/versions",
json={"label": "Before export"},
headers=csrf,
)
assert version.status_code == 201
version_id = version.json()["id"]
changed = {**annotation, "props": {**annotation["props"], "text": "Changed"}}
client.put(
f"/api/v1/documents/{document_id}/annotations",
json={"data": [changed]},
headers=csrf,
)
restored = client.post(
f"/api/v1/documents/{document_id}/versions/{version_id}/restore",
headers=csrf,
)
assert restored.status_code == 200
assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [
annotation
]
exported = client.post(
f"/api/v1/documents/{document_id}/export",
json={"flatten": True},
headers=csrf,
)
assert exported.status_code == 200
with pymupdf.open(stream=exported.content, filetype="pdf") as document:
assert "Persisted note" in document[0].get_text("text")
assert client.delete(f"/api/v1/documents/{document_id}", headers=csrf).status_code == 200
assert client.get("/api/v1/documents").json()["total"] == 0
assert client.get("/api/v1/documents/trash").json()["total"] == 1
assert (
client.post(f"/api/v1/documents/{document_id}/restore", headers=csrf).status_code == 200
)
assert client.get("/api/v1/documents").json()["total"] == 1
shutil.rmtree(_ROOT, ignore_errors=True)

View file

@ -0,0 +1,121 @@
"""Regression coverage for canonical-coordinate PDF export."""
import json
import pymupdf
from app.services.export.renderer import export_annotations
def _source_pdf(path) -> None:
document = pymupdf.open()
page = document.new_page(width=720, height=936)
page.set_cropbox(pymupdf.Rect(36, 72, 648, 864))
page.set_rotation(90)
document.save(path)
document.close()
def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) -> None:
source = tmp_path / "source.pdf"
_source_pdf(source)
annotations = [
{
"id": "text",
"page": 0,
"type": "text",
"rect": {"x": 40, "y": 50, "width": 180, "height": 40},
"props": {
"text": "Exported text",
"fontFamily": "Liberation Sans",
"fontSize": 14,
"color": "#112233",
"styles": {"0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}}},
},
},
{
"id": "line",
"page": 0,
"type": "draw",
"rect": {"x": 40, "y": 110, "width": 80, "height": 40},
"props": {
"paths": [[0, 0], [80, 40]],
"strokeColor": "#ff0000",
"strokeWidth": 2,
"opacity": 1,
},
},
{
"id": "highlight",
"page": 0,
"type": "highlight",
"rect": {"x": 40, "y": 170, "width": 100, "height": 20},
"props": {"color": "#ffff00", "opacity": 0.4},
},
{
"id": "signature",
"page": 0,
"type": "signature",
"rect": {"x": 40, "y": 220, "width": 160, "height": 50},
"props": {
"mode": "type",
"text": "Ava",
"fontFamily": "Great Vibes",
"color": "#000000",
},
},
{
"id": "shape",
"page": 0,
"type": "shape",
"rect": {"x": 180, "y": 170, "width": 60, "height": 30},
"props": {"kind": "ellipse", "strokeColor": "#0000ff", "strokeWidth": 2},
},
{
"id": "future",
"page": 0,
"type": "future-stamp",
"rect": {"x": 0, "y": 0, "width": 10, "height": 10},
"props": {},
},
]
exported = export_annotations(source, annotations, tmp_path / "assets")
output = tmp_path / "exported.pdf"
output.write_bytes(exported)
with pymupdf.open(output) as document:
page = document[0]
assert page.rotation == 90
assert page.rect.width == 792
assert page.rect.height == 612
assert "Exported text" in page.get_text("text")
assert "Ava" in page.get_text("text")
# Normalize only for inspection: the exported PDF still retains the
# original page rotation above.
page.set_rotation(0)
words = page.get_text("words")
text_word = next(word for word in words if word[4] == "Exported")
assert 35 <= text_word[0] <= 45
assert 45 <= text_word[1] <= 60
assert len(page.get_drawings()) >= 3
def test_export_does_not_mutate_annotation_input(tmp_path) -> None:
source = tmp_path / "source.pdf"
_source_pdf(source)
annotations = [
{
"id": "unknown",
"page": 0,
"type": "not-yet-supported",
"rect": {"x": 1, "y": 2, "width": 3, "height": 4},
"props": {"future": True},
}
]
before = json.loads(json.dumps(annotations))
export_annotations(source, annotations, tmp_path / "assets")
assert annotations == before

View file

@ -30,12 +30,18 @@ dev = [
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
# FastAPI requires dependency and multipart declarations in parameter defaults;
# B008 treats that framework idiom as a mutable-default hazard.
ignore = ["B008"]
[tool.mypy]
python_version = "3.12"
@ -43,6 +49,21 @@ strict = true
warn_return_any = true
warn_unused_configs = true
# PyMuPDF does not ship complete type information. Keep strict checking for
# application code while isolating its untyped boundary and test fixtures.
[[tool.mypy.overrides]]
module = [
"app.services.export.renderer",
"app.services.storage",
"app.services.thumbnails",
"app.api.v1.debug",
]
disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def"]
[[tool.mypy.overrides]]
module = ["app.tests.*"]
disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def", "dict-item"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["app/tests"]