Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""images and renditions

Revision ID: b85ca8527eb6
Revises: 57fafd32a1da
Create Date: 2026-09-23 16:52:18.726916
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision: str = "b85ca8527eb6"
down_revision: str | None = "57fafd32a1da"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"images",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("person_id", sa.Uuid(), nullable=False),
sa.Column("role", sa.String(length=16), nullable=False),
sa.Column("modality", sa.String(length=16), nullable=False),
sa.Column("sha256", sa.String(length=64), nullable=False),
sa.Column("bytes", sa.BigInteger(), nullable=False),
sa.Column("mime", sa.String(length=64), nullable=False),
sa.Column("width", sa.Integer(), nullable=False),
sa.Column("height", sa.Integer(), nullable=False),
sa.Column("orientation", sa.Integer(), nullable=False),
sa.Column("source_format", sa.String(length=16), nullable=False),
sa.Column("re_encoded", sa.Boolean(), nullable=False),
sa.Column("captured_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("captured_tz", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["created_by"], ["users.id"], name=op.f("fk_images_created_by_users"), ondelete="SET NULL"
),
sa.ForeignKeyConstraint(
["person_id"], ["persons.id"], name=op.f("fk_images_person_id_persons"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_images")),
)
with op.batch_alter_table("images", schema=None) as batch_op:
batch_op.create_index("ix_images_person_id", ["person_id"], unique=False)
batch_op.create_index("ix_images_sha256", ["sha256"], unique=False)

op.create_table(
"renditions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("image_id", sa.Uuid(), nullable=False),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("sha256", sa.String(length=64), nullable=False),
sa.Column("bytes", sa.BigInteger(), nullable=False),
sa.Column("mime", sa.String(length=64), nullable=False),
sa.Column("width", sa.Integer(), nullable=False),
sa.Column("height", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["image_id"], ["images.id"], name=op.f("fk_renditions_image_id_images"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_renditions")),
)
with op.batch_alter_table("renditions", schema=None) as batch_op:
batch_op.create_index("ix_renditions_image_id_kind", ["image_id", "kind"], unique=True)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("renditions", schema=None) as batch_op:
batch_op.drop_index("ix_renditions_image_id_kind")

op.drop_table("renditions")
with op.batch_alter_table("images", schema=None) as batch_op:
batch_op.drop_index("ix_images_sha256")
batch_op.drop_index("ix_images_person_id")

op.drop_table("images")
# ### end Alembic commands ###
5 changes: 4 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ dependencies = [
"argon2-cffi>=23.1",
"structlog>=24.4",
"psycopg[binary]>=3.2",
"pillow>=11.0",
"pillow-heif>=1.0",
"python-multipart>=0.0.20",
]

[project.scripts]
Expand Down Expand Up @@ -55,7 +58,7 @@ packages = ["nevus"]
plugins = ["pydantic.mypy"]

[[tool.mypy.overrides]]
module = ["argon2.*"]
module = ["argon2.*", "pillow_heif.*"]
ignore_missing_imports = true

[tool.pytest.ini_options]
Expand Down
214 changes: 214 additions & 0 deletions backend/src/nevus/api/images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""Uploading photographs and serving them to the people allowed to see them."""

from __future__ import annotations

import uuid
from datetime import datetime
from typing import Annotated, Literal

from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session

from nevus.auth import service
from nevus.auth.dependencies import AppSettings, CurrentUser, DbSession, client_ip
from nevus.db.models import (
ACCESS_MANAGER,
ACCESS_OWNER,
IMAGE_MODALITIES,
IMAGE_ROLES,
Image,
Person,
PersonAccess,
Rendition,
User,
)
from nevus.db.types import utcnow
from nevus.storage.blobs import BlobStore, InsufficientStorageError
from nevus.storage.renditions import make_renditions
from nevus.storage.scrub import UnsupportedImageError, scrub

router = APIRouter(prefix="/api", tags=["images"])

ImageRole = Literal["overview", "close_up", "with_reference", "other"]
Modality = Literal["camera", "dermatoscope"]
RenditionKind = Literal["original", "full", "preview", "thumb"]
IMMUTABLE_PRIVATE = "private, max-age=31536000, immutable"


class RenditionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)

kind: str
width: int
height: int
bytes: int


class ImageOut(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: uuid.UUID
person_id: uuid.UUID
role: str
modality: str
sha256: str
bytes: int
mime: str
width: int
height: int
orientation: int
source_format: str
re_encoded: bool
captured_at: datetime | None
created_at: datetime
renditions: list[RenditionOut]


def _store(request: Request) -> BlobStore:
store: BlobStore = request.app.state.blob_store
return store


def _person_for(db: Session, person_id: uuid.UUID, user: User, *roles: str) -> Person:
person = db.get(Person, person_id)
access = db.get(PersonAccess, (person_id, user.id)) if person else None
if person is None or person.deleted_at is not None or access is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such person.")
if roles and access.role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Your access to this person does not allow that.")
return person


@router.post("/persons/{person_id}/images", response_model=ImageOut, status_code=status.HTTP_201_CREATED)
async def upload_image(
person_id: uuid.UUID,
request: Request,
user: CurrentUser,
db: DbSession,
settings: AppSettings,
file: Annotated[UploadFile, File()],
role: Annotated[ImageRole, Form()] = "close_up",
modality: Annotated[Modality, Form()] = "camera",
captured_at: Annotated[datetime | None, Form()] = None,
captured_tz: Annotated[str | None, Form(max_length=64)] = None,
) -> ImageOut:
_person_for(db, person_id, user, ACCESS_OWNER, ACCESS_MANAGER)
data = await file.read(settings.max_upload_bytes + 1)
if len(data) > settings.max_upload_bytes:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "The photo is larger than the upload limit.")
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "The upload is empty.")
try:
scrubbed = scrub(data)
except UnsupportedImageError as error:
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, str(error)) from error
if scrubbed.width * scrubbed.height > settings.max_upload_pixels:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "The photo has more pixels than the limit.")
store = _store(request)
try:
digest = store.put(scrubbed.data)
renditions = make_renditions(scrubbed.data, scrubbed.orientation)
stored = [(r, store.put(r.data, derived=True)) for r in renditions]
except InsufficientStorageError as error:
raise HTTPException(status.HTTP_507_INSUFFICIENT_STORAGE, "The data volume is nearly full.") from error
when = captured_at or scrubbed.captured_at
image = Image(
person_id=person_id,
role=role,
modality=modality,
sha256=digest,
bytes=len(scrubbed.data),
mime=scrubbed.mime,
width=scrubbed.width,
height=scrubbed.height,
orientation=scrubbed.orientation,
source_format=scrubbed.source_format,
re_encoded=scrubbed.re_encoded,
captured_at=when.astimezone() if when and when.tzinfo else when,
captured_tz=captured_tz,
created_by=user.id,
)
db.add(image)
db.flush()
for rendition, rendition_digest in stored:
db.add(
Rendition(
image_id=image.id,
kind=rendition.kind,
sha256=rendition_digest,
bytes=len(rendition.data),
mime=rendition.mime,
width=rendition.width,
height=rendition.height,
)
)
db.flush()
db.refresh(image)
service.audit(db, "image.upload", user, "image", image.id, client_ip(request, settings), {"person": str(person_id)})
return ImageOut.model_validate(image)


@router.get("/persons/{person_id}/images", response_model=list[ImageOut])
def list_images(person_id: uuid.UUID, user: CurrentUser, db: DbSession) -> list[ImageOut]:
_person_for(db, person_id, user)
rows = db.scalars(
select(Image).where(Image.person_id == person_id, Image.deleted_at.is_(None)).order_by(Image.created_at.desc())
)
return [ImageOut.model_validate(i) for i in rows]


def _image_for(db: Session, image_id: uuid.UUID, user: User, *roles: str) -> Image:
image = db.get(Image, image_id)
if image is None or image.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.")
_person_for(db, image.person_id, user, *roles)
return image


@router.get("/images/{image_id}", response_model=ImageOut)
def get_image(image_id: uuid.UUID, user: CurrentUser, db: DbSession) -> ImageOut:
return ImageOut.model_validate(_image_for(db, image_id, user))


@router.get("/images/{image_id}/{kind}", response_class=FileResponse)
def image_file(
image_id: uuid.UUID, kind: RenditionKind, request: Request, user: CurrentUser, db: DbSession
) -> Response:
"""The bytes, only for people who may see the person; cacheable forever because the content is addressed by hash."""
image = _image_for(db, image_id, user)
store = _store(request)
if kind == "original":
digest, mime = image.sha256, image.mime
path = store.path(digest)
else:
rendition = next((r for r in image.renditions if r.kind == kind), None)
if rendition is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such rendition.")
digest, mime = rendition.sha256, rendition.mime
path = store.path(digest, derived=True)
if request.headers.get("if-none-match") == f'"{digest}"':
return Response(status_code=status.HTTP_304_NOT_MODIFIED)
if not path.is_file():
raise HTTPException(status.HTTP_404_NOT_FOUND, "The file is missing from the store.")
return FileResponse(
path,
media_type=mime,
headers={"Cache-Control": IMMUTABLE_PRIVATE, "ETag": f'"{digest}"', "Content-Disposition": "inline"},
)


@router.delete("/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_image(
image_id: uuid.UUID, request: Request, user: CurrentUser, db: DbSession, settings: AppSettings
) -> Response:
"""Moves the image to the trash; the purge and the garbage collection of blobs arrive with data management."""
image = _image_for(db, image_id, user, ACCESS_OWNER, ACCESS_MANAGER)
image.deleted_at = utcnow()
service.audit(db, "image.delete", user, "image", image.id, client_ip(request, settings))
return Response(status_code=status.HTTP_204_NO_CONTENT)


__all__ = ["IMAGE_MODALITIES", "IMAGE_ROLES", "router"]
4 changes: 4 additions & 0 deletions backend/src/nevus/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from nevus import __version__
from nevus.api.auth import router as auth_router
from nevus.api.health import router as health_router
from nevus.api.images import router as images_router
from nevus.api.persons import router as persons_router
from nevus.api.users import router as users_router
from nevus.auth.ratelimit import LoginRateLimiter
Expand All @@ -19,6 +20,7 @@
from nevus.db.engine import make_engine, make_session_factory
from nevus.db.migrate import upgrade_to_head
from nevus.logging import configure_logging, get_logger
from nevus.storage.blobs import BlobStore
from nevus.web.csrf import CsrfMiddleware
from nevus.web.security import HostAllowlistMiddleware, SecurityHeadersMiddleware
from nevus.web.static import mount_frontend
Expand Down Expand Up @@ -62,6 +64,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.settings = settings
app.state.engine = engine
app.state.session_factory = session_factory
app.state.blob_store = BlobStore(settings.blobs_dir, settings.min_free_bytes)
app.state.login_limiter = LoginRateLimiter(settings.login_attempts, settings.login_window_minutes * 60)

app.add_middleware(SecurityHeadersMiddleware)
Expand All @@ -72,6 +75,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(persons_router)
app.include_router(images_router)
mount_frontend(app, _static_dir(settings))
return app

Expand Down
3 changes: 3 additions & 0 deletions backend/src/nevus/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ class Settings(BaseSettings):
login_attempts: int = Field(default=10, ge=3, le=100, description="Failed logins allowed per window")
login_window_minutes: int = Field(default=15, ge=1, le=1440)
trust_proxy_headers: bool = Field(default=False, description="Trust X-Forwarded-Proto/For from a reverse proxy")
max_upload_bytes: int = Field(default=30 * 1024 * 1024, ge=1024 * 1024)
max_upload_pixels: int = Field(default=24_000_000, ge=1_000_000)
min_free_bytes: int = Field(default=2 * 1024**3, ge=0, description="Refuse uploads below this free space")

@field_validator("allowed_hosts", mode="before")
@classmethod
Expand Down
Loading
Loading