Initial commit with Phase 0 Scaffolding
Some checks failed
Automated Container Build / build-and-push (push) Failing after 3s
CI / Backend (Python) (push) Failing after 28s
CI / Frontend (TypeScript) (push) Failing after 5m8s

This commit is contained in:
Elijah 2026-06-10 18:28:17 -07:00
parent b393607602
commit c545d4b17d
51 changed files with 7064 additions and 4 deletions

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@
# PaperJet Backend

View file

@ -0,0 +1 @@
# API v1 package init

View file

@ -0,0 +1,10 @@
"""API v1 router — aggregates all sub-routers."""
from fastapi import APIRouter
from app.api.v1.health import router as health_router
router = APIRouter(prefix="/api/v1")
# Health (unauthenticated)
router.include_router(health_router)

View file

@ -0,0 +1,13 @@
"""Health check endpoint — unauthenticated."""
from fastapi import APIRouter
from app.config import settings
router = APIRouter(tags=["health"])
@router.get("/health")
def health_check() -> dict[str, str]:
"""Return service health status and version."""
return {"status": "ok", "version": settings.APP_VERSION}

View file

@ -0,0 +1 @@
# PaperJet backend auth

47
backend/app/config.py Normal file
View file

@ -0,0 +1,47 @@
"""
PaperJet backend configuration.
All settings are driven by environment variables (see .env.example).
"""
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# --- Security ---
SECRET_KEY: str = "change-me-in-production"
# --- Upload ---
MAX_UPLOAD_MB: int = 200
# --- Retention ---
TRASH_RETENTION_DAYS: int = 30
AUTO_VERSION_RETENTION_DAYS: int = 30
# --- Cookie ---
COOKIE_SECURE: bool = False # Set True in production (behind HTTPS proxy)
COOKIE_MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60 # 7 days
COOKIE_NAME: str = "paperjet_session"
# --- Database ---
DATABASE_PATH: Path = Path("/data/db/app.sqlite")
# --- Storage ---
PDF_STORAGE_PATH: Path = Path("/data/pdfs")
THUMBNAILS_PATH: Path = Path("/data/thumbnails")
# --- App ---
APP_VERSION: str = "0.1.0"
DEBUG: bool = False
# --- Rate Limiting ---
LOGIN_MAX_ATTEMPTS: int = 5
LOGIN_LOCKOUT_SECONDS: int = 900 # 15 minutes
model_config = {"env_prefix": "PAPERJET_", "env_file": ".env", "extra": "ignore"}
settings = Settings()

58
backend/app/db.py Normal file
View file

@ -0,0 +1,58 @@
"""
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 typing import Any
from sqlalchemy import event, create_engine, Engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
"""SQLAlchemy declarative base for all models."""
pass
def _set_sqlite_wal_mode(dbapi_conn: Any, _connection_record: Any) -> None:
"""Enable WAL mode and other SQLite performance pragmas on every connection."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def create_db_engine() -> Engine:
"""Create the SQLAlchemy engine with SQLite and WAL mode."""
# Ensure the database directory exists
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{settings.DATABASE_PATH}",
echo=settings.DEBUG,
connect_args={"check_same_thread": False},
)
# Register WAL mode on every new connection
event.listen(engine, "connect", _set_sqlite_wal_mode)
return engine
engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
def get_db() -> Session:
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db # type: ignore[misc]
finally:
db.close()

85
backend/app/main.py Normal file
View file

@ -0,0 +1,85 @@
"""
PaperJet backend FastAPI application factory.
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 typing import Any
from fastapi import FastAPI, Request
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
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan: startup and shutdown tasks."""
# Create all tables (Alembic handles migrations in production,
# but this ensures tables exist for development/first run)
Base.metadata.create_all(bind=engine)
# Ensure storage directories exist
settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
yield
# Shutdown: dispose of the engine
engine.dispose()
app = FastAPI(
title="PaperJet",
description="Self-hosted PDF editor API",
version=settings.APP_VERSION,
docs_url="/api/v1/docs" if settings.DEBUG else None,
redoc_url="/api/v1/redoc" if settings.DEBUG else None,
openapi_url="/api/v1/openapi.json",
lifespan=lifespan,
)
# --- Error handlers ---
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent 404 error envelope."""
return JSONResponse(
status_code=404,
content={"error": {"code": "not_found", "message": "Resource not found"}},
)
@app.exception_handler(422)
async def validation_error_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent validation error envelope."""
return JSONResponse(
status_code=422,
content={
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": exc.errors() if hasattr(exc, "errors") else str(exc),
}
},
)
@app.exception_handler(500)
async def internal_error_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent 500 error envelope."""
return JSONResponse(
status_code=500,
content={"error": {"code": "internal_error", "message": "Internal server error"}},
)
# --- Mount routers ---
app.include_router(v1_router)

View file

@ -0,0 +1,13 @@
"""
SQLAlchemy models for PaperJet.
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.version import Version
__all__ = ["Settings", "Document", "AnnotationState", "Version"]

View file

@ -0,0 +1,32 @@
"""AnnotationState model — current working annotation layer per document."""
from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class AnnotationState(Base):
"""
Current working annotation state for a document.
One row per document. `data` is a JSON text column containing
the full annotation array opaque to the server except during export.
"""
__tablename__ = "annotation_states"
document_id: Mapped[str] = mapped_column(
Text, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
data: Mapped[str] = mapped_column(Text, default="[]") # JSON array
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
# Relationships
document: Mapped["Document"] = relationship("Document", back_populates="annotation_state")

View file

@ -0,0 +1,58 @@
"""Document model — uploaded PDFs with soft-delete support."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class Document(Base):
"""
A single uploaded PDF document.
Supports soft deletion via `deleted_at` when set, the document
is in the trash and can be restored or permanently purged.
"""
__tablename__ = "documents"
id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid)
title: Mapped[str] = mapped_column(Text, nullable=False)
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
file_path: Mapped[str] = mapped_column(Text, nullable=False)
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
page_count: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[str] = mapped_column(Text, default=_now_iso)
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)
# Relationships
annotation_state: Mapped["AnnotationState"] = relationship(
"AnnotationState",
back_populates="document",
uselist=False,
cascade="all, delete-orphan",
)
versions: Mapped[list["Version"]] = relationship(
"Version",
back_populates="document",
cascade="all, delete-orphan",
order_by="Version.created_at.desc()",
)
__table_args__ = (
Index("ix_documents_updated_at", "updated_at"),
Index("ix_documents_deleted_at", "deleted_at"),
)

View file

@ -0,0 +1,31 @@
"""Settings model — singleton row for app-wide configuration."""
from datetime import datetime, timezone
from sqlalchemy import Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Settings(Base):
"""
Singleton settings row (id=1).
Stores the hashed password and app-level config.
password_hash is NULL until first-run setup.
"""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
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(),
)
updated_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(timezone.utc).isoformat(),
onupdate=lambda: datetime.now(timezone.utc).isoformat(),
)

View file

@ -0,0 +1,45 @@
"""Version model — annotation state snapshots for history/recovery."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class Version(Base):
"""
A snapshot of a document's annotation state at a point in time.
Versions serve as a safety net for accidental annotation loss.
`kind` is either 'manual' (user-created) or 'auto' (system-created
at safety boundaries like document close or before destructive ops).
"""
__tablename__ = "versions"
id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid)
document_id: Mapped[str] = mapped_column(
Text, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
label: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
data: Mapped[str] = mapped_column(Text, nullable=False) # JSON snapshot
kind: Mapped[str] = mapped_column(Text, nullable=False) # 'manual' | 'auto'
created_at: Mapped[str] = mapped_column(Text, default=_now_iso)
# Relationships
document: Mapped["Document"] = relationship("Document", back_populates="versions")
__table_args__ = (
Index("ix_versions_document_created", "document_id", "created_at"),
)

View file

@ -0,0 +1 @@
# PaperJet backend schemas

View file

@ -0,0 +1 @@
# PaperJet backend services

View file

@ -0,0 +1 @@
# Backend tests