Fix bugs, convert thumbnail to png, add context menu
Some checks failed
Automated Container Build / build-and-push (push) Failing after 2s
CI / Backend (Python) (push) Failing after 9s
CI / Frontend (TypeScript) (push) Failing after 4m46s

This commit is contained in:
Elijah 2026-06-10 19:06:28 -07:00
parent c545d4b17d
commit eb8a302222
37 changed files with 1916 additions and 137 deletions

View file

@ -2,9 +2,17 @@
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.documents import router as documents_router
from app.api.v1.health import router as health_router
router = APIRouter(prefix="/api/v1")
# Auth
router.include_router(auth_router)
# Documents
router.include_router(documents_router)
# Health (unauthenticated)
router.include_router(health_router)

124
backend/app/api/v1/auth.py Normal file
View file

@ -0,0 +1,124 @@
"""Authentication endpoints."""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.auth.hashing import hash_password, verify_password
from app.auth.rate_limit import check_rate_limit, clear_attempts, record_failed_attempt
from app.auth.session import clear_session, create_session, verify_session
from app.config import settings
from app.db import get_db
from app.models.settings import Settings
from app.schemas.auth import AuthStatusResponse, ChangePasswordRequest, LoginRequest, SetupRequest
router = APIRouter(prefix="/auth", tags=["auth"])
def get_client_ip(request: Request) -> str:
"""Helper to extract client IP for rate limiting."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
@router.get("/status", response_model=AuthStatusResponse)
def get_auth_status(request: Request, db: Session = Depends(get_db)) -> AuthStatusResponse:
"""Check if app requires setup and if user is logged in."""
app_settings = db.query(Settings).first()
if not app_settings:
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,
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"}
@router.post("/login", dependencies=[Depends(verify_csrf)])
def login(
request: Request,
data: LoginRequest,
response: Response,
db: Session = Depends(get_db)
) -> dict[str, str]:
"""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"}
@router.post("/logout", dependencies=[Depends(verify_csrf)])
def logout(response: Response) -> dict[str, str]:
"""Clear session cookie."""
clear_session(response)
return {"status": "ok"}
@router.put("/password", dependencies=[Depends(verify_csrf), Depends(get_current_user)])
def change_password(
request: Request,
data: ChangePasswordRequest,
db: Session = Depends(get_db)
) -> dict[str, str]:
"""Change an existing password."""
ip = get_client_ip(request)
check_rate_limit(ip)
app_settings = db.query(Settings).first()
if not app_settings or not app_settings.password_hash:
raise HTTPException(status_code=400, detail="Setup required first")
if not verify_password(app_settings.password_hash, data.current_password):
record_failed_attempt(ip)
raise HTTPException(status_code=401, detail="Invalid current password")
clear_attempts(ip)
app_settings.password_hash = hash_password(data.new_password)
db.commit()
return {"status": "ok"}

View file

@ -0,0 +1,221 @@
"""Document management endpoints."""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime, timezone
from app.auth.dependencies import get_current_user, verify_csrf
from app.db import get_db
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.version import Version
from app.schemas.document import (
DocumentListResponse, DocumentMeta, BulkDeleteRequest,
BulkRestoreRequest, 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
router = APIRouter(
prefix="/documents",
tags=["documents"],
dependencies=[Depends(get_current_user)]
)
@router.get("", response_model=DocumentListResponse)
def list_documents(
query: str = "",
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
"""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)
@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)
):
"""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)
@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
async def upload_document(
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""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()
doc = Document(
id=file_id,
title=file.filename or "Untitled",
original_filename=file.filename or "Untitled.pdf",
file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"),
page_count=page_count,
size_bytes=size_bytes,
created_at=now,
updated_at=now
)
db.add(doc)
db.commit()
db.refresh(doc)
return 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()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def update_document(id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)):
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
if update.in_trash is not None:
if update.in_trash:
doc.deleted_at = datetime.now(timezone.utc).isoformat()
else:
doc.deleted_at = None
doc.updated_at = datetime.now(timezone.utc).isoformat()
db.commit()
db.refresh(doc)
return doc
@router.delete("/{id}", dependencies=[Depends(verify_csrf)])
def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)):
"""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)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
db.commit()
return {"status": "deleted"}
else:
# Soft delete
doc.deleted_at = datetime.now(timezone.utc).isoformat()
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)):
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()
db.commit()
db.refresh(doc)
return 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)
db.commit()
return {"status": "ok", "count": len(data.ids)}
@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)
db.commit()
return {"status": "ok", "count": len(data.ids)}
@router.post("/trash/empty", dependencies=[Depends(verify_csrf)])
def empty_trash(db: Session = Depends(get_db)):
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)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
count += 1
db.commit()
return {"status": "ok", "deleted": count}
@router.get("/{id}/file")
def get_document_file(id: str, db: Session = Depends(get_db)):
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)):
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
path = settings.THUMBNAILS_PATH / f"{doc.id}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Thumbnail missing")
return FileResponse(path, media_type="image/png")

View file

@ -0,0 +1,35 @@
"""FastAPI dependencies for authentication and security."""
from fastapi import Request, HTTPException
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.
Raises 401 if missing or invalid.
Returns the user_id.
"""
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
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"
)

View file

@ -0,0 +1,20 @@
"""Argon2id password hashing and verification."""
from argon2 import PasswordHasher
from argon2.exceptions import 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:
return False

View file

@ -0,0 +1,59 @@
"""In-memory rate limiting for login attempts."""
import time
from collections import defaultdict
from fastapi import HTTPException
from app.config import settings
# In-memory store: IP address -> {"attempts": int, "lockout_until": float}
_rate_limits: dict[str, dict[str, float]] = defaultdict(
lambda: {"attempts": 0, "lockout_until": 0.0}
)
def check_rate_limit(ip: str) -> None:
"""
Check if the IP is currently locked out.
Raises a 429 HTTPException if locked out.
"""
now = time.time()
record = _rate_limits[ip]
if record["lockout_until"] > now:
remaining = int(record["lockout_until"] - now)
raise HTTPException(
status_code=429,
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
record["lockout_until"] = 0.0
def record_failed_attempt(ip: str) -> None:
"""
Record a failed login attempt for the IP.
Applies lockout if MAX_ATTEMPTS is reached.
"""
now = time.time()
record = _rate_limits[ip]
record["attempts"] += 1
if record["attempts"] >= settings.LOGIN_MAX_ATTEMPTS:
record["lockout_until"] = now + settings.LOGIN_LOCKOUT_SECONDS
remaining = settings.LOGIN_LOCKOUT_SECONDS
raise HTTPException(
status_code=429,
detail=f"Too many failed attempts. Try again in {remaining} seconds.",
headers={"Retry-After": str(remaining)},
)
def clear_attempts(ip: str) -> None:
"""Clear all failed attempts for the IP (e.g., after successful login)."""
if ip in _rate_limits:
_rate_limits[ip] = {"attempts": 0, "lockout_until": 0.0}

View file

@ -0,0 +1,49 @@
"""Session token generation and validation."""
from typing import Any
from fastapi import Response
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app.config import settings
def get_serializer() -> URLSafeTimedSerializer:
"""Return a configured URLSafeTimedSerializer."""
return URLSafeTimedSerializer(settings.SECRET_KEY)
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,
max_age=settings.COOKIE_MAX_AGE_SECONDS,
secure=settings.COOKIE_SECURE,
httponly=True,
samesite="lax",
)
def clear_session(response: Response) -> None:
"""Clear the session cookie."""
response.delete_cookie(
key=settings.COOKIE_NAME,
secure=settings.COOKIE_SECURE,
httponly=True,
samesite="lax",
)
def verify_session(token: str) -> int | None:
"""
Verify the session token.
Returns the user_id if valid, or None if invalid/expired.
"""
serializer = get_serializer()
try:
data: dict[str, Any] = serializer.loads(
token,
max_age=settings.COOKIE_MAX_AGE_SECONDS
)
return data.get("user_id")
except (BadSignature, SignatureExpired):
return None

View file

@ -38,6 +38,18 @@ class Document(Base):
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
deleted_at: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
@property
def in_trash(self) -> bool:
return self.deleted_at is not None
@property
def filename(self) -> str:
return self.original_filename
@property
def mime_type(self) -> str:
return "application/pdf"
# Relationships
annotation_state: Mapped["AnnotationState"] = relationship(
"AnnotationState",

View file

@ -0,0 +1,17 @@
"""Auth request and response schemas."""
from pydantic import BaseModel, Field
class SetupRequest(BaseModel):
password: str = Field(..., min_length=8)
class LoginRequest(BaseModel):
password: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8)
class AuthStatusResponse(BaseModel):
setupRequired: bool
loggedIn: bool

View file

@ -0,0 +1,31 @@
"""Pydantic schemas for Document APIs."""
from pydantic import BaseModel
from typing import Optional
class DocumentMeta(BaseModel):
id: str
title: str
filename: str
mime_type: str
size_bytes: int
in_trash: bool
created_at: str
updated_at: str
deleted_at: Optional[str]
model_config = {"from_attributes": True}
class DocumentListResponse(BaseModel):
items: list[DocumentMeta]
total: int
class BulkDeleteRequest(BaseModel):
ids: list[str]
class BulkRestoreRequest(BaseModel):
ids: list[str]
class DocumentUpdateRequest(BaseModel):
title: Optional[str] = None
in_trash: Optional[bool] = None

View file

@ -0,0 +1,53 @@
"""Document storage service."""
import shutil
import uuid
import pymupdf
from pathlib import Path
from fastapi import UploadFile, HTTPException
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-":
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}")
async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]:
"""Save an uploaded file to disk and validate it."""
file_id = str(uuid.uuid4())
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
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")
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
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()

View file

@ -0,0 +1,38 @@
"""Thumbnail generation service."""
import pymupdf
from pathlib import Path
from app.config import settings
def generate_thumbnail(file_id: str) -> None:
"""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
try:
doc = pymupdf.open(pdf_path)
if len(doc) > 0:
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}")
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()

View file

@ -0,0 +1,48 @@
"""Background tasks for sweeping trash and old versions."""
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.version import Version
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_iso = cutoff.isoformat()
docs_to_delete = db.query(Document).filter(
Document.in_trash == True,
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 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_iso = cutoff.isoformat()
db.query(Version).filter(
Version.kind == "auto",
Version.created_at <= cutoff_iso
).delete()
db.commit()