feat: multi-user auth with per-user media isolation

Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.

Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup

Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 21:46:52 +02:00
parent 03a4c75e3e
commit 348e9c3585
40 changed files with 2313 additions and 440 deletions

View File

@@ -0,0 +1,144 @@
"""users table and user_id foreign keys
Revision ID: 0009_users_and_auth
Revises: 0008_photos_date_warning
Create Date: 2026-04-12
Introduces multi-user support:
1. Creates the `users` table.
2. Adds `user_id` FK columns to photos, folders, source_roots, heaps, tags.
3. For existing installs: creates a default admin user and assigns all
existing rows to that user. The generated password is printed to the
backend logs — the admin should change it on first login.
4. Replaces the unique constraint on tags (name, kind) with
(name, kind, user_id) so each user can have their own tags.
"""
from typing import Sequence, Union
import uuid
import secrets
from alembic import op
import sqlalchemy as sa
revision: str = "0009_users_and_auth"
down_revision: Union[str, None] = "0008_photos_date_warning"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# 1. Create users table (IF NOT EXISTS — safe on fresh installs where
# init_db's create_all has already laid down the schema).
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS users (
id VARCHAR NOT NULL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR UNIQUE,
hashed_password VARCHAR NOT NULL,
role VARCHAR NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
media_path VARCHAR NOT NULL
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_users_username ON users (username)"
))
# 2. Add user_id columns (nullable initially for the data migration)
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
conn.execute(sa.text(
f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS user_id VARCHAR"
))
conn.execute(sa.text(
f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"
))
# FK — check if it already exists before adding
fk_name = f"fk_{table}_user_id"
fk_exists = conn.execute(sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE constraint_name = :name AND table_name = :tbl"
), {"name": fk_name, "tbl": table}).scalar()
if not fk_exists:
conn.execute(sa.text(
f"ALTER TABLE {table} ADD CONSTRAINT {fk_name} "
f"FOREIGN KEY (user_id) REFERENCES users(id)"
))
# 3. Data migration: if rows exist, create a default admin and assign
conn = op.get_bind()
photo_count = conn.execute(sa.text("SELECT COUNT(*) FROM photos")).scalar()
if photo_count > 0:
admin_id = str(uuid.uuid4())
generated_password = secrets.token_urlsafe(16)
# Hash the password using passlib at migration time
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
hashed = pwd_context.hash(generated_password)
# The default admin's media_path is the legacy /photos root
conn.execute(
sa.text(
"INSERT INTO users (id, username, hashed_password, role, media_path) "
"VALUES (:id, :username, :hashed, :role, :media_path)"
),
{
"id": admin_id,
"username": "admin",
"hashed": hashed,
"role": "admin",
"media_path": "/photos",
},
)
# Assign all existing rows to the default admin
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
conn.execute(
sa.text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
{"uid": admin_id},
)
import logging
logger = logging.getLogger("alembic.migration")
logger.warning(
f"=== MIGRATION 0009 === Default admin created. "
f"Username: admin | Password: {generated_password} | "
f"Change this password on first login!"
)
# 4. Replace tag unique constraint to include user_id
# Check whether the old constraint exists before trying to drop it
# (on fresh installs create_all creates the new constraint directly).
old_uq_exists = conn.execute(sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE constraint_name = 'uq_tags_name_kind' AND table_name = 'tags'"
)).scalar()
if old_uq_exists:
op.drop_constraint("uq_tags_name_kind", "tags", type_="unique")
new_uq_exists = conn.execute(sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE constraint_name = 'uq_tags_name_kind_user' AND table_name = 'tags'"
)).scalar()
if not new_uq_exists:
op.create_unique_constraint("uq_tags_name_kind_user", "tags", ["name", "kind", "user_id"])
def downgrade() -> None:
# Reverse the tag constraint
op.drop_constraint("uq_tags_name_kind_user", "tags", type_="unique")
op.create_unique_constraint("uq_tags_name_kind", "tags", ["name", "kind"])
# Drop user_id columns and FKs
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
op.drop_constraint(f"fk_{table}_user_id", table, type_="foreignkey")
op.drop_index(f"ix_{table}_user_id", table_name=table)
op.drop_column(table, "user_id")
# Drop users table
op.drop_index("ix_users_username", table_name="users")
op.drop_table("users")

47
backend/app/auth.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Authentication utilities — password hashing and JWT token management.
"""
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": user_id,
"role": role,
"exp": expire,
"type": "access",
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def create_refresh_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
payload = {
"sub": user_id,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
"""Decode and validate a JWT. Raises JWTError on any problem."""
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])

View File

@@ -127,6 +127,17 @@ class Settings(BaseSettings):
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
log_level: str = Field(default="INFO", env="LOG_LEVEL")
# Auth — JWT signing key. Set SECRET_KEY in .env for production.
# If unset, a deterministic fallback is used (acceptable for
# single-machine homelab deploys, but set a real key if the instance
# is network-exposed).
secret_key: str = Field(
default="mulita-dev-secret-change-me",
env="SECRET_KEY",
)
access_token_expire_minutes: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES")
refresh_token_expire_days: int = Field(default=30, env="REFRESH_TOKEN_EXPIRE_DAYS")
@property
def cors_origins(self) -> list[str]:
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:

View File

@@ -100,7 +100,7 @@ async def init_db():
"""Initialize database, create tables if they don't exist"""
async with engine.begin() as conn:
# Import all models to register them with Base
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
# Postgres: ensure pgvector is available before create_all touches
# any Vector columns (added in later PRs but the extension is cheap

132
backend/app/dependencies.py Normal file
View File

@@ -0,0 +1,132 @@
"""
FastAPI dependencies for authentication and user-scoped data access.
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth import decode_token
from app.database import get_db
from app.models.user import User
from app.models.photos import Photo
from app.models.folders import Folder, SourceRoot
from app.models.heaps import Heap
from app.models.tags import Tag
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
"""Decode JWT, look up user, raise 401 if invalid or inactive."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
user_id: str = payload.get("sub")
token_type: str = payload.get("type")
if user_id is None or token_type != "access":
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise credentials_exception
return user
async def require_admin(
user: User = Depends(get_current_user),
) -> User:
"""Raise 403 if user is not an admin."""
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
return user
# ---------------------------------------------------------------------------
# User-scoped query helpers
# ---------------------------------------------------------------------------
def user_photos_query(user: User):
"""Base select for photos owned by user, with tags eager-loaded."""
return (
select(Photo)
.options(selectinload(Photo.tags))
.where(Photo.user_id == user.id)
)
async def get_user_photo(
photo_id: str,
user: User,
db: AsyncSession,
) -> Photo:
"""Fetch a single photo by ID, scoped to the user. Raises 404."""
result = await db.execute(
select(Photo)
.options(selectinload(Photo.tags))
.where(Photo.id == photo_id, Photo.user_id == user.id)
)
photo = result.scalar_one_or_none()
if photo is None:
raise HTTPException(status_code=404, detail="Photo not found")
return photo
async def get_user_folder(
folder_id: str,
user: User,
db: AsyncSession,
) -> Folder:
"""Fetch a single folder by ID, scoped to the user. Raises 404."""
result = await db.execute(
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
)
folder = result.scalar_one_or_none()
if folder is None:
raise HTTPException(status_code=404, detail="Folder not found")
return folder
async def get_user_heap(
heap_id: str,
user: User,
db: AsyncSession,
) -> Heap:
"""Fetch a single heap by ID, scoped to the user. Raises 404."""
result = await db.execute(
select(Heap).where(Heap.id == heap_id, Heap.user_id == user.id)
)
heap = result.scalar_one_or_none()
if heap is None:
raise HTTPException(status_code=404, detail="Heap not found")
return heap
async def get_user_tag(
tag_id: str,
user: User,
db: AsyncSession,
) -> Tag:
"""Fetch a single tag by ID, scoped to the user. Raises 404."""
result = await db.execute(
select(Tag).where(Tag.id == tag_id, Tag.user_id == user.id)
)
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
return tag

