Initial commit with Phase 0 Scaffolding
This commit is contained in:
parent
b393607602
commit
c545d4b17d
51 changed files with 7064 additions and 4 deletions
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet Backend
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# API v1 package init
|
||||
10
backend/app/api/v1/__init__.py
Normal file
10
backend/app/api/v1/__init__.py
Normal 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)
|
||||
13
backend/app/api/v1/health.py
Normal file
13
backend/app/api/v1/health.py
Normal 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}
|
||||
1
backend/app/auth/__init__.py
Normal file
1
backend/app/auth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet backend auth
|
||||
47
backend/app/config.py
Normal file
47
backend/app/config.py
Normal 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
58
backend/app/db.py
Normal 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
85
backend/app/main.py
Normal 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)
|
||||
13
backend/app/models/__init__.py
Normal file
13
backend/app/models/__init__.py
Normal 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"]
|
||||
32
backend/app/models/annotation_state.py
Normal file
32
backend/app/models/annotation_state.py
Normal 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")
|
||||
58
backend/app/models/document.py
Normal file
58
backend/app/models/document.py
Normal 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"),
|
||||
)
|
||||
31
backend/app/models/settings.py
Normal file
31
backend/app/models/settings.py
Normal 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(),
|
||||
)
|
||||
45
backend/app/models/version.py
Normal file
45
backend/app/models/version.py
Normal 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"),
|
||||
)
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet backend schemas
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet backend services
|
||||
1
backend/app/tests/__init__.py
Normal file
1
backend/app/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Backend tests
|
||||
Loading…
Add table
Add a link
Reference in a new issue