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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@ FRONTEND_URL=http://localhost:5173

# CORS — comma-separated allowed origins (required in production)
CORS_ORIGINS=

# Cloudflare R2 storage
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=trove-images
R2_PUBLIC_URL=
1 change: 1 addition & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Import all models so Alembic can detect them
from app.models import ( # noqa: F401
Collection,
Image,
Item,
ItemNote,
Mark,
Expand Down
66 changes: 66 additions & 0 deletions alembic/versions/34fcd61c94a9_add_images_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""add images table

Revision ID: 34fcd61c94a9
Revises: 62a67799efe0
Create Date: 2026-02-04 16:55:08.313105

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "34fcd61c94a9"
down_revision: str | Sequence[str] | None = "62a67799efe0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"images",
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False),
sa.Column("item_id", sa.UUID(as_uuid=False), nullable=True),
sa.Column("mark_id", sa.UUID(as_uuid=False), nullable=True),
sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("storage_key", sa.String(length=500), nullable=False),
sa.Column("url", sa.String(length=1000), nullable=False),
sa.Column("content_type", sa.String(length=50), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"(item_id IS NOT NULL AND mark_id IS NULL) OR (item_id IS NULL AND mark_id IS NOT NULL)",
name="ck_images_exactly_one_parent",
),
sa.ForeignKeyConstraint(["item_id"], ["items.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["mark_id"], ["marks.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("storage_key"),
)
op.create_index(op.f("ix_images_item_id"), "images", ["item_id"], unique=False)
op.create_index(op.f("ix_images_mark_id"), "images", ["mark_id"], unique=False)
op.create_index(op.f("ix_images_user_id"), "images", ["user_id"], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_images_user_id"), table_name="images")
op.drop_index(op.f("ix_images_mark_id"), table_name="images")
op.drop_index(op.f("ix_images_item_id"), table_name="images")
op.drop_table("images")
# ### end Alembic commands ###
9 changes: 9 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ class Settings(BaseSettings):
# Cookie auth
cookie_domain: str | None = None

# Cloudflare R2 storage
r2_account_id: str = ""
r2_access_key_id: str = ""
r2_secret_access_key: str = ""
r2_bucket_name: str = "trove-images"
r2_public_url: str = ""

@property
def is_development(self) -> bool:
return self.environment == "development"
Expand All @@ -57,6 +64,8 @@ def validate_production_settings(self) -> "Settings":
)
if "postgres:postgres@" in self.database_url:
raise ValueError("Default database credentials must not be used in production")
if not self.r2_account_id or not self.r2_public_url:
raise ValueError("R2_ACCOUNT_ID and R2_PUBLIC_URL must be set in production")
return self


Expand Down
34 changes: 34 additions & 0 deletions app/image_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from fastapi import HTTPException, UploadFile, status

ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
MAX_ITEM_IMAGES = 10
MAX_MARK_IMAGES = 3


async def validate_image_file(file: UploadFile) -> bytes:
"""Validate an uploaded image file and return its contents.

Raises HTTPException if the file is invalid.
"""
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type '{file.content_type}' not allowed. Allowed types: JPEG, PNG, WebP",
)

data = await file.read()

if len(data) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)} MB",
)

if len(data) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty",
)

return data
4 changes: 4 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
from app.routers import (
collection_types_router,
collections_router,
item_images_router,
item_notes_router,
items_router,
mark_images_router,
marks_router,
provenance_router,
tags_router,
Expand Down Expand Up @@ -116,6 +118,8 @@ async def add_security_headers(request: Request, call_next) -> Response:
app.include_router(items_router)
app.include_router(tags_router)
app.include_router(marks_router)
app.include_router(item_images_router)
app.include_router(mark_images_router)
app.include_router(provenance_router)
app.include_router(item_notes_router)

Expand Down
2 changes: 2 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from app.models.collection import Collection
from app.models.image import Image
from app.models.item import Item
from app.models.item_note import ItemNote
from app.models.mark import Mark
Expand All @@ -11,6 +12,7 @@
__all__ = [
"User",
"Collection",
"Image",
"Item",
"ItemNote",
"Mark",
Expand Down
67 changes: 67 additions & 0 deletions app/models/image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from datetime import datetime
from uuid import uuid4

from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database import Base


class Image(Base):
"""Image attached to an item or a mark."""

__tablename__ = "images"

id: Mapped[str] = mapped_column(
UUID(as_uuid=False),
primary_key=True,
default=lambda: str(uuid4()),
)
user_id: Mapped[str] = mapped_column(
UUID(as_uuid=False),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
item_id: Mapped[str | None] = mapped_column(
UUID(as_uuid=False),
ForeignKey("items.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
mark_id: Mapped[str | None] = mapped_column(
UUID(as_uuid=False),
ForeignKey("marks.id", ondelete="CASCADE"),
nullable=True,
index=True,
)

filename: Mapped[str] = mapped_column(String(255), nullable=False)
storage_key: Mapped[str] = mapped_column(String(500), nullable=False, unique=True)
url: Mapped[str] = mapped_column(String(1000), nullable=False)
content_type: Mapped[str] = mapped_column(String(50), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)

# Relationships
user = relationship("User")
item = relationship("Item", back_populates="images")
mark = relationship("Mark", back_populates="images")

__table_args__ = (
CheckConstraint(
"(item_id IS NOT NULL AND mark_id IS NULL) OR "
"(item_id IS NULL AND mark_id IS NOT NULL)",
name="ck_images_exactly_one_parent",
),
)

def __repr__(self) -> str:
return f"<Image {self.filename}>"
7 changes: 7 additions & 0 deletions app/models/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ class Item(Base):
order_by="ItemNote.created_at",
cascade="all, delete-orphan",
)
images = relationship(
"Image",
back_populates="item",
lazy="selectin",
order_by="Image.position",
cascade="all, delete-orphan",
)

def __repr__(self) -> str:
return f"<Item {self.name}>"
7 changes: 7 additions & 0 deletions app/models/mark.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class Mark(Base):

# Relationships
item = relationship("Item", back_populates="marks")
images = relationship(
"Image",
back_populates="mark",
lazy="selectin",
order_by="Image.position",
cascade="all, delete-orphan",
)

def __repr__(self) -> str:
return f"<Mark {self.title}>"
4 changes: 4 additions & 0 deletions app/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from app.routers.collection_types import router as collection_types_router
from app.routers.collections import router as collections_router
from app.routers.item_images import router as item_images_router
from app.routers.item_notes import router as item_notes_router
from app.routers.items import router as items_router
from app.routers.mark_images import router as mark_images_router
from app.routers.marks import router as marks_router
from app.routers.provenance import router as provenance_router
from app.routers.tags import router as tags_router

__all__ = [
"collection_types_router",
"collections_router",
"item_images_router",
"item_notes_router",
"items_router",
"mark_images_router",
"marks_router",
"provenance_router",
"tags_router",
Expand Down
Loading