View File

@@ -11,7 +11,7 @@ import os
from app.config import settings
from app.database import init_db
from app.routers import photos, folders, heaps, tags, discard, library, search
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
from app.services.cleanup import cleanup_data_integrity
@@ -85,6 +85,8 @@ if os.path.exists("/data/thumbs"):
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
# Include routers
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])

View File

@@ -1,6 +1,7 @@
"""
Database models for Mulita
"""
from app.models.user import User
from app.models.photos import Photo
from app.models.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag
@@ -10,6 +11,7 @@ from app.models.ocr_text import OCRText
from app.models.face_embedding import FaceEmbedding
__all__ = [
'User',
'Photo',
'Folder',
'SourceRoot',

View File

@@ -10,13 +10,16 @@ from app.database import Base
class SourceRoot(Base):
__tablename__ = 'source_roots'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
path = Column(String, unique=True, nullable=False)
is_active = Column(Boolean, default=True)
added_at = Column(DateTime, server_default=func.now())
# Owner
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
# Relationships
folders = relationship("Folder", back_populates="source_root")
@@ -28,6 +31,9 @@ class Folder(Base):
path = Column(String, unique=True, nullable=False)
parent_id = Column(String, ForeignKey('folders.id'))
source_root_id = Column(String, ForeignKey('source_roots.id'))
# Owner
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
photo_count = Column(Integer, default=0)
last_scanned = Column(DateTime)

View File

@@ -22,12 +22,15 @@ heap_photos = Table(
class Heap(Base):
__tablename__ = 'heaps'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
is_active = Column(Boolean, default=False) # For active heap feature
# Owner
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
# Relationships
photos = relationship("Photo", secondary=heap_photos, backref="heaps")

View File

@@ -14,6 +14,9 @@ class Photo(Base):
# Primary key
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
# Owner
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
# File information
filepath = Column(String, unique=True, nullable=False)
filename = Column(String, nullable=False)

View File

@@ -30,13 +30,16 @@ photo_tags = Table(
class Tag(Base):
__tablename__ = 'tags'
__table_args__ = (
UniqueConstraint('name', 'kind', name='uq_tags_name_kind'),
UniqueConstraint('name', 'kind', 'user_id', name='uq_tags_name_kind_user'),
)
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False, index=True)
color = Column(String) # Hex color code for UI display
# Owner
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
# Tag classification
kind = Column(String, nullable=False, default='user', index=True)
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'

View File

@@ -0,0 +1,23 @@
"""
User model definition
"""
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.sql import func
import uuid
from app.database import Base
class User(Base):
__tablename__ = 'users'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String, unique=True, nullable=True)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default='user') # 'admin' | 'user'
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
# Absolute path to this user's photo directory (e.g., "/photos/daniel")
media_path = Column(String, nullable=False)

View File

@@ -0,0 +1,260 @@
"""
Admin router — user management and app configuration.
All endpoints require admin role.
"""
import os
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import hash_password
from app.database import get_db
from app.dependencies import require_admin
from app.models.user import User
from app.models.photos import Photo
from app.models.folders import SourceRoot
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class CreateUserRequest(BaseModel):
username: str
password: str
role: str = "user" # 'admin' | 'user'
class UpdateUserRequest(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
new_password: Optional[str] = None
class UserDetailResponse(BaseModel):
id: str
username: str
email: Optional[str]
role: str
is_active: bool
media_path: str
created_at: Optional[str]
photo_count: int = 0
class UserListResponse(BaseModel):
users: List[UserDetailResponse]
total: int
# ---------------------------------------------------------------------------
# User CRUD
# ---------------------------------------------------------------------------
@router.get("/users", response_model=UserListResponse)
async def list_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""List all users with their photo counts."""
result = await db.execute(select(User).order_by(User.created_at))
users = result.scalars().all()
user_list = []
for u in users:
count_result = await db.execute(
select(sa_func.count(Photo.id)).where(Photo.user_id == u.id)
)
photo_count = count_result.scalar() or 0
user_list.append(UserDetailResponse(
id=u.id,
username=u.username,
email=u.email,
role=u.role,
is_active=u.is_active,
media_path=u.media_path,
created_at=u.created_at.isoformat() if u.created_at else None,
photo_count=photo_count,
))
return UserListResponse(users=user_list, total=len(user_list))
@router.post("/users", status_code=201, response_model=UserDetailResponse)
async def create_user(
body: CreateUserRequest,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new user. Creates their media directory and source root."""
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
if len(body.username.strip()) < 2:
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
if len(body.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
# Check for duplicate username
existing = await db.execute(
select(User).where(User.username == body.username.strip())
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Username already taken")
media_path = os.path.join(settings.photo_dirs, body.username.strip())
os.makedirs(media_path, exist_ok=True)
user = User(
username=body.username.strip(),
hashed_password=hash_password(body.password),
role=body.role,
media_path=media_path,
)
db.add(user)
await db.flush() # get user.id before creating source root
source_root = SourceRoot(
name=f"{user.username}'s Library",
path=media_path,
user_id=user.id,
)
db.add(source_root)
await db.commit()
logger.info(f"Admin '{admin.username}' created user '{user.username}' (role={user.role})")
return UserDetailResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
media_path=user.media_path,
created_at=user.created_at.isoformat() if user.created_at else None,
photo_count=0,
)
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Get a single user's details."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
count_result = await db.execute(
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
)
photo_count = count_result.scalar() or 0
return UserDetailResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
media_path=user.media_path,
created_at=user.created_at.isoformat() if user.created_at else None,
photo_count=photo_count,
)
@router.patch("/users/{user_id}", response_model=UserDetailResponse)
async def update_user(
user_id: str,
body: UpdateUserRequest,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a user's role, active status, or password."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if body.role is not None:
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
# Prevent demoting the last admin
if user.role == "admin" and body.role == "user":
admin_count = (await db.execute(
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
)).scalar()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
user.role = body.role
if body.is_active is not None:
# Prevent deactivating the last admin
if user.role == "admin" and not body.is_active:
admin_count = (await db.execute(
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
)).scalar()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot deactivate the last admin")
user.is_active = body.is_active
if body.new_password is not None:
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
user.hashed_password = hash_password(body.new_password)
await db.commit()
count_result = await db.execute(
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
)
photo_count = count_result.scalar() or 0
return UserDetailResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
media_path=user.media_path,
created_at=user.created_at.isoformat() if user.created_at else None,
photo_count=photo_count,
)
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Soft-delete a user by deactivating them. Media is preserved."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Prevent deleting the last admin
if user.role == "admin":
admin_count = (await db.execute(
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
)).scalar()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
user.is_active = False
await db.commit()
logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'")
return {"status": "ok", "detail": f"User '{user.username}' deactivated"}

200
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Authentication router — login, token refresh, profile, first-run setup.
"""
import os
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.models.folders import SourceRoot
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Request / response schemas
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: str
username: str
email: Optional[str]
role: str
is_active: bool
created_at: Optional[str]
class SetupRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate with username + password, receive JWT tokens."""
result = await db.execute(
select(User).where(User.username == body.username)
)
user = result.scalar_one_or_none()
if user is None or not verify_password(body.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated",
)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
"""Exchange a valid refresh token for a new access + refresh pair."""
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != "refresh":
raise ValueError("not a refresh token")
user_id = payload["sub"]
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or deactivated",
)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
"""Return the authenticated user's profile."""
return UserResponse(
id=current_user.id,
username=current_user.username,
email=current_user.email,
role=current_user.role,
is_active=current_user.is_active,
created_at=current_user.created_at.isoformat() if current_user.created_at else None,
)
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Change the authenticated user's password."""
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
current_user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"status": "ok"}
@router.post("/setup", response_model=TokenResponse, status_code=201)
async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)):
"""First-run only: create the initial admin account.
Returns 409 if any user already exists. This endpoint is
unauthenticated by design — it can only run once.
"""
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
if count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Setup already completed — users exist",
)
if len(body.username.strip()) < 2:
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
if len(body.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
media_path = os.path.join(settings.photo_dirs, body.username.strip())
os.makedirs(media_path, exist_ok=True)
user = User(
username=body.username.strip(),
hashed_password=hash_password(body.password),
role="admin",
media_path=media_path,
)
db.add(user)
# Create a source root for the new admin's media directory
source_root = SourceRoot(
name=f"{user.username}'s Library",
path=media_path,
user_id=user.id,
)
db.add(source_root)
await db.commit()
logger.info(f"Initial admin account created: {user.username}")
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
)
@router.get("/status")
async def auth_status(db: AsyncSession = Depends(get_db)):
"""Public endpoint: returns whether setup has been completed.
The frontend calls this to decide whether to show the setup page
or the login page.
"""
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
return {"setup_completed": count > 0}

View File

@@ -9,25 +9,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo
from app.models.user import User
from app.dependencies import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("")
async def list_discarded(db: AsyncSession = Depends(get_db)):
async def list_discarded(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""List discarded photos"""
result = await db.execute(
select(Photo).where(Photo.is_discarded == True)
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
)
photos = result.scalars().all()
return photos
@router.post("/restore")
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Restore photos from the discard pile"""
result = await db.execute(
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id))
)
photos = result.scalars().all()
@@ -39,12 +41,12 @@ async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db
return {"status": "success", "restored": len(photos)}
@router.delete("/empty")
async def empty_discard(db: AsyncSession = Depends(get_db)):
async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Permanently delete all discarded photos and unlink their files from
disk. Failures on individual files are logged but don't abort the batch.
"""
result = await db.execute(
select(Photo).where(Photo.is_discarded == True)
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
)
photos = result.scalars().all()
return await _permanently_delete(db, photos)
@@ -54,6 +56,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db)):
async def delete_discarded(
photo_ids: list[str] = Body(..., embed=True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Permanently delete a specific subset of discarded photos. The photos
must already be in the discard pile — non-discarded ids are skipped so
@@ -63,7 +66,7 @@ async def delete_discarded(
return {"status": "success", "deleted": 0, "file_errors": 0}
result = await db.execute(
select(Photo).where(
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True)
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id)
)
)
photos = result.scalars().all()

View File

@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Folder, SourceRoot, Photo
from app.models.user import User
from app.dependencies import get_current_user, get_user_folder
logger = logging.getLogger(__name__)
@@ -48,10 +50,10 @@ def _validate_folder_name(name: str) -> str:
return name
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
async def get_folders(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get all source folders"""
# Get source roots instead of regular folders
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id))
source_roots = result.scalars().all()
folders_list = []
@@ -73,7 +75,7 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
return {"folders": folders_list}
@router.get("/tree")
async def get_folder_tree(db: AsyncSession = Depends(get_db)):
async def get_folder_tree(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Recursive folder tree, one root per active SourceRoot. The tree
starts at the Folder row matching the SourceRoot.path (the scanner
creates one for every walked directory), with the SourceRoot's
@@ -97,7 +99,7 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)):
creates as a parent walk) are skipped via path-prefix filtering.
"""
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id) # noqa: E712
)
source_roots = sr_result.scalars().all()
@@ -196,6 +198,7 @@ async def rename_folder(
folder_id: str,
body: FolderRename,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Rename a folder. Two cases:
@@ -212,7 +215,7 @@ async def rename_folder(
# Try SourceRoot first (display-only rename).
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id)
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
)
source_root = sr_result.scalar_one_or_none()
if source_root:
@@ -225,14 +228,11 @@ async def rename_folder(
}
# Otherwise it's a Folder row.
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await get_user_folder(folder_id, current_user, db)
# Refuse to rename the bare source root mount through here.
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
@@ -291,7 +291,7 @@ async def rename_folder(
@router.post("", status_code=201)
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Create a new sub-folder under an existing Folder. Mirrors the
create to disk so the next scan sees it. Body: { name, parent_id }.
parent_id MUST be an existing Folder row id (any descendant of a
@@ -300,12 +300,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
"""
name = _validate_folder_name(body.name)
parent_result = await db.execute(
select(Folder).where(Folder.id == body.parent_id)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
parent = await get_user_folder(body.parent_id, current_user, db)
new_path = os.path.join(parent.path, name)
if os.path.exists(new_path):
@@ -323,6 +318,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
name=name,
path=new_path,
source_root_id=parent.source_root_id,
user_id=current_user.id,
photo_count=0,
)
db.add(new_folder)
@@ -341,6 +337,7 @@ async def delete_folder(
folder_id: str,
mode: Literal['discard', 'permanent'] = Query('discard'),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a folder. Behavior depends on mode:
@@ -357,13 +354,10 @@ async def delete_folder(
Refuses to delete the bare source-root mount in either mode (deleting
the docker mount through the UI would be a footgun).
"""
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await get_user_folder(folder_id, current_user, db)
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
@@ -484,6 +478,7 @@ async def set_folder_hidden(
folder_id: str,
body: FolderHide,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Toggle the "hide from views" flag on a folder or source root.
@@ -502,7 +497,7 @@ async def set_folder_hidden(
"""
# SourceRoot path — resolve to the Folder row at the mount point.
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id)
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
)
source_root = sr_result.scalar_one_or_none()
@@ -511,6 +506,7 @@ async def set_folder_hidden(
root_folder_result = await db.execute(
select(Folder).where(
Folder.source_root_id == source_root.id,
Folder.user_id == current_user.id,
Folder.path == os.path.normpath(source_root.path),
)
)
@@ -521,12 +517,7 @@ async def set_folder_hidden(
detail="Source root has no indexed Folder row yet; scan first.",
)
else:
folder_result = await db.execute(
select(Folder).where(Folder.id == folder_id)
)
folder = folder_result.scalar_one_or_none()
if folder is None:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await get_user_folder(folder_id, current_user, db)
folder.is_hidden = bool(body.hidden)
await db.flush()
@@ -547,11 +538,11 @@ async def set_folder_hidden(
@router.post("/{folder_id}/scan")
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Trigger manual re-scan of source root folder"""
from app.tasks.celery import celery_app
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id))
source_root = result.scalar_one_or_none()
if not source_root:

View File

@@ -14,6 +14,8 @@ from app.database import get_db
from app.models import Heap, Photo, Folder
from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
from app.models.user import User
from app.dependencies import get_current_user, get_user_heap
logger = logging.getLogger(__name__)
@@ -48,7 +50,10 @@ class HeapConvertBody(BaseModel):
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_heaps(db: AsyncSession = Depends(get_db)):
async def list_heaps(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all heaps with photo counts."""
# LEFT JOIN heap_photos and group so we can return counts in one query.
count_subq = (
@@ -63,6 +68,7 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
stmt = (
select(Heap, count_subq.c.photo_count)
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
.where(Heap.user_id == current_user.id)
.order_by(Heap.created_at.asc())
)
result = await db.execute(stmt)
@@ -82,12 +88,16 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
@router.post("", status_code=201)
async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
async def create_heap(
body: HeapCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new heap."""
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Heap name is required")
heap = Heap(name=name)
heap = Heap(name=name, user_id=current_user.id)
db.add(heap)
await db.commit()
await db.refresh(heap)
@@ -103,14 +113,14 @@ async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
@router.patch("/{heap_id}")
async def update_heap(
heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db)
heap_id: str,
body: HeapUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Rename a heap and/or toggle active state. Setting is_active=true on
one heap deactivates all others (single-active invariant)."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
heap = await get_user_heap(heap_id, current_user, db)
if body.name is not None:
name = body.name.strip()
@@ -120,8 +130,12 @@ async def update_heap(
if body.is_active is not None:
if body.is_active:
# Clear active flag on all other heaps in one statement
await db.execute(update(Heap).values(is_active=False))
# Clear active flag on all other heaps for this user
await db.execute(
update(Heap)
.where(Heap.user_id == current_user.id)
.values(is_active=False)
)
heap.is_active = True
else:
heap.is_active = False
@@ -138,17 +152,18 @@ async def update_heap(
@router.post("/{heap_id}/duplicate", status_code=201)
async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
async def duplicate_heap(
heap_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new heap with the same membership as an existing one. The
new heap is named "{original} (copy)" and is never the active target —
duplicating shouldn't quietly steal the user's T-key destination.
"""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Heap not found")
source = await get_user_heap(heap_id, current_user, db)
new_heap = Heap(name=f"{source.name} (copy)", is_active=False)
new_heap = Heap(name=f"{source.name} (copy)", is_active=False, user_id=current_user.id)
db.add(new_heap)
await db.flush() # populate new_heap.id without committing yet
@@ -178,24 +193,30 @@ async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
@router.delete("/{heap_id}", status_code=204)
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
async def delete_heap(
heap_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a heap. Photos themselves are unaffected — only the membership
rows in heap_photos cascade-delete."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
heap = await get_user_heap(heap_id, current_user, db)
await db.delete(heap)
await db.commit()
return None
@router.get("/{heap_id}/photo_ids")
async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
async def get_heap_photo_ids(
heap_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return just the photo ids belonging to a heap. Used by the frontend
to maintain a fast client-side membership lookup for the active heap
(for the basket affordance on thumbnails) without fetching full photo
records."""
await get_user_heap(heap_id, current_user, db)
result = await db.execute(
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
)
@@ -204,14 +225,14 @@ async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
@router.post("/{heap_id}/photos")
async def add_photos_to_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
heap_id: str,
body: HeapPhotosBody,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add photos to a heap. Idempotent: re-adding existing members is a
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
await get_user_heap(heap_id, current_user, db)
if not body.photo_ids:
return {"status": "success", "added": 0}
@@ -241,6 +262,7 @@ async def convert_heap_to_folder(
heap_id: str,
body: HeapConvertBody,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Convert a heap into a folder by moving (or copying) every member
photo into the target directory. Optionally deletes the heap row at
@@ -249,10 +271,7 @@ async def convert_heap_to_folder(
target_id may be a Folder id or a SourceRoot id (matches the
/photos/move convention so the same dropdown can populate it).
"""
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = heap_result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
heap = await get_user_heap(heap_id, current_user, db)
# Resolve target_id → (target_dir, target_folder)
sr_check = await db.execute(
@@ -402,13 +421,13 @@ async def convert_heap_to_folder(
@router.delete("/{heap_id}/photos")
async def remove_photos_from_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
heap_id: str,
body: HeapPhotosBody,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove photos from a heap. Removing a non-member is a no-op."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
await get_user_heap(heap_id, current_user, db)
if not body.photo_ids:
return {"status": "success", "removed": 0}

View File

@@ -11,42 +11,42 @@ import os
import shutil
from typing import List, Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func, update
from sqlalchemy import select, func, update, true as sa_true
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo
from app.models.user import User
from app.dependencies import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
def _owner_filter(user: User, scope: str | None):
"""Return a column expression scoping photos to the current user,
or a pass-through true() when an admin requests global scope."""
if scope == "global" and user.role == "admin":
return sa_true()
return Photo.user_id == user.id
# Media types we accept in the regenerate-thumbnails request body. Mirrors
# the values produced by `app.tasks.scan.get_media_type`.
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
@router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics + per-section counts. Each section count
matches the filter the sidebar applies when you click it, so the
sidebar badges and the timeline below them stay in sync.
- all_photos: non-discarded photos + videos (matches the All
Photos section's default filter)
- rated: non-discarded with rating >= 1
- colored: non-discarded with a color_label set (matches the
Colors grouped view's labeled buckets)
- duplicates: non-discarded with is_duplicate = true
- discarded: is_discarded = true
- total_size: raw bytes across every row, including discarded
"""
# Every sidebar badge runs against this filter. `not_visible` is the
# inverse: a photo is visible iff it's neither discarded nor hidden
# (marked hidden-from-views via a folder toggle). Kept as a single
# expression so every sub-count below applies it identically.
visible = (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False))
async def get_library_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str | None = Query(None),
):
"""Get library statistics. Pass ?scope=global (admin only) for
cross-user totals (used by the Settings page)."""
owner = _owner_filter(current_user, scope)
visible = owner & (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False))
all_photos_count = (
await db.execute(select(func.count(Photo.id)).where(visible))
@@ -84,7 +84,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
discarded_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
select(func.count(Photo.id)).where(owner, Photo.is_discarded.is_(True))
)
).scalar() or 0
@@ -92,17 +92,18 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
photo_count = (
await db.execute(
select(func.count(Photo.id)).where(
owner,
Photo.media_type.in_(['photo', 'heic', 'raw'])
)
)
).scalar() or 0
video_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
select(func.count(Photo.id)).where(owner, Photo.media_type == 'video')
)
).scalar() or 0
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
size = (await db.execute(select(func.sum(Photo.file_size)).where(owner))).scalar() or 0
return {
"all_photos": all_photos_count,
@@ -118,7 +119,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
}
@router.post("/scan")
async def trigger_scan():
async def trigger_scan(current_user: User = Depends(get_current_user)):
"""Trigger full library re-scan"""
from app.tasks.scan import scan_all_source_roots
@@ -128,7 +129,7 @@ async def trigger_scan():
@router.post("/backfill-gps")
async def trigger_backfill_gps():
async def trigger_backfill_gps(current_user: User = Depends(get_current_user)):
"""Re-run EXIF metadata extraction on every photo that's still missing
GPS coordinates. Useful after fixing the EXIF parser, or any time the
Map view looks emptier than expected. Returns immediately — work runs
@@ -139,7 +140,7 @@ async def trigger_backfill_gps():
return {"status": "success", "message": "GPS backfill queued"}
@router.get("/scan/status")
async def get_scan_status(db: AsyncSession = Depends(get_db)):
async def get_scan_status(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get current scan status"""
import redis
from app.config import settings
@@ -187,20 +188,27 @@ class RegenerateThumbnailsRequest(BaseModel):
@router.get("/maintenance/thumbnail-stats")
async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
async def get_thumbnail_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str | None = Query(None),
):
"""Counts of photos by processing_status, plus a media-type breakdown
so the Settings panel can show the user what's outstanding."""
owner = _owner_filter(current_user, scope)
status_rows = (
await db.execute(
select(Photo.processing_status, func.count(Photo.id)).group_by(
Photo.processing_status
)
select(Photo.processing_status, func.count(Photo.id))
.where(owner)
.group_by(Photo.processing_status)
)
).all()
media_rows = (
await db.execute(
select(Photo.media_type, func.count(Photo.id)).group_by(Photo.media_type)
select(Photo.media_type, func.count(Photo.id))
.where(owner)
.group_by(Photo.media_type)
)
).all()
@@ -222,6 +230,8 @@ async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
async def regenerate_thumbnails(
body: RegenerateThumbnailsRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str | None = Query(None),
):
"""Reset matching photos' on-disk thumbnail directories and re-queue
Celery thumbnail generation. Used by the Settings panel for the
@@ -233,6 +243,8 @@ async def regenerate_thumbnails(
"""
from app.tasks.thumbs import generate_thumbnails
owner = _owner_filter(current_user, scope)
# Validate media_types early so a typo can't silently match nothing.
media_types = body.media_types
if media_types is not None:
@@ -244,7 +256,7 @@ async def regenerate_thumbnails(
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
}
query = select(Photo)
query = select(Photo).where(owner)
if media_types:
query = query.where(Photo.media_type.in_(media_types))
if body.only_failed:
@@ -297,7 +309,11 @@ async def regenerate_thumbnails(
@router.get("/maintenance/worker-status")
async def get_worker_status(db: AsyncSession = Depends(get_db)):
async def get_worker_status(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str | None = Query(None),
):
"""Diagnostics for the Celery worker fleet + recent task failures.
Surfaced in the Settings panel so the user can spot a stuck queue or
@@ -316,6 +332,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
things failed without opening the DB.
- broker_ok: bool — could we even reach Redis?
"""
owner = _owner_filter(current_user, scope)
from app.tasks.celery import celery_app
from app.config import settings
import redis as _redis
@@ -398,7 +415,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
# ----- Recent task failures from the photos table ----------------------
failed_total = (
await db.execute(
select(func.count(Photo.id)).where(Photo.processing_status == 'failed')
select(func.count(Photo.id)).where(owner, Photo.processing_status == 'failed')
)
).scalar() or 0
@@ -411,7 +428,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
Photo.processing_error,
Photo.updated_at,
)
.where(Photo.processing_status == 'failed')
.where(owner, Photo.processing_status == 'failed')
.order_by(Photo.updated_at.desc().nullslast())
.limit(20)
)
@@ -454,7 +471,11 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
@router.get("/maintenance/pipeline-stats")
async def get_pipeline_stats(db: AsyncSession = Depends(get_db)):
async def get_pipeline_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str | None = Query(None),
):
"""Per-stage progress across the ingestion pipeline.
Returns a `{stage_key: {done, total, label}}` map so the Settings
@@ -470,7 +491,8 @@ async def get_pipeline_stats(db: AsyncSession = Depends(get_db)):
from app.models import Embedding, FaceEmbedding, OCRText
from app.models.tags import photo_tags # association Table, not a model
not_discarded = Photo.is_discarded.is_(False)
owner = _owner_filter(current_user, scope)
not_discarded = owner & Photo.is_discarded.is_(False)
async def scalar_count(query):
return (await db.execute(query)).scalar() or 0
@@ -654,7 +676,7 @@ async def get_pipeline_stats(db: AsyncSession = Depends(get_db)):
@router.get("/maintenance/missing-stats")
async def get_missing_stats():
async def get_missing_stats(current_user: User = Depends(get_current_user)):
"""Count photos whose files no longer exist on disk under a mounted
source root. Surfaced in Settings so the user can see a number before
pulling the trigger on prune-missing. Cheap enough to call freely."""
@@ -663,7 +685,7 @@ async def get_missing_stats():
@router.post("/maintenance/prune-missing")
async def run_prune_missing():
async def run_prune_missing(current_user: User = Depends(get_current_user)):
"""Actually delete the orphaned photo rows reported by /missing-stats.
Common cause: PHOTO_DIRS in .env was repointed at a different library
leaving every old row dangling. Skips any photo whose source root
@@ -677,7 +699,7 @@ async def run_prune_missing():
@router.post("/maintenance/cleanup")
async def run_data_integrity_cleanup():
async def run_data_integrity_cleanup(current_user: User = Depends(get_current_user)):
"""Re-run the source-roots / folders / photos data-integrity cleanup
that normally only runs on backend startup. Idempotent."""
from app.services.cleanup import cleanup_data_integrity
@@ -695,7 +717,11 @@ async def run_data_integrity_cleanup():
# ─────────────────────────────────────────────────────────────────────────
@router.get("/duplicates/groups")
async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
async def get_duplicate_groups(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str | None = Query(None),
):
"""Return every duplicate group with its members.
Drives the frontend grouped grid view in the Duplicates section. One
@@ -708,6 +734,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
duplicates that the perceptual hash trivially caught)
* "similar" — members differ at the byte level but match perceptually
"""
owner = _owner_filter(current_user, scope)
rows = (
await db.execute(
select(
@@ -723,6 +750,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
Photo.media_type,
Photo.duplicate_group_id,
)
.where(owner)
.where(Photo.duplicate_group_id.is_not(None))
.where(Photo.is_discarded.is_(False))
.where(Photo.is_hidden.is_(False))
@@ -784,7 +812,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
@router.post("/maintenance/regroup-duplicates")
async def trigger_regroup_duplicates():
async def trigger_regroup_duplicates(current_user: User = Depends(get_current_user)):
"""Recompute duplicate groups from current perceptual hashes.
Fires the celery `regroup_duplicates` task which walks every photo's
@@ -800,7 +828,7 @@ async def trigger_regroup_duplicates():
@router.post("/maintenance/backfill-phashes")
async def trigger_backfill_phashes():
async def trigger_backfill_phashes(current_user: User = Depends(get_current_user)):
"""Compute perceptual hashes for every photo currently missing one.
One-shot recovery path for libraries that existed before the phash

View File

@@ -7,9 +7,10 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func
from sqlalchemy import select, and_, or_, func, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import base64
import json
import os
import logging
@@ -19,11 +20,13 @@ logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag
from app.models.folders import SourceRoot
from app.models.user import User
from app.models.heaps import heap_photos
from app.models.tags import photo_tags
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.services.exif_writer import ExifWriteError, write_taken_at
from app.services.date_guess import has_date_warning as compute_date_warning
from app.dependencies import get_current_user, get_user_photo
from app.config import settings
router = APIRouter()
@@ -47,13 +50,15 @@ async def list_photos(
order: str = "desc",
page: int = Query(1, ge=1),
per_page: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db)
cursor: Optional[str] = Query(None, description="Opaque cursor for keyset pagination"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List photos with filters and pagination"""
# Build query — eager-load tags so the response can include them
# without an N+1 round-trip per photo.
query = select(Photo).options(selectinload(Photo.tags))
# without an N+1 round-trip per photo. Scoped to the current user.
query = select(Photo).options(selectinload(Photo.tags)).where(Photo.user_id == current_user.id)
# Apply filters
filters = []
@@ -206,24 +211,92 @@ async def list_photos(
"rating": Photo.rating,
}
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
if order == "desc":
query = query.order_by(sort_column.desc())
desc = order == "desc"
# Keyset / cursor pagination — O(1) regardless of page depth.
# The cursor encodes the last-seen (sort_value, id) pair so the DB
# can seek directly to the next slice via an indexed range scan
# instead of skipping N rows with OFFSET.
if cursor:
try:
decoded = json.loads(base64.urlsafe_b64decode(cursor))
cursor_val = decoded["v"]
cursor_id = decoded["id"]
# For datetime columns, parse the ISO string back.
if sort in ("taken_at", "added_at") and cursor_val is not None:
cursor_val = datetime.fromisoformat(cursor_val)
except Exception:
raise HTTPException(status_code=400, detail="Invalid cursor")
# Keyset condition: for DESC we want rows "less than" the cursor,
# for ASC rows "greater than". We use (sort_col, id) tuple
# comparison which handles NULLs and ties correctly.
if desc:
if cursor_val is None:
# NULL sorts last in DESC with NULLS LAST — seek past it by id
query = query.where(
or_(
sort_column.is_(None) & (Photo.id < cursor_id),
)
)
else:
query = query.where(
or_(
sort_column < cursor_val,
and_(sort_column == cursor_val, Photo.id < cursor_id),
sort_column.is_(None),
)
)
else:
if cursor_val is None:
query = query.where(
or_(
sort_column.is_(None) & (Photo.id > cursor_id),
)
)
else:
query = query.where(
or_(
sort_column > cursor_val,
and_(sort_column == cursor_val, Photo.id > cursor_id),
)
)
if desc:
query = query.order_by(sort_column.desc().nulls_last(), Photo.id.desc())
else:
query = query.order_by(sort_column.asc())
# Count total results
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar()
# Apply pagination
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
query = query.order_by(sort_column.asc().nulls_last(), Photo.id.asc())
# Count total results (only when no cursor — first page needs it;
# subsequent pages reuse the total from the first response).
total = None
if not cursor:
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar()
# Fallback to offset pagination when no cursor is provided and page > 1
# (backward compat for any callers not yet using cursors).
if not cursor and page > 1:
offset = (page - 1) * per_page
query = query.offset(offset)
query = query.limit(per_page)
# Execute query
result = await db.execute(query)
photos = result.scalars().all()
# Build next_cursor from the last row in this batch.
next_cursor = None
if photos and len(photos) == per_page:
last = photos[-1]
sort_val = getattr(last, sort if sort in SORT_WHITELIST else "taken_at")
if isinstance(sort_val, datetime):
sort_val = sort_val.isoformat()
cursor_payload = json.dumps({"v": sort_val, "id": last.id})
next_cursor = base64.urlsafe_b64encode(cursor_payload.encode()).decode()
# Convert to response, attaching tags inline so the frontend can group
# client-side without a second round-trip.
photo_dicts = []
@@ -235,21 +308,25 @@ async def list_photos(
]
photo_dicts.append(d)
return {
response = {
"photos": photo_dicts,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total else 0,
"next_cursor": next_cursor,
}
# Include total + legacy page fields on first page / non-cursor requests
if total is not None:
response["total"] = total
response["page"] = page
response["pages"] = (total + per_page - 1) // per_page if total else 0
return response
@router.get("/map")
async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
async def list_photos_with_gps(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Lightweight listing of every non-discarded photo that has GPS
coordinates, used by the Map view. Intentionally returns a flat list
(no pagination) with only the fields the map renderer needs, so even
large libraries serialize to a few MB at most. Declared *before*
``/{photo_id}`` so the literal path wins the FastAPI route match."""
coordinates, used by the Map view."""
result = await db.execute(
select(
Photo.id,
@@ -257,6 +334,7 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
Photo.longitude,
Photo.taken_at,
).where(
Photo.user_id == current_user.id,
Photo.is_discarded.is_(False),
Photo.is_hidden.is_(False),
Photo.latitude.is_not(None),
@@ -277,16 +355,11 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
@router.get("/{photo_id}")
async def get_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get single photo with full EXIF and its tags."""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
photo = await get_user_photo(photo_id, current_user, db)
# Fetch tags via the join table so we don't need to declare a
# relationship on the Photo model side.
@@ -310,12 +383,11 @@ async def add_photo_tags(
photo_id: str,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
Idempotent: re-adding existing members is a no-op."""
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id))
if photo_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Photo not found")
await get_user_photo(photo_id, current_user, db)
tag_ids = body.get("tag_ids") or []
if not isinstance(tag_ids, list) or not tag_ids:
@@ -346,8 +418,10 @@ async def remove_photo_tag(
photo_id: str,
tag_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a tag from a photo. Removing a non-member is a no-op."""
await get_user_photo(photo_id, current_user, db)
from sqlalchemy import delete as sql_delete
await db.execute(
sql_delete(photo_tags).where(
@@ -363,22 +437,21 @@ async def get_thumbnail(
photo_id: str,
size: str,
response: Response,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
if size not in ['small', 'medium', 'large']:
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Check if thumbnail exists, generate if not
thumb_dir = f"/data/thumbs/{photo_id}"
photo = await get_user_photo(photo_id, current_user, db)
# Check if thumbnail exists, generate if not.
# User-prefixed path for isolation.
if photo.user_id:
thumb_dir = f"/data/thumbs/{photo.user_id}/{photo_id}"
else:
thumb_dir = f"/data/thumbs/{photo_id}"
thumb_path = f"{thumb_dir}/{size}.webp"
if not os.path.exists(thumb_path):
@@ -451,16 +524,11 @@ async def get_thumbnail(
@router.get("/{photo_id}/original")
async def get_original(
photo_id: str,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Serve original file (download for RAW, inline for web-safe formats)"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
photo = await get_user_photo(photo_id, current_user, db)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found")
@@ -560,19 +628,10 @@ async def get_proxy(
photo_id: str,
response: Response,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Serve a full-resolution WebP proxy for non-web-safe formats (RAW, HEIC,
TIFF) so the loupe view can display them inline. Web-safe formats are
redirected to /original to avoid pointless transcoding.
Cached at /data/proxies/{photo_id}.webp; subsequent requests serve the
cached file (with optional X-Accel-Redirect for production).
"""
result = await db.execute(select(Photo).where(Photo.id == photo_id))
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
"""Serve a full-resolution WebP proxy for non-web-safe formats."""
photo = await get_user_photo(photo_id, current_user, db)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found")
@@ -613,19 +672,11 @@ async def get_proxy(
async def update_photo(
photo_id: str,
update: PhotoUpdate,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update photo metadata. If `filename` is included, also rename the
file on disk in its current directory (no cross-folder moves through
this endpoint).
"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
"""Update photo metadata."""
photo = await get_user_photo(photo_id, current_user, db)
update_data = update.dict(exclude_unset=True)
@@ -692,19 +743,11 @@ async def update_photo(
@router.delete("/{photo_id}")
async def discard_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Soft-discard a photo: sets is_discarded=true. The file stays on disk so
restore is just a flag flip. Permanent deletion happens via DELETE
/discard/{id} or DELETE /discard/empty.
"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
"""Soft-discard a photo."""
photo = await get_user_photo(photo_id, current_user, db)
photo.is_discarded = True
photo.discarded_at = datetime.utcnow()
@@ -726,6 +769,7 @@ class CopyRequest(BaseModel):
async def copy_photos(
body: CopyRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Copy photos into a target folder. Same target resolution as /move
(folder id or source root id), but uses shutil.copy2 and creates new
@@ -767,7 +811,7 @@ async def copy_photos(
return {"status": "success", "copied": 0, "errors": []}
photos_result = await db.execute(
select(Photo).where(Photo.id.in_(body.photo_ids))
select(Photo).where(Photo.id.in_(body.photo_ids), Photo.user_id == current_user.id)
)
photos_to_copy = photos_result.scalars().all()
@@ -820,6 +864,7 @@ async def copy_photos(
file_size=photo.file_size,
taken_at=photo.taken_at,
taken_at_source=photo.taken_at_source,
user_id=current_user.id,
user_title=photo.user_title,
user_notes=photo.user_notes,
rating=photo.rating,
@@ -844,6 +889,7 @@ async def copy_photos(
async def move_photos(
body: MoveRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Move photos into a target folder. The target can be either a Folder
id or a SourceRoot id (since the LeftSidebar only exposes source roots
@@ -887,9 +933,9 @@ async def move_photos(
if not body.photo_ids:
return {"status": "success", "moved": 0, "errors": []}
# Fetch the photo rows
# Fetch the photo rows, scoped to user
photos_result = await db.execute(
select(Photo).where(Photo.id.in_(body.photo_ids))
select(Photo).where(Photo.id.in_(body.photo_ids), Photo.user_id == current_user.id)
)
photos_to_move = photos_result.scalars().all()
@@ -932,12 +978,13 @@ async def move_photos(
@router.post("/bulk")
async def bulk_action(
action: BulkAction,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Perform bulk actions on multiple photos"""
# Get photos
# Get photos, scoped to user
result = await db.execute(
select(Photo).where(Photo.id.in_(action.ids))
select(Photo).where(Photo.id.in_(action.ids), Photo.user_id == current_user.id)
)
photos = result.scalars().all()

View File

@@ -10,6 +10,8 @@ from sqlalchemy import select
from app.database import get_db
from app.models import Photo
from app.services.search import hybrid_search
from app.models.user import User
from app.dependencies import get_current_user
router = APIRouter()
@@ -22,7 +24,7 @@ class SearchRequest(BaseModel):
@router.post("")
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db)):
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
by default — the user never picks a mode.
@@ -47,7 +49,7 @@ async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db))
# Hydrate with photo data
photo_ids = [r["photo_id"] for r in results]
stmt = select(Photo).where(Photo.id.in_(photo_ids))
stmt = select(Photo).where(Photo.id.in_(photo_ids), Photo.user_id == current_user.id)
rows = (await db.execute(stmt)).scalars().all()
photo_map = {p.id: p for p in rows}

View File

@@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo, Tag
from app.models.tags import photo_tags
from app.models.user import User
from app.dependencies import get_current_user
router = APIRouter()
@@ -42,6 +44,7 @@ class TagMerge(BaseModel):
async def list_tags(
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all tags with their photo counts, optionally filtered by kind.
@@ -61,6 +64,7 @@ async def list_tags(
photo_tags.join(Photo, Photo.id == photo_tags.c.photo_id)
)
.where(
Photo.user_id == current_user.id,
Photo.is_discarded.is_(False),
Photo.is_hidden.is_(False),
)
@@ -70,6 +74,7 @@ async def list_tags(
stmt = (
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
.where(Tag.user_id == current_user.id)
)
if kind:
stmt = stmt.where(Tag.kind == kind)
@@ -93,7 +98,7 @@ async def list_tags(
@router.post("", status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Create a new tag. The (name, kind) pair is unique — re-creating an
existing pair returns the existing row (idempotent for autocomplete)."""
name = (body.name or "").strip()
@@ -101,7 +106,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=400, detail="Tag name is required")
existing = await db.execute(
select(Tag).where(Tag.name == name, Tag.kind == body.kind)
select(Tag).where(Tag.name == name, Tag.kind == body.kind, Tag.user_id == current_user.id)
)
found = existing.scalar_one_or_none()
if found:
@@ -110,7 +115,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
"kind": found.kind, "photo_count": 0,
}
tag = Tag(name=name, color=body.color, kind=body.kind)
tag = Tag(name=name, color=body.color, kind=body.kind, user_id=current_user.id)
db.add(tag)
await db.commit()
await db.refresh(tag)
@@ -122,10 +127,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
@router.patch("/{tag_id}")
async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
@@ -145,7 +151,8 @@ async def update_tag(
@router.post("/{tag_id}/merge")
async def merge_tag(
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db)
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Merge tag_id INTO target_id. All photo associations from the source
tag are moved to the target, then the source tag is deleted.
@@ -155,8 +162,8 @@ async def merge_tag(
if tag_id == body.target_id:
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none()
target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none()
source = (await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))).scalar_one_or_none()
target = (await db.execute(select(Tag).where(Tag.id == body.target_id, Tag.user_id == current_user.id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source tag not found")
if not target:
@@ -189,9 +196,9 @@ async def merge_tag(
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)):
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Delete a tag. Photo associations cascade-delete via the FK."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")

View File

@@ -1,6 +1,5 @@
"""
Scanner service for initial library scan and one-time bootstrap of the
default source root on first boot.
Scanner service for initial library scan and per-user source root bootstrap.
"""
import os
import logging
@@ -8,44 +7,74 @@ from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import SourceRoot
from app.models.user import User
from app.tasks.scan import scan_all_source_roots
from app.config import settings
logger = logging.getLogger(__name__)
# The single host → container mount path. The compose file mounts whatever
# the user set as PHOTO_DIRS at this path.
DEFAULT_LIBRARY_PATH = "/photos"
DEFAULT_LIBRARY_NAME = "Library"
async def bootstrap_user_source_root(user: User, session=None) -> None:
"""Create the media directory and a source root for a user.
Called when a new user is created (by the admin or the setup endpoint).
If the user already has a source root, this is a no-op.
"""
own_session = session is None
if own_session:
session = AsyncSessionLocal()
try:
# Check if user already has a source root
result = await session.execute(
select(SourceRoot).where(SourceRoot.user_id == user.id)
)
if result.scalar_one_or_none() is not None:
return
os.makedirs(user.media_path, exist_ok=True)
source_root = SourceRoot(
name=f"{user.username}'s Library",
path=user.media_path,
user_id=user.id,
)
session.add(source_root)
if own_session:
await session.commit()
else:
await session.flush()
logger.info(
f"Bootstrapped source root for user '{user.username}': "
f"{user.media_path}"
)
finally:
if own_session:
await session.close()
async def bootstrap_default_source_root() -> None:
"""If no source roots exist in the DB, create one pointing at the default
library mount. Lets a fresh install pick up photos with zero
configuration: the user only needs to set PHOTO_DIRS in .env.
"""Legacy bootstrap — for existing installs that have source roots
without user_id (pre-auth migration). On fresh installs, source roots
are created per-user via bootstrap_user_source_root. If there are
already source roots in the DB, this is a no-op.
"""
if not os.path.isdir(DEFAULT_LIBRARY_PATH):
logger.warning(
f"Default library path {DEFAULT_LIBRARY_PATH} is not mounted; "
"set PHOTO_DIRS in .env and recreate the container."
)
return
async with AsyncSessionLocal() as session:
result = await session.execute(select(SourceRoot))
if result.scalars().first() is not None:
return # Already have at least one source root, leave it alone.
return # Already have source roots.
source_root = SourceRoot(
name=DEFAULT_LIBRARY_NAME,
path=DEFAULT_LIBRARY_PATH,
)
session.add(source_root)
await session.commit()
logger.info(
f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME}"
f"{DEFAULT_LIBRARY_PATH}"
)
# No source roots and no users means fresh install — the setup
# endpoint will create the first user + source root.
user_count = (await session.execute(
select(User)
)).scalars().first()
if user_count is None:
logger.info(
"No users or source roots — waiting for first-run setup."
)
return
async def start_initial_scan():

View File

@@ -122,6 +122,13 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
if not source_root_id:
source_root = await get_or_create_source_root(session, folder_path)
source_root_id = source_root.id
else:
source_root = (await session.execute(
select(SourceRoot).where(SourceRoot.id == source_root_id)
)).scalar_one_or_none()
# Inherit user_id from the source root's owner
owner_user_id = source_root.user_id if source_root else None
# Per-scan memoization cache for "is this folder's effective
# is_hidden true?" Populated on first lookup by walking the
@@ -173,7 +180,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
for root, dirs, files in os.walk(folder_path):
# Get or create folder entry
folder = await get_or_create_folder(session, root, source_root_id)
folder = await get_or_create_folder(session, root, source_root_id, owner_user_id)
progress_set(REDIS_KEY_CURRENT_FOLDER, root)
# Filter supported files
@@ -237,6 +244,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
filepath=filepath,
filename=filename,
folder_id=folder.id,
user_id=owner_user_id,
file_hash=file_hash,
media_type=get_media_type(filepath),
original_format=Path(filepath).suffix.upper()[1:],
@@ -342,7 +350,9 @@ async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceR
return source_root
async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder:
async def get_or_create_folder(
session: AsyncSession, path: str, source_root_id: str, user_id: str = None
) -> Folder:
"""Get or create a folder entry, matching by normalized path."""
from sqlalchemy import select
@@ -364,7 +374,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
parent_id = parent.id
else:
# Recursively create parent
parent = await get_or_create_folder(session, parent_path, source_root_id)
parent = await get_or_create_folder(session, parent_path, source_root_id, user_id)
parent_id = parent.id
else:
parent_id = None
@@ -374,6 +384,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
path=norm,
parent_id=parent_id,
source_root_id=source_root_id,
user_id=user_id,
)
session.add(folder)
await session.flush()

View File

@@ -47,9 +47,16 @@ THUMB_SIZES = {
'large': settings.thumbnails.large
}
def get_thumb_path(photo_id: str, size: str) -> str:
"""Get the path for a thumbnail file"""
thumb_dir = f"/data/thumbs/{photo_id}"
def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
"""Get the path for a thumbnail file.
When user_id is provided, thumbnails are stored under a user-specific
subdirectory to enforce isolation between users.
"""
if user_id:
thumb_dir = f"/data/thumbs/{user_id}/{photo_id}"
else:
thumb_dir = f"/data/thumbs/{photo_id}"
os.makedirs(thumb_dir, exist_ok=True)
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
@@ -302,7 +309,7 @@ async def _generate_thumbnails_async(photo_id: str, task):
# Generate thumbnails for each size
for size_name, size_value in THUMB_SIZES.items():
thumb_path = get_thumb_path(photo_id, size_name)
thumb_path = get_thumb_path(photo_id, size_name, photo.user_id)
generate_thumbnail(image, size_value, thumb_path)
# Update database with thumbnail path

54
backend/bootstrap.py Normal file
View File

@@ -0,0 +1,54 @@
"""Post-init_db bootstrap: run or stamp Alembic migrations.
On a FRESH Postgres install, init_db's create_all has already built the
full schema from the current models. Running `alembic upgrade head` would
fail because the older migrations try ADD COLUMN on columns that already
exist. So we detect the fresh-install case (alembic_version table is
missing or empty) and `stamp head` instead.
On an EXISTING install, the alembic_version table has a revision and
`upgrade head` applies only the new deltas.
"""
import subprocess
import sys
from sqlalchemy import create_engine, text, inspect
from app.config import settings
def run():
# Use a sync engine for this one-shot script.
sync_url = settings.database_url.replace("+asyncpg", "").replace("+aiosqlite", "")
engine = create_engine(sync_url)
with engine.connect() as conn:
inspector = inspect(engine)
tables = inspector.get_table_names()
if "alembic_version" not in tables:
# Fresh install — create_all built everything. Stamp head.
print("Fresh install detected — stamping alembic head")
subprocess.run(
[sys.executable, "-m", "alembic", "stamp", "head"],
check=True,
)
else:
row = conn.execute(text("SELECT version_num FROM alembic_version")).first()
if row is None:
print("Empty alembic_version — stamping head")
subprocess.run(
[sys.executable, "-m", "alembic", "stamp", "head"],
check=True,
)
else:
print(f"Existing install at revision {row[0]} — running alembic upgrade head")
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
check=True,
)
engine.dispose()
if __name__ == "__main__":
run()

View File

@@ -54,6 +54,7 @@ aiofiles==23.2.1
# Security and authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
# Development
pytest==7.4.4