No documents yet
-- Upload a PDF to get started -
-PaperJet
-- Self-hosted PDF editor -
-diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py
index 0353aa3..da6fd48 100644
--- a/backend/app/api/v1/__init__.py
+++ b/backend/app/api/v1/__init__.py
@@ -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)
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
new file mode 100644
index 0000000..c05c534
--- /dev/null
+++ b/backend/app/api/v1/auth.py
@@ -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"}
diff --git a/backend/app/api/v1/documents.py b/backend/app/api/v1/documents.py
new file mode 100644
index 0000000..7e572b2
--- /dev/null
+++ b/backend/app/api/v1/documents.py
@@ -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")
diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py
new file mode 100644
index 0000000..648ac19
--- /dev/null
+++ b/backend/app/auth/dependencies.py
@@ -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"
+ )
diff --git a/backend/app/auth/hashing.py b/backend/app/auth/hashing.py
new file mode 100644
index 0000000..31beb5d
--- /dev/null
+++ b/backend/app/auth/hashing.py
@@ -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
diff --git a/backend/app/auth/rate_limit.py b/backend/app/auth/rate_limit.py
new file mode 100644
index 0000000..fd25202
--- /dev/null
+++ b/backend/app/auth/rate_limit.py
@@ -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}
diff --git a/backend/app/auth/session.py b/backend/app/auth/session.py
new file mode 100644
index 0000000..837ad59
--- /dev/null
+++ b/backend/app/auth/session.py
@@ -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
diff --git a/backend/app/models/document.py b/backend/app/models/document.py
index d25aa3d..a60a695 100644
--- a/backend/app/models/document.py
+++ b/backend/app/models/document.py
@@ -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",
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
new file mode 100644
index 0000000..58bfb9d
--- /dev/null
+++ b/backend/app/schemas/auth.py
@@ -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
diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py
new file mode 100644
index 0000000..0479202
--- /dev/null
+++ b/backend/app/schemas/document.py
@@ -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
diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py
new file mode 100644
index 0000000..d500ac9
--- /dev/null
+++ b/backend/app/services/storage.py
@@ -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()
diff --git a/backend/app/services/thumbnails.py b/backend/app/services/thumbnails.py
new file mode 100644
index 0000000..fa50b92
--- /dev/null
+++ b/backend/app/services/thumbnails.py
@@ -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()
diff --git a/backend/app/services/trash_sweep.py b/backend/app/services/trash_sweep.py
new file mode 100644
index 0000000..99c6079
--- /dev/null
+++ b/backend/app/services/trash_sweep.py
@@ -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()
diff --git a/docker-compose.yml b/docker-compose.yml
index 6177a7c..d67aa0c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,8 +14,8 @@ services:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
- expose:
- - "8000"
+ ports:
+ - "8000:8000"
restart: unless-stopped
frontend:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7fc98c4..9850eac 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,14 +1,15 @@
{
- "name": "frontend",
- "version": "0.0.0",
+ "name": "paperjet",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "frontend",
- "version": "0.0.0",
+ "name": "paperjet",
+ "version": "0.1.0",
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
+ "date-fns": "^4.4.0",
"fabric": "^7.4.0",
"pdfjs-dist": "^6.0.227",
"react": "^19.2.6",
@@ -22,7 +23,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/fabric": "^5.3.11",
- "@types/node": "^24.12.3",
+ "@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -2598,6 +2599,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3508,7 +3519,6 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
diff --git a/frontend/package.json b/frontend/package.json
index 75bc372..7149971 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
+ "date-fns": "^4.4.0",
"fabric": "^7.4.0",
"pdfjs-dist": "^6.0.227",
"react": "^19.2.6",
@@ -27,7 +28,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/fabric": "^5.3.11",
- "@types/node": "^24.12.3",
+ "@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx
index 1c35dfc..967a062 100644
--- a/frontend/src/app/App.tsx
+++ b/frontend/src/app/App.tsx
@@ -1,22 +1,36 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { LoginPage } from '../pages/LoginPage'
+import { SetupPage } from '../pages/SetupPage'
import { HomePage } from '../pages/HomePage'
import { EditorPage } from '../pages/EditorPage'
+import { AuthGuard } from '../features/auth/AuthGuard'
-/**
- * Root application component with route definitions.
- *
- * Routes:
- * - /login — password entry / first-run setup
- * - / — home page (recently edited, library, trash)
- * - /editor/:id — PDF editor workspace
- */
export function App() {
return (
+ Enter your master password to unlock your library +
++ Create a master password to secure your documents. Make sure you remember it! +
++ {isTrash ? 'Nothing to see here.' : 'Drag & drop a PDF, or click Upload to get started.'} +
++ {formatDistanceToNow(new Date(doc.updated_at), { addSuffix: true })} +
++ {(doc.size_bytes / 1024 / 1024).toFixed(1)} MB +
+Release to upload to your library
+- Upload a PDF to get started -
-- Self-hosted PDF editor -
-