Rescue Paperjet v1 implementation
This commit is contained in:
parent
7ff5c8130d
commit
db9e2ca51b
83 changed files with 6186 additions and 3894 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
94
backend/app/api/v1/export.py
Normal file
94
backend/app/api/v1/export.py
Normal 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}"'},
|
||||
)
|
||||
203
backend/app/api/v1/versions.py
Normal file
203
backend/app/api/v1/versions.py
Normal 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)
|
||||
BIN
backend/app/assets/fonts/GreatVibes-Regular.ttf
Normal file
BIN
backend/app/assets/fonts/GreatVibes-Regular.ttf
Normal file
Binary file not shown.
93
backend/app/assets/fonts/OFL.txt
Normal file
93
backend/app/assets/fonts/OFL.txt
Normal 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.
|
||||
BIN
backend/app/assets/fonts/allura.ttf
Normal file
BIN
backend/app/assets/fonts/allura.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/fonts/caveat.ttf
Normal file
BIN
backend/app/assets/fonts/caveat.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/fonts/dancing-script.ttf
Normal file
BIN
backend/app/assets/fonts/dancing-script.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/fonts/sacramento.ttf
Normal file
BIN
backend/app/assets/fonts/sacramento.ttf
Normal file
Binary file not shown.
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ All settings are driven by environment variables (see .env.example).
|
|||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
backend/app/schemas/export.py
Normal file
8
backend/app/schemas/export.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""PDF export request schema."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
versionId: str | None = None
|
||||
flatten: bool = True
|
||||
35
backend/app/schemas/versions.py
Normal file
35
backend/app/schemas/versions.py
Normal 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
|
||||
12
backend/app/services/assets.py
Normal file
12
backend/app/services/assets.py
Normal 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)
|
||||
1
backend/app/services/export/__init__.py
Normal file
1
backend/app/services/export/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""PDF export services."""
|
||||
465
backend/app/services/export/renderer.py
Normal file
465
backend/app/services/export/renderer.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
121
backend/app/tests/test_api_workflows.py
Normal file
121
backend/app/tests/test_api_workflows.py
Normal 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)
|
||||
121
backend/app/tests/test_export.py
Normal file
121
backend/app/tests/test_export.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue