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

@@ -36,6 +36,20 @@ BACKEND_PORT=8001
REDIS_PORT=6379 REDIS_PORT=6379
# ── AUTH ─────────────────────────────────────────────────────────────────────
# Secret key used to sign JWT tokens. Generate a strong random value for
# production (e.g. `openssl rand -base64 32`). The default is a deterministic
# placeholder acceptable only for local/homelab use.
# SECRET_KEY=change-me-to-a-random-string
# How long access and refresh tokens stay valid. Access tokens are short-lived
# and silently refreshed by the frontend; refresh tokens let a session survive
# across browser restarts.
# ACCESS_TOKEN_EXPIRE_MINUTES=60
# REFRESH_TOKEN_EXPIRE_DAYS=30
# ── CORS ───────────────────────────────────────────────────────────────────── # ── CORS ─────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins for direct browser access to the # Comma-separated list of allowed origins for direct browser access to the

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. # ERROR, CRITICAL). Bumped from INFO when chasing a problem.
log_level: str = Field(default="INFO", env="LOG_LEVEL") 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 @property
def cors_origins(self) -> list[str]: def cors_origins(self) -> list[str]:
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts: """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""" """Initialize database, create tables if they don't exist"""
async with engine.begin() as conn: async with engine.begin() as conn:
# Import all models to register them with Base # 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 # Postgres: ensure pgvector is available before create_all touches
# any Vector columns (added in later PRs but the extension is cheap # 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.config import settings
from app.database import init_db 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.scanner import start_initial_scan, bootstrap_default_source_root
from app.services.cleanup import cleanup_data_integrity 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") app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
# Include routers # 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(photos.router, prefix="/api/v1/photos", tags=["photos"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"]) app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"]) app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])

View File

@@ -1,6 +1,7 @@
""" """
Database models for Mulita Database models for Mulita
""" """
from app.models.user import User
from app.models.photos import Photo from app.models.photos import Photo
from app.models.folders import Folder, SourceRoot from app.models.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag 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 from app.models.face_embedding import FaceEmbedding
__all__ = [ __all__ = [
'User',
'Photo', 'Photo',
'Folder', 'Folder',
'SourceRoot', 'SourceRoot',

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ class Photo(Base):
# Primary key # Primary key
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) 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 # File information
filepath = Column(String, unique=True, nullable=False) filepath = Column(String, unique=True, nullable=False)
filename = Column(String, nullable=False) filename = Column(String, nullable=False)

View File

@@ -30,13 +30,16 @@ photo_tags = Table(
class Tag(Base): class Tag(Base):
__tablename__ = 'tags' __tablename__ = 'tags'
__table_args__ = ( __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())) id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False, index=True) name = Column(String, nullable=False, index=True)
color = Column(String) # Hex color code for UI display color = Column(String) # Hex color code for UI display
# Owner
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
# Tag classification # Tag classification
kind = Column(String, nullable=False, default='user', index=True) kind = Column(String, nullable=False, default='user', index=True)
# kind values: 'user' | 'object' | 'scene' | 'face_cluster' # 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.database import get_db
from app.models import Photo from app.models import Photo
from app.models.user import User
from app.dependencies import get_current_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.get("") @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""" """List discarded photos"""
result = await db.execute( 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() photos = result.scalars().all()
return photos return photos
@router.post("/restore") @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""" """Restore photos from the discard pile"""
result = await db.execute( 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() 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)} return {"status": "success", "restored": len(photos)}
@router.delete("/empty") @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 """Permanently delete all discarded photos and unlink their files from
disk. Failures on individual files are logged but don't abort the batch. disk. Failures on individual files are logged but don't abort the batch.
""" """
result = await db.execute( 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() photos = result.scalars().all()
return await _permanently_delete(db, photos) return await _permanently_delete(db, photos)
@@ -54,6 +56,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db)):
async def delete_discarded( async def delete_discarded(
photo_ids: list[str] = Body(..., embed=True), photo_ids: list[str] = Body(..., embed=True),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Permanently delete a specific subset of discarded photos. The photos """Permanently delete a specific subset of discarded photos. The photos
must already be in the discard pile — non-discarded ids are skipped so 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} return {"status": "success", "deleted": 0, "file_errors": 0}
result = await db.execute( result = await db.execute(
select(Photo).where( 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() photos = result.scalars().all()

View File

@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import Folder, SourceRoot, Photo 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__) logger = logging.getLogger(__name__)
@@ -48,10 +50,10 @@ def _validate_folder_name(name: str) -> str:
return name return name
@router.get("") @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 all source folders"""
# Get source roots instead of regular 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() source_roots = result.scalars().all()
folders_list = [] folders_list = []
@@ -73,7 +75,7 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
return {"folders": folders_list} return {"folders": folders_list}
@router.get("/tree") @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 """Recursive folder tree, one root per active SourceRoot. The tree
starts at the Folder row matching the SourceRoot.path (the scanner starts at the Folder row matching the SourceRoot.path (the scanner
creates one for every walked directory), with the SourceRoot's 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. creates as a parent walk) are skipped via path-prefix filtering.
""" """
sr_result = await db.execute( 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() source_roots = sr_result.scalars().all()
@@ -196,6 +198,7 @@ async def rename_folder(
folder_id: str, folder_id: str,
body: FolderRename, body: FolderRename,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Rename a folder. Two cases: """Rename a folder. Two cases:
@@ -212,7 +215,7 @@ async def rename_folder(
# Try SourceRoot first (display-only rename). # Try SourceRoot first (display-only rename).
sr_result = await db.execute( 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() source_root = sr_result.scalar_one_or_none()
if source_root: if source_root:
@@ -225,14 +228,11 @@ async def rename_folder(
} }
# Otherwise it's a Folder row. # Otherwise it's a Folder row.
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id)) folder = await get_user_folder(folder_id, current_user, db)
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
# Refuse to rename the bare source root mount through here. # Refuse to rename the bare source root mount through here.
sr_check = await db.execute( 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() sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path): 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) @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 a new sub-folder under an existing Folder. Mirrors the
create to disk so the next scan sees it. Body: { name, parent_id }. 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 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) name = _validate_folder_name(body.name)
parent_result = await db.execute( parent = await get_user_folder(body.parent_id, current_user, db)
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")
new_path = os.path.join(parent.path, name) new_path = os.path.join(parent.path, name)
if os.path.exists(new_path): if os.path.exists(new_path):
@@ -323,6 +318,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
name=name, name=name,
path=new_path, path=new_path,
source_root_id=parent.source_root_id, source_root_id=parent.source_root_id,
user_id=current_user.id,
photo_count=0, photo_count=0,
) )
db.add(new_folder) db.add(new_folder)
@@ -341,6 +337,7 @@ async def delete_folder(
folder_id: str, folder_id: str,
mode: Literal['discard', 'permanent'] = Query('discard'), mode: Literal['discard', 'permanent'] = Query('discard'),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Delete a folder. Behavior depends on mode: """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 Refuses to delete the bare source-root mount in either mode (deleting
the docker mount through the UI would be a footgun). the docker mount through the UI would be a footgun).
""" """
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id)) folder = await get_user_folder(folder_id, current_user, db)
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
sr_check = await db.execute( 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() sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path): 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, folder_id: str,
body: FolderHide, body: FolderHide,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Toggle the "hide from views" flag on a folder or source root. """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. # SourceRoot path — resolve to the Folder row at the mount point.
sr_result = await db.execute( 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() source_root = sr_result.scalar_one_or_none()
@@ -511,6 +506,7 @@ async def set_folder_hidden(
root_folder_result = await db.execute( root_folder_result = await db.execute(
select(Folder).where( select(Folder).where(
Folder.source_root_id == source_root.id, Folder.source_root_id == source_root.id,
Folder.user_id == current_user.id,
Folder.path == os.path.normpath(source_root.path), 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.", detail="Source root has no indexed Folder row yet; scan first.",
) )
else: else:
folder_result = await db.execute( folder = await get_user_folder(folder_id, current_user, db)
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.is_hidden = bool(body.hidden) folder.is_hidden = bool(body.hidden)
await db.flush() await db.flush()
@@ -547,11 +538,11 @@ async def set_folder_hidden(
@router.post("/{folder_id}/scan") @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""" """Trigger manual re-scan of source root folder"""
from app.tasks.celery import celery_app 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() source_root = result.scalar_one_or_none()
if not source_root: 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 import Heap, Photo, Folder
from app.models.folders import SourceRoot from app.models.folders import SourceRoot
from app.models.heaps import heap_photos 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__) logger = logging.getLogger(__name__)
@@ -48,7 +50,10 @@ class HeapConvertBody(BaseModel):
# ── Endpoints ───────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("") @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.""" """List all heaps with photo counts."""
# LEFT JOIN heap_photos and group so we can return counts in one query. # LEFT JOIN heap_photos and group so we can return counts in one query.
count_subq = ( count_subq = (
@@ -63,6 +68,7 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
stmt = ( stmt = (
select(Heap, count_subq.c.photo_count) select(Heap, count_subq.c.photo_count)
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id) .outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
.where(Heap.user_id == current_user.id)
.order_by(Heap.created_at.asc()) .order_by(Heap.created_at.asc())
) )
result = await db.execute(stmt) result = await db.execute(stmt)
@@ -82,12 +88,16 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
@router.post("", status_code=201) @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.""" """Create a new heap."""
name = (body.name or "").strip() name = (body.name or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="Heap name is required") 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) db.add(heap)
await db.commit() await db.commit()
await db.refresh(heap) await db.refresh(heap)
@@ -103,14 +113,14 @@ async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
@router.patch("/{heap_id}") @router.patch("/{heap_id}")
async def update_heap( 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 """Rename a heap and/or toggle active state. Setting is_active=true on
one heap deactivates all others (single-active invariant).""" one heap deactivates all others (single-active invariant)."""
result = await db.execute(select(Heap).where(Heap.id == heap_id)) heap = await get_user_heap(heap_id, current_user, db)
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
if body.name is not None: if body.name is not None:
name = body.name.strip() name = body.name.strip()
@@ -120,8 +130,12 @@ async def update_heap(
if body.is_active is not None: if body.is_active is not None:
if body.is_active: if body.is_active:
# Clear active flag on all other heaps in one statement # Clear active flag on all other heaps for this user
await db.execute(update(Heap).values(is_active=False)) await db.execute(
update(Heap)
.where(Heap.user_id == current_user.id)
.values(is_active=False)
)
heap.is_active = True heap.is_active = True
else: else:
heap.is_active = False heap.is_active = False
@@ -138,17 +152,18 @@ async def update_heap(
@router.post("/{heap_id}/duplicate", status_code=201) @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 """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 — new heap is named "{original} (copy)" and is never the active target —
duplicating shouldn't quietly steal the user's T-key destination. duplicating shouldn't quietly steal the user's T-key destination.
""" """
result = await db.execute(select(Heap).where(Heap.id == heap_id)) source = await get_user_heap(heap_id, current_user, db)
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Heap not found")
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) db.add(new_heap)
await db.flush() # populate new_heap.id without committing yet 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) @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 """Delete a heap. Photos themselves are unaffected — only the membership
rows in heap_photos cascade-delete.""" rows in heap_photos cascade-delete."""
result = await db.execute(select(Heap).where(Heap.id == heap_id)) heap = await get_user_heap(heap_id, current_user, db)
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
await db.delete(heap) await db.delete(heap)
await db.commit() await db.commit()
return None return None
@router.get("/{heap_id}/photo_ids") @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 """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 to maintain a fast client-side membership lookup for the active heap
(for the basket affordance on thumbnails) without fetching full photo (for the basket affordance on thumbnails) without fetching full photo
records.""" records."""
await get_user_heap(heap_id, current_user, db)
result = await db.execute( result = await db.execute(
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id) 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") @router.post("/{heap_id}/photos")
async def add_photos_to_heap( 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 """Add photos to a heap. Idempotent: re-adding existing members is a
no-op (handled by an INSERT OR IGNORE-style filter on duplicates).""" no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
result = await db.execute(select(Heap).where(Heap.id == heap_id)) await get_user_heap(heap_id, current_user, db)
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
if not body.photo_ids: if not body.photo_ids:
return {"status": "success", "added": 0} return {"status": "success", "added": 0}
@@ -241,6 +262,7 @@ async def convert_heap_to_folder(
heap_id: str, heap_id: str,
body: HeapConvertBody, body: HeapConvertBody,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Convert a heap into a folder by moving (or copying) every member """Convert a heap into a folder by moving (or copying) every member
photo into the target directory. Optionally deletes the heap row at 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 target_id may be a Folder id or a SourceRoot id (matches the
/photos/move convention so the same dropdown can populate it). /photos/move convention so the same dropdown can populate it).
""" """
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id)) heap = await get_user_heap(heap_id, current_user, db)
heap = heap_result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
# Resolve target_id → (target_dir, target_folder) # Resolve target_id → (target_dir, target_folder)
sr_check = await db.execute( sr_check = await db.execute(
@@ -402,13 +421,13 @@ async def convert_heap_to_folder(
@router.delete("/{heap_id}/photos") @router.delete("/{heap_id}/photos")
async def remove_photos_from_heap( 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.""" """Remove photos from a heap. Removing a non-member is a no-op."""
result = await db.execute(select(Heap).where(Heap.id == heap_id)) await get_user_heap(heap_id, current_user, db)
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
if not body.photo_ids: if not body.photo_ids:
return {"status": "success", "removed": 0} return {"status": "success", "removed": 0}

View File

@@ -11,42 +11,42 @@ import os
import shutil import shutil
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field 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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import Photo from app.models import Photo
from app.models.user import User
from app.dependencies import get_current_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() 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 # Media types we accept in the regenerate-thumbnails request body. Mirrors
# the values produced by `app.tasks.scan.get_media_type`. # the values produced by `app.tasks.scan.get_media_type`.
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'} _VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
@router.get("/stats") @router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)): async def get_library_stats(
"""Get library statistics + per-section counts. Each section count db: AsyncSession = Depends(get_db),
matches the filter the sidebar applies when you click it, so the current_user: User = Depends(get_current_user),
sidebar badges and the timeline below them stay in sync. scope: str | None = Query(None),
):
- all_photos: non-discarded photos + videos (matches the All """Get library statistics. Pass ?scope=global (admin only) for
Photos section's default filter) cross-user totals (used by the Settings page)."""
- rated: non-discarded with rating >= 1 owner = _owner_filter(current_user, scope)
- colored: non-discarded with a color_label set (matches the visible = owner & (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False))
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))
all_photos_count = ( all_photos_count = (
await db.execute(select(func.count(Photo.id)).where(visible)) 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 = ( discarded_count = (
await db.execute( 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 ).scalar() or 0
@@ -92,17 +92,18 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
photo_count = ( photo_count = (
await db.execute( await db.execute(
select(func.count(Photo.id)).where( select(func.count(Photo.id)).where(
owner,
Photo.media_type.in_(['photo', 'heic', 'raw']) Photo.media_type.in_(['photo', 'heic', 'raw'])
) )
) )
).scalar() or 0 ).scalar() or 0
video_count = ( video_count = (
await db.execute( 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 ).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 { return {
"all_photos": all_photos_count, "all_photos": all_photos_count,
@@ -118,7 +119,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
} }
@router.post("/scan") @router.post("/scan")
async def trigger_scan(): async def trigger_scan(current_user: User = Depends(get_current_user)):
"""Trigger full library re-scan""" """Trigger full library re-scan"""
from app.tasks.scan import scan_all_source_roots from app.tasks.scan import scan_all_source_roots
@@ -128,7 +129,7 @@ async def trigger_scan():
@router.post("/backfill-gps") @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 """Re-run EXIF metadata extraction on every photo that's still missing
GPS coordinates. Useful after fixing the EXIF parser, or any time the GPS coordinates. Useful after fixing the EXIF parser, or any time the
Map view looks emptier than expected. Returns immediately — work runs 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"} return {"status": "success", "message": "GPS backfill queued"}
@router.get("/scan/status") @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""" """Get current scan status"""
import redis import redis
from app.config import settings from app.config import settings
@@ -187,20 +188,27 @@ class RegenerateThumbnailsRequest(BaseModel):
@router.get("/maintenance/thumbnail-stats") @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 """Counts of photos by processing_status, plus a media-type breakdown
so the Settings panel can show the user what's outstanding.""" so the Settings panel can show the user what's outstanding."""
owner = _owner_filter(current_user, scope)
status_rows = ( status_rows = (
await db.execute( await db.execute(
select(Photo.processing_status, func.count(Photo.id)).group_by( select(Photo.processing_status, func.count(Photo.id))
Photo.processing_status .where(owner)
) .group_by(Photo.processing_status)
) )
).all() ).all()
media_rows = ( media_rows = (
await db.execute( 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() ).all()
@@ -222,6 +230,8 @@ async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
async def regenerate_thumbnails( async def regenerate_thumbnails(
body: RegenerateThumbnailsRequest, body: RegenerateThumbnailsRequest,
db: AsyncSession = Depends(get_db), 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 """Reset matching photos' on-disk thumbnail directories and re-queue
Celery thumbnail generation. Used by the Settings panel for the 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 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. # Validate media_types early so a typo can't silently match nothing.
media_types = body.media_types media_types = body.media_types
if media_types is not None: if media_types is not None:
@@ -244,7 +256,7 @@ async def regenerate_thumbnails(
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}", f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
} }
query = select(Photo) query = select(Photo).where(owner)
if media_types: if media_types:
query = query.where(Photo.media_type.in_(media_types)) query = query.where(Photo.media_type.in_(media_types))
if body.only_failed: if body.only_failed:
@@ -297,7 +309,11 @@ async def regenerate_thumbnails(
@router.get("/maintenance/worker-status") @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. """Diagnostics for the Celery worker fleet + recent task failures.
Surfaced in the Settings panel so the user can spot a stuck queue or 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. things failed without opening the DB.
- broker_ok: bool — could we even reach Redis? - broker_ok: bool — could we even reach Redis?
""" """
owner = _owner_filter(current_user, scope)
from app.tasks.celery import celery_app from app.tasks.celery import celery_app
from app.config import settings from app.config import settings
import redis as _redis 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 ---------------------- # ----- Recent task failures from the photos table ----------------------
failed_total = ( failed_total = (
await db.execute( 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 ).scalar() or 0
@@ -411,7 +428,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
Photo.processing_error, Photo.processing_error,
Photo.updated_at, Photo.updated_at,
) )
.where(Photo.processing_status == 'failed') .where(owner, Photo.processing_status == 'failed')
.order_by(Photo.updated_at.desc().nullslast()) .order_by(Photo.updated_at.desc().nullslast())
.limit(20) .limit(20)
) )
@@ -454,7 +471,11 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
@router.get("/maintenance/pipeline-stats") @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. """Per-stage progress across the ingestion pipeline.
Returns a `{stage_key: {done, total, label}}` map so the Settings 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 import Embedding, FaceEmbedding, OCRText
from app.models.tags import photo_tags # association Table, not a model 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): async def scalar_count(query):
return (await db.execute(query)).scalar() or 0 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") @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 """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 source root. Surfaced in Settings so the user can see a number before
pulling the trigger on prune-missing. Cheap enough to call freely.""" 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") @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. """Actually delete the orphaned photo rows reported by /missing-stats.
Common cause: PHOTO_DIRS in .env was repointed at a different library Common cause: PHOTO_DIRS in .env was repointed at a different library
leaving every old row dangling. Skips any photo whose source root leaving every old row dangling. Skips any photo whose source root
@@ -677,7 +699,7 @@ async def run_prune_missing():
@router.post("/maintenance/cleanup") @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 """Re-run the source-roots / folders / photos data-integrity cleanup
that normally only runs on backend startup. Idempotent.""" that normally only runs on backend startup. Idempotent."""
from app.services.cleanup import cleanup_data_integrity from app.services.cleanup import cleanup_data_integrity
@@ -695,7 +717,11 @@ async def run_data_integrity_cleanup():
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
@router.get("/duplicates/groups") @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. """Return every duplicate group with its members.
Drives the frontend grouped grid view in the Duplicates section. One 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) duplicates that the perceptual hash trivially caught)
* "similar" — members differ at the byte level but match perceptually * "similar" — members differ at the byte level but match perceptually
""" """
owner = _owner_filter(current_user, scope)
rows = ( rows = (
await db.execute( await db.execute(
select( select(
@@ -723,6 +750,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
Photo.media_type, Photo.media_type,
Photo.duplicate_group_id, Photo.duplicate_group_id,
) )
.where(owner)
.where(Photo.duplicate_group_id.is_not(None)) .where(Photo.duplicate_group_id.is_not(None))
.where(Photo.is_discarded.is_(False)) .where(Photo.is_discarded.is_(False))
.where(Photo.is_hidden.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") @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. """Recompute duplicate groups from current perceptual hashes.
Fires the celery `regroup_duplicates` task which walks every photo's 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") @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. """Compute perceptual hashes for every photo currently missing one.
One-shot recovery path for libraries that existed before the phash 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 import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
import base64
import json import json
import os import os
import logging import logging
@@ -19,11 +20,13 @@ logger = logging.getLogger(__name__)
from app.database import get_db from app.database import get_db
from app.models import Photo, Folder, Tag from app.models import Photo, Folder, Tag
from app.models.folders import SourceRoot from app.models.folders import SourceRoot
from app.models.user import User
from app.models.heaps import heap_photos from app.models.heaps import heap_photos
from app.models.tags import photo_tags from app.models.tags import photo_tags
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.services.exif_writer import ExifWriteError, write_taken_at 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.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 from app.config import settings
router = APIRouter() router = APIRouter()
@@ -47,13 +50,15 @@ async def list_photos(
order: str = "desc", order: str = "desc",
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(100, ge=1, le=500), 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""" """List photos with filters and pagination"""
# Build query — eager-load tags so the response can include them # Build query — eager-load tags so the response can include them
# without an N+1 round-trip per photo. # without an N+1 round-trip per photo. Scoped to the current user.
query = select(Photo).options(selectinload(Photo.tags)) query = select(Photo).options(selectinload(Photo.tags)).where(Photo.user_id == current_user.id)
# Apply filters # Apply filters
filters = [] filters = []
@@ -206,24 +211,92 @@ async def list_photos(
"rating": Photo.rating, "rating": Photo.rating,
} }
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at) sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
if order == "desc": desc = order == "desc"
query = query.order_by(sort_column.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: else:
query = query.order_by(sort_column.asc()) query = query.order_by(sort_column.asc().nulls_last(), Photo.id.asc())
# Count total results # Count total results (only when no cursor — first page needs it;
count_query = select(func.count()).select_from(query.subquery()) # subsequent pages reuse the total from the first response).
total_result = await db.execute(count_query) total = None
total = total_result.scalar() if not cursor:
count_query = select(func.count()).select_from(query.subquery())
# Apply pagination total_result = await db.execute(count_query)
offset = (page - 1) * per_page total = total_result.scalar()
query = query.offset(offset).limit(per_page)
# 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 # Execute query
result = await db.execute(query) result = await db.execute(query)
photos = result.scalars().all() 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 # Convert to response, attaching tags inline so the frontend can group
# client-side without a second round-trip. # client-side without a second round-trip.
photo_dicts = [] photo_dicts = []
@@ -235,21 +308,25 @@ async def list_photos(
] ]
photo_dicts.append(d) photo_dicts.append(d)
return { response = {
"photos": photo_dicts, "photos": photo_dicts,
"total": total,
"page": page,
"per_page": per_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") @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 """Lightweight listing of every non-discarded photo that has GPS
coordinates, used by the Map view. Intentionally returns a flat list coordinates, used by the Map view."""
(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."""
result = await db.execute( result = await db.execute(
select( select(
Photo.id, Photo.id,
@@ -257,6 +334,7 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
Photo.longitude, Photo.longitude,
Photo.taken_at, Photo.taken_at,
).where( ).where(
Photo.user_id == current_user.id,
Photo.is_discarded.is_(False), Photo.is_discarded.is_(False),
Photo.is_hidden.is_(False), Photo.is_hidden.is_(False),
Photo.latitude.is_not(None), Photo.latitude.is_not(None),
@@ -277,16 +355,11 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
@router.get("/{photo_id}") @router.get("/{photo_id}")
async def get_photo( async def get_photo(
photo_id: str, 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.""" """Get single photo with full EXIF and its tags."""
result = await db.execute( photo = await get_user_photo(photo_id, current_user, db)
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")
# Fetch tags via the join table so we don't need to declare a # Fetch tags via the join table so we don't need to declare a
# relationship on the Photo model side. # relationship on the Photo model side.
@@ -310,12 +383,11 @@ async def add_photo_tags(
photo_id: str, photo_id: str,
body: dict, body: dict,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }. """Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
Idempotent: re-adding existing members is a no-op.""" Idempotent: re-adding existing members is a no-op."""
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id)) await get_user_photo(photo_id, current_user, db)
if photo_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Photo not found")
tag_ids = body.get("tag_ids") or [] tag_ids = body.get("tag_ids") or []
if not isinstance(tag_ids, list) or not tag_ids: if not isinstance(tag_ids, list) or not tag_ids:
@@ -346,8 +418,10 @@ async def remove_photo_tag(
photo_id: str, photo_id: str,
tag_id: str, tag_id: str,
db: AsyncSession = Depends(get_db), 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.""" """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 from sqlalchemy import delete as sql_delete
await db.execute( await db.execute(
sql_delete(photo_tags).where( sql_delete(photo_tags).where(
@@ -363,22 +437,21 @@ async def get_thumbnail(
photo_id: str, photo_id: str,
size: str, size: str,
response: Response, 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)""" """Serve thumbnail (with Nginx X-Accel-Redirect support)"""
if size not in ['small', 'medium', 'large']: if size not in ['small', 'medium', 'large']:
raise HTTPException(status_code=400, detail="Invalid thumbnail size") raise HTTPException(status_code=400, detail="Invalid thumbnail size")
result = await db.execute( photo = await get_user_photo(photo_id, current_user, db)
select(Photo).where(Photo.id == photo_id)
) # Check if thumbnail exists, generate if not.
photo = result.scalar_one_or_none() # User-prefixed path for isolation.
if photo.user_id:
if not photo: thumb_dir = f"/data/thumbs/{photo.user_id}/{photo_id}"
raise HTTPException(status_code=404, detail="Photo not found") else:
thumb_dir = f"/data/thumbs/{photo_id}"
# Check if thumbnail exists, generate if not
thumb_dir = f"/data/thumbs/{photo_id}"
thumb_path = f"{thumb_dir}/{size}.webp" thumb_path = f"{thumb_dir}/{size}.webp"
if not os.path.exists(thumb_path): if not os.path.exists(thumb_path):
@@ -451,16 +524,11 @@ async def get_thumbnail(
@router.get("/{photo_id}/original") @router.get("/{photo_id}/original")
async def get_original( async def get_original(
photo_id: str, 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)""" """Serve original file (download for RAW, inline for web-safe formats)"""
result = await db.execute( photo = await get_user_photo(photo_id, current_user, db)
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")
if not os.path.exists(photo.filepath): if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
@@ -560,19 +628,10 @@ async def get_proxy(
photo_id: str, photo_id: str,
response: Response, response: Response,
db: AsyncSession = Depends(get_db), 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, """Serve a full-resolution WebP proxy for non-web-safe formats."""
TIFF) so the loupe view can display them inline. Web-safe formats are photo = await get_user_photo(photo_id, current_user, db)
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")
if not os.path.exists(photo.filepath): if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
@@ -613,19 +672,11 @@ async def get_proxy(
async def update_photo( async def update_photo(
photo_id: str, photo_id: str,
update: PhotoUpdate, 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 """Update photo metadata."""
file on disk in its current directory (no cross-folder moves through photo = await get_user_photo(photo_id, current_user, db)
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_data = update.dict(exclude_unset=True) update_data = update.dict(exclude_unset=True)
@@ -692,19 +743,11 @@ async def update_photo(
@router.delete("/{photo_id}") @router.delete("/{photo_id}")
async def discard_photo( async def discard_photo(
photo_id: str, 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 """Soft-discard a photo."""
restore is just a flag flip. Permanent deletion happens via DELETE photo = await get_user_photo(photo_id, current_user, db)
/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")
photo.is_discarded = True photo.is_discarded = True
photo.discarded_at = datetime.utcnow() photo.discarded_at = datetime.utcnow()
@@ -726,6 +769,7 @@ class CopyRequest(BaseModel):
async def copy_photos( async def copy_photos(
body: CopyRequest, body: CopyRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""Copy photos into a target folder. Same target resolution as /move """Copy photos into a target folder. Same target resolution as /move
(folder id or source root id), but uses shutil.copy2 and creates new (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": []} return {"status": "success", "copied": 0, "errors": []}
photos_result = await db.execute( 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() photos_to_copy = photos_result.scalars().all()
@@ -820,6 +864,7 @@ async def copy_photos(
file_size=photo.file_size, file_size=photo.file_size,
taken_at=photo.taken_at, taken_at=photo.taken_at,
taken_at_source=photo.taken_at_source, taken_at_source=photo.taken_at_source,
user_id=current_user.id,
user_title=photo.user_title, user_title=photo.user_title,
user_notes=photo.user_notes, user_notes=photo.user_notes,
rating=photo.rating, rating=photo.rating,
@@ -844,6 +889,7 @@ async def copy_photos(
async def move_photos( async def move_photos(
body: MoveRequest, body: MoveRequest,
db: AsyncSession = Depends(get_db), 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 """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 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: if not body.photo_ids:
return {"status": "success", "moved": 0, "errors": []} return {"status": "success", "moved": 0, "errors": []}
# Fetch the photo rows # Fetch the photo rows, scoped to user
photos_result = await db.execute( 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() photos_to_move = photos_result.scalars().all()
@@ -932,12 +978,13 @@ async def move_photos(
@router.post("/bulk") @router.post("/bulk")
async def bulk_action( async def bulk_action(
action: BulkAction, 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""" """Perform bulk actions on multiple photos"""
# Get photos # Get photos, scoped to user
result = await db.execute( 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() photos = result.scalars().all()

View File

@@ -10,6 +10,8 @@ from sqlalchemy import select
from app.database import get_db from app.database import get_db
from app.models import Photo from app.models import Photo
from app.services.search import hybrid_search from app.services.search import hybrid_search
from app.models.user import User
from app.dependencies import get_current_user
router = APIRouter() router = APIRouter()
@@ -22,7 +24,7 @@ class SearchRequest(BaseModel):
@router.post("") @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) """Unified search endpoint. Every query runs hybrid (FTS + semantic)
by default — the user never picks a mode. 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 # Hydrate with photo data
photo_ids = [r["photo_id"] for r in results] 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() rows = (await db.execute(stmt)).scalars().all()
photo_map = {p.id: p for p in rows} 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.database import get_db
from app.models import Photo, Tag from app.models import Photo, Tag
from app.models.tags import photo_tags from app.models.tags import photo_tags
from app.models.user import User
from app.dependencies import get_current_user
router = APIRouter() router = APIRouter()
@@ -42,6 +44,7 @@ class TagMerge(BaseModel):
async def list_tags( async def list_tags(
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"), kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
"""List all tags with their photo counts, optionally filtered by kind. """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) photo_tags.join(Photo, Photo.id == photo_tags.c.photo_id)
) )
.where( .where(
Photo.user_id == current_user.id,
Photo.is_discarded.is_(False), Photo.is_discarded.is_(False),
Photo.is_hidden.is_(False), Photo.is_hidden.is_(False),
) )
@@ -70,6 +74,7 @@ async def list_tags(
stmt = ( stmt = (
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id) select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id) .outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
.where(Tag.user_id == current_user.id)
) )
if kind: if kind:
stmt = stmt.where(Tag.kind == kind) stmt = stmt.where(Tag.kind == kind)
@@ -93,7 +98,7 @@ async def list_tags(
@router.post("", status_code=201) @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 """Create a new tag. The (name, kind) pair is unique — re-creating an
existing pair returns the existing row (idempotent for autocomplete).""" existing pair returns the existing row (idempotent for autocomplete)."""
name = (body.name or "").strip() 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") raise HTTPException(status_code=400, detail="Tag name is required")
existing = await db.execute( 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() found = existing.scalar_one_or_none()
if found: if found:
@@ -110,7 +115,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
"kind": found.kind, "photo_count": 0, "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) db.add(tag)
await db.commit() await db.commit()
await db.refresh(tag) await db.refresh(tag)
@@ -122,10 +127,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
@router.patch("/{tag_id}") @router.patch("/{tag_id}")
async def update_tag( 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).""" """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() tag = result.scalar_one_or_none()
if not tag: if not tag:
raise HTTPException(status_code=404, detail="Tag not found") raise HTTPException(status_code=404, detail="Tag not found")
@@ -145,7 +151,8 @@ async def update_tag(
@router.post("/{tag_id}/merge") @router.post("/{tag_id}/merge")
async def merge_tag( 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 """Merge tag_id INTO target_id. All photo associations from the source
tag are moved to the target, then the source tag is deleted. 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: if tag_id == body.target_id:
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself") 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() 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))).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: if not source:
raise HTTPException(status_code=404, detail="Source tag not found") raise HTTPException(status_code=404, detail="Source tag not found")
if not target: if not target:
@@ -189,9 +196,9 @@ async def merge_tag(
@router.delete("/{tag_id}", status_code=204) @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.""" """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() tag = result.scalar_one_or_none()
if not tag: if not tag:
raise HTTPException(status_code=404, detail="Tag not found") 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 Scanner service for initial library scan and per-user source root bootstrap.
default source root on first boot.
""" """
import os import os
import logging import logging
@@ -8,44 +7,74 @@ from sqlalchemy import select
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models import SourceRoot from app.models import SourceRoot
from app.models.user import User
from app.tasks.scan import scan_all_source_roots from app.tasks.scan import scan_all_source_roots
from app.config import settings from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# The single host → container mount path. The compose file mounts whatever
# the user set as PHOTO_DIRS at this path. async def bootstrap_user_source_root(user: User, session=None) -> None:
DEFAULT_LIBRARY_PATH = "/photos" """Create the media directory and a source root for a user.
DEFAULT_LIBRARY_NAME = "Library"
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: async def bootstrap_default_source_root() -> None:
"""If no source roots exist in the DB, create one pointing at the default """Legacy bootstrap — for existing installs that have source roots
library mount. Lets a fresh install pick up photos with zero without user_id (pre-auth migration). On fresh installs, source roots
configuration: the user only needs to set PHOTO_DIRS in .env. 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: async with AsyncSessionLocal() as session:
result = await session.execute(select(SourceRoot)) result = await session.execute(select(SourceRoot))
if result.scalars().first() is not None: 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( # No source roots and no users means fresh install — the setup
name=DEFAULT_LIBRARY_NAME, # endpoint will create the first user + source root.
path=DEFAULT_LIBRARY_PATH, user_count = (await session.execute(
) select(User)
session.add(source_root) )).scalars().first()
await session.commit() if user_count is None:
logger.info( logger.info(
f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME}" "No users or source roots — waiting for first-run setup."
f"{DEFAULT_LIBRARY_PATH}" )
) return
async def start_initial_scan(): 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: if not source_root_id:
source_root = await get_or_create_source_root(session, folder_path) source_root = await get_or_create_source_root(session, folder_path)
source_root_id = source_root.id 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 # Per-scan memoization cache for "is this folder's effective
# is_hidden true?" Populated on first lookup by walking the # 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): for root, dirs, files in os.walk(folder_path):
# Get or create folder entry # 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) progress_set(REDIS_KEY_CURRENT_FOLDER, root)
# Filter supported files # Filter supported files
@@ -237,6 +244,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
filepath=filepath, filepath=filepath,
filename=filename, filename=filename,
folder_id=folder.id, folder_id=folder.id,
user_id=owner_user_id,
file_hash=file_hash, file_hash=file_hash,
media_type=get_media_type(filepath), media_type=get_media_type(filepath),
original_format=Path(filepath).suffix.upper()[1:], 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 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.""" """Get or create a folder entry, matching by normalized path."""
from sqlalchemy import select 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 parent_id = parent.id
else: else:
# Recursively create parent # 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 parent_id = parent.id
else: else:
parent_id = None parent_id = None
@@ -374,6 +384,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
path=norm, path=norm,
parent_id=parent_id, parent_id=parent_id,
source_root_id=source_root_id, source_root_id=source_root_id,
user_id=user_id,
) )
session.add(folder) session.add(folder)
await session.flush() await session.flush()

View File

@@ -47,9 +47,16 @@ THUMB_SIZES = {
'large': settings.thumbnails.large 'large': settings.thumbnails.large
} }
def get_thumb_path(photo_id: str, size: str) -> str: def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
"""Get the path for a thumbnail file""" """Get the path for a thumbnail file.
thumb_dir = f"/data/thumbs/{photo_id}"
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) os.makedirs(thumb_dir, exist_ok=True)
return f"{thumb_dir}/{size}.{settings.thumbnails.format}" 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 # Generate thumbnails for each size
for size_name, size_value in THUMB_SIZES.items(): 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) generate_thumbnail(image, size_value, thumb_path)
# Update database with thumbnail 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 # Security and authentication
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.0.1
# Development # Development
pytest==7.4.4 pytest==7.4.4

View File

@@ -24,6 +24,9 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos} - PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
depends_on: depends_on:

View File

@@ -38,7 +38,11 @@ services:
# Run Alembic migrations before starting uvicorn. On a fresh Postgres # Run Alembic migrations before starting uvicorn. On a fresh Postgres
# the empty 0001 baseline is a no-op stamp; create_all in init_db then # the empty 0001 baseline is a no-op stamp; create_all in init_db then
# builds the schema. # builds the schema.
command: sh -c "python -c \"import asyncio; from app.database import init_db; asyncio.run(init_db())\" && alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" # init_db creates all tables from models (idempotent create_all),
# then Alembic runs migrations for existing installs. On fresh DBs
# create_all already built the full schema, so bootstrap.py stamps
# alembic head to skip redundant ALTER statements.
command: sh -c "python -c 'import asyncio; from app.database import init_db; asyncio.run(init_db())' && python bootstrap.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
environment: environment:
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita - DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
@@ -46,6 +50,9 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos} - PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
depends_on: depends_on:

View File

@@ -15,17 +15,19 @@ import { KeyboardHints } from './components/KeyboardHints'
import { PreviewView } from './components/preview/PreviewView' import { PreviewView } from './components/preview/PreviewView'
import { FilterBar } from './components/filter/FilterBar' import { FilterBar } from './components/filter/FilterBar'
import { DiscardActionBar } from './components/discard/DiscardActionBar' import { DiscardActionBar } from './components/discard/DiscardActionBar'
import { SettingsDialog } from './components/dialogs/SettingsDialog' import { SettingsPage } from './components/dialogs/SettingsDialog'
import { usePhotoStore } from './store/photoStore' import { usePhotoStore } from './store/photoStore'
import { useFilterStore } from './store/filterStore' import { useFilterStore } from './store/filterStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useFilterUrlSync } from './hooks/useFilterUrlSync' import { useFilterUrlSync } from './hooks/useFilterUrlSync'
import { usePhotosQuery } from './hooks/usePhotosQuery' import { usePhotosQuery } from './hooks/usePhotosQuery'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LoginPage } from './components/auth/LoginPage'
import { SetupPage } from './components/auth/SetupPage'
function App() { function MainApp() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(true) const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const viewMode = usePhotoStore((state) => state.viewMode) const viewMode = usePhotoStore((state) => state.viewMode)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
@@ -44,13 +46,13 @@ function App() {
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null, getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
}) })
// Settings page is a full-page section — hide filter bar, right sidebar,
// and keyboard hints when it's active.
const isSettings = currentSection === 'settings'
// Right sidebar stays open by default and shows whatever's selected // Right sidebar stays open by default and shows whatever's selected
// (or an empty state if nothing is). User can still toggle it manually. // (or an empty state if nothing is). User can still toggle it manually.
// Note: deliberately NOT gated on viewMode — the preview overlay sits const showRightSidebar = rightSidebarOpen && !isSettings
// on top with z-[1000], so leaving the sidebar mounted underneath
// costs nothing visually and avoids the collapse-then-reopen layout
// shift the user would otherwise see every time they exit preview.
const showRightSidebar = rightSidebarOpen
return ( return (
<div className="flex flex-col h-screen bg-bg text-text"> <div className="flex flex-col h-screen bg-bg text-text">
@@ -70,7 +72,6 @@ function App() {
> >
<LeftSidebar <LeftSidebar
onCollapse={() => setLeftSidebarOpen(false)} onCollapse={() => setLeftSidebarOpen(false)}
onOpenSettings={() => setSettingsOpen(true)}
/> />
</div> </div>
@@ -79,14 +80,12 @@ function App() {
* across the sidebar. relative so the KeyboardHints overlay * across the sidebar. relative so the KeyboardHints overlay
* centers against this column, not the viewport. */} * centers against this column, not the viewport. */}
<div className="relative flex min-w-0 flex-1 flex-col"> <div className="relative flex min-w-0 flex-1 flex-col">
<FilterBar /> {!isSettings && <FilterBar />}
<DiscardActionBar /> {!isSettings && <DiscardActionBar />}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{/* Section-level routing. The Map view replaces the timeline {currentSection === 'settings' ? (
* with a Leaflet map of GPS-tagged photos; Duplicates gets its <SettingsPage />
* own grouped grid; everything else falls through to the ) : currentSection === 'map' ? (
* filter-driven Timeline. */}
{currentSection === 'map' ? (
<MapView /> <MapView />
) : currentSection === 'duplicates' ? ( ) : currentSection === 'duplicates' ? (
<DuplicatesView /> <DuplicatesView />
@@ -102,10 +101,7 @@ function App() {
<Timeline /> <Timeline />
)} )}
</div> </div>
{/* Floating keyboard hints — bottom-center of the main column, {!isSettings && <KeyboardHints />}
* glassy. Mounted here so it's centered against the timeline,
* not the viewport (which would be offset by the sidebars). */}
<KeyboardHints />
</div> </div>
{/* Right Sidebar */} {/* Right Sidebar */}
@@ -127,13 +123,33 @@ function App() {
{/* Preview overlay — covers TopBar when active */} {/* Preview overlay — covers TopBar when active */}
{viewMode === 'preview' && <PreviewView />} {viewMode === 'preview' && <PreviewView />}
{/* Settings panel — admin/maintenance actions */}
<SettingsDialog
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</div> </div>
) )
} }
/** Auth-gated shell: shows setup, login, or the main app. */
function App() {
return (
<AuthProvider>
<AuthGate />
</AuthProvider>
)
}
function AuthGate() {
const { user, isLoading, needsSetup } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg">
<div className="text-text-muted">Loading&hellip;</div>
</div>
)
}
if (needsSetup) return <SetupPage />
if (!user) return <LoginPage />
return <MainApp />
}
export default App export default App

View File

@@ -0,0 +1,336 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react'
import { admin, type AdminUser } from '../../services/api'
export function UserManagement() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [error, setError] = useState<string | null>(null)
const fetchUsers = useCallback(async () => {
try {
const data = await admin.listUsers()
setUsers(data.users)
} catch {
setError('Failed to load users.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUsers()
}, [fetchUsers])
if (loading) {
return <div className="p-4 text-sm text-text-muted">Loading users&hellip;</div>
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">Users</h3>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white hover:bg-accent/80"
>
<Plus className="h-3 w-3" />
Add User
</button>
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-1 pr-4">Username</th>
<th className="pb-1 pr-4">Role</th>
<th className="pb-1 pr-4">Photos</th>
<th className="pb-1 pr-4">Status</th>
<th className="pb-1">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-border/50">
<td className="py-1.5 pr-4">
<div className="flex items-center gap-1.5">
{u.role === 'admin' ? (
<Shield className="h-3 w-3 text-accent" />
) : (
<UserIcon className="h-3 w-3 text-text-muted" />
)}
<span className="text-text">{u.username}</span>
</div>
</td>
<td className="py-1.5 pr-4 text-text-muted">{u.role}</td>
<td className="py-1.5 pr-4 text-text-muted">
{u.photo_count.toLocaleString()}
</td>
<td className="py-1.5 pr-4">
<span
className={
u.is_active
? 'text-green-400'
: 'text-red-400'
}
>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="py-1.5">
<div className="flex gap-1">
<button
onClick={() => setEditingUser(u)}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-text"
title="Edit user"
>
<Pencil className="h-3 w-3" />
</button>
{u.is_active && (
<button
onClick={async () => {
if (!confirm(`Deactivate user "${u.username}"? Their photos will be preserved.`)) return
try {
await admin.deleteUser(u.id)
fetchUsers()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
}
}}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-red-400"
title="Deactivate user"
>
<UserX className="h-3 w-3" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
)}
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => {
setEditingUser(null)
fetchUsers()
}}
/>
)}
</div>
)
}
// ── Create User Modal ──────────────────────────────────────────────────
function CreateUserModal({
onClose,
onCreated,
}: {
onClose: () => void
onCreated: () => void
}) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [role, setRole] = useState<'user' | 'admin'>('user')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
setError(null)
setLoading(true)
try {
await admin.createUser({ username: username.trim(), password, role })
onCreated()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to create user.')
} finally {
setLoading(false)
}
}
return (
<ModalOverlay onClose={onClose} title="Add User">
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
<div className="space-y-3">
<Field label="Username">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
autoFocus
/>
</Field>
<Field label="Password">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
/>
</Field>
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Creating\u2026' : 'Create'}
</button>
</div>
</ModalOverlay>
)
}
// ── Edit User Modal ────────────────────────────────────────────────────
function EditUserModal({
user,
onClose,
onSaved,
}: {
user: AdminUser
onClose: () => void
onSaved: () => void
}) {
const [role, setRole] = useState(user.role)
const [newPassword, setNewPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
setError(null)
setLoading(true)
try {
const data: { role?: string; new_password?: string } = {}
if (role !== user.role) data.role = role
if (newPassword) data.new_password = newPassword
if (Object.keys(data).length > 0) {
await admin.updateUser(user.id, data)
}
onSaved()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to update user.')
} finally {
setLoading(false)
}
}
return (
<ModalOverlay onClose={onClose} title={`Edit: ${user.username}`}>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
<div className="space-y-3">
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
<Field label="New Password (leave blank to keep current)">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
placeholder="Unchanged"
/>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Saving\u2026' : 'Save'}
</button>
</div>
</ModalOverlay>
)
}
// ── Shared helpers ─────────────────────────────────────────────────────
function ModalOverlay({
onClose: _onClose,
title,
children,
}: {
onClose: () => void
title: string
children: React.ReactNode
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={_onClose}>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h4 className="mb-3 text-sm font-semibold text-text">{title}</h4>
{children}
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<label className="block text-[11px] text-text-muted">{label}</label>
{children}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { useState, type FormEvent } from 'react'
import { useAuth } from '../../contexts/AuthContext'
export function LoginPage() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await login(username, password)
} catch (err: any) {
setError(
err.response?.data?.detail ?? 'Unable to sign in. Check your credentials.',
)
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl"
>
<h1 className="text-center text-xl font-semibold text-text">
Sign in to Mulita
</h1>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
<div className="space-y-1">
<label htmlFor="login-user" className="block text-sm text-text-muted">
Username
</label>
<input
id="login-user"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<div className="space-y-1">
<label htmlFor="login-pass" className="block text-sm text-text-muted">
Password
</label>
<input
id="login-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Signing in\u2026' : 'Sign In'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState, type FormEvent } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import api from '../../services/api'
export function SetupPage() {
const { onSetupComplete } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters.')
return
}
if (username.trim().length < 2) {
setError('Username must be at least 2 characters.')
return
}
setLoading(true)
try {
const res = await api.post('/auth/setup', {
username: username.trim(),
password,
})
const { access_token, refresh_token } = res.data
await onSetupComplete(access_token, refresh_token)
} catch (err: any) {
setError(
err.response?.data?.detail ?? 'Setup failed. Please try again.',
)
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl"
>
<div className="space-y-1 text-center">
<h1 className="text-xl font-semibold text-text">Welcome to Mulita</h1>
<p className="text-sm text-text-muted">
Create your admin account to get started.
</p>
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
<div className="space-y-1">
<label htmlFor="setup-user" className="block text-sm text-text-muted">
Username
</label>
<input
id="setup-user"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-pass" className="block text-sm text-text-muted">
Password
</label>
<input
id="setup-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-confirm" className="block text-sm text-text-muted">
Confirm Password
</label>
<input
id="setup-confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Creating account\u2026' : 'Create Admin Account'}
</button>
</form>
</div>
)
}

View File

@@ -1,6 +1,5 @@
import { useEffect, useState, useCallback, useRef } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { import {
X,
RefreshCw, RefreshCw,
Wrench, Wrench,
Film, Film,
@@ -15,6 +14,7 @@ import {
Sparkles, Sparkles,
Activity, Activity,
FolderSearch, FolderSearch,
Shield,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
@@ -26,6 +26,8 @@ import {
type WorkerStatus, type WorkerStatus,
} from '../../services/api' } from '../../services/api'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { useAuth } from '../../contexts/AuthContext'
import { UserManagement } from '../admin/UserManagement'
// React Query keys for the settings panels. Kept here (not in a shared // React Query keys for the settings panels. Kept here (not in a shared
// hook module) since they're internal to this dialog and used by the // hook module) since they're internal to this dialog and used by the
@@ -40,24 +42,22 @@ const SETTINGS_SCAN_STATUS_KEY = ['settings', 'scan-status'] as const
// the grid renders from. Imported via the canonical hook key. // the grid renders from. Imported via the canonical hook key.
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery' import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
interface SettingsDialogProps { type SettingsTab = 'library' | 'users'
isOpen: boolean
onClose: () => void const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [
} { id: 'library', label: 'Library Management' },
{ id: 'users', label: 'Users', adminOnly: true },
]
/** /**
* Catch-all "settings + admin" panel. Currently exposes the maintenance * Full-page settings view with tabbed navigation. Replaces the old
* endpoints exposed by /api/v1/library/maintenance/* — regenerate * modal dialog — renders as a top-level section in the main content
* thumbnails (with filters), run the data-integrity cleanup, and trigger * area (like Timeline or MapView).
* a full library re-scan. The thumbnail stats block is the entry point
* users will look at to understand what's going on after a scan.
*
* Each action is gated by an in-flight flag so double-clicks don't
* stack background jobs, and the stats block re-fetches whenever the
* dialog opens or after any action completes.
*/ */
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { export function SettingsPage() {
const { isAdmin } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<SettingsTab>('library')
const [showAllErrors, setShowAllErrors] = useState(false) const [showAllErrors, setShowAllErrors] = useState(false)
// One key per action so each button has its own spinner without // One key per action so each button has its own spinner without
// blocking the others. // blocking the others.
@@ -65,67 +65,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
// All four panels fetch through React Query so cached data shows // All four panels fetch through React Query so cached data shows
// instantly on reopen while a background refetch updates the numbers. // instantly on reopen while a background refetch updates the numbers.
// `enabled: isOpen` avoids firing requests while the dialog is closed, // All queries use scope=global so the admin sees cross-user totals.
// but the cache entries survive between opens (default gcTime = 5m).
const thumbStatsQuery = useQuery({ const thumbStatsQuery = useQuery({
queryKey: SETTINGS_THUMB_STATS_KEY, queryKey: SETTINGS_THUMB_STATS_KEY,
queryFn: library.maintenance.thumbnailStats, queryFn: () => library.maintenance.thumbnailStats('global'),
enabled: isOpen,
// Treat as stale immediately so reopening the dialog triggers a
// background refetch on top of the cached view.
staleTime: 0, staleTime: 0,
}) })
const libStatsQuery = useQuery({ const libStatsQuery = useQuery({
queryKey: SETTINGS_LIB_STATS_KEY, queryKey: SETTINGS_LIB_STATS_KEY,
queryFn: library.stats, queryFn: () => library.stats('global'),
enabled: isOpen,
staleTime: 0, staleTime: 0,
}) })
// Worker status polls every 5s while the dialog is open — `refetchInterval`
// replaces the old setInterval loop. Missing-stats is relatively cheap
// but shares the same 5s rhythm to keep the orphan banner live.
const workerStatusQuery = useQuery({ const workerStatusQuery = useQuery({
queryKey: SETTINGS_WORKER_STATUS_KEY, queryKey: SETTINGS_WORKER_STATUS_KEY,
queryFn: library.maintenance.workerStatus, queryFn: () => library.maintenance.workerStatus('global'),
enabled: isOpen, refetchInterval: 5000,
refetchInterval: isOpen ? 5000 : false,
staleTime: 0, staleTime: 0,
}) })
const missingStatsQuery = useQuery({ const missingStatsQuery = useQuery({
queryKey: SETTINGS_MISSING_STATS_KEY, queryKey: SETTINGS_MISSING_STATS_KEY,
queryFn: library.maintenance.missingStats, queryFn: library.maintenance.missingStats,
enabled: isOpen, refetchInterval: 5000,
refetchInterval: isOpen ? 5000 : false,
staleTime: 0, staleTime: 0,
}) })
// Pipeline progress polls on the same 5s cadence as the worker status
// so both cards update together. Cheap query — ten COUNT(*)s on
// indexed columns.
const pipelineStatsQuery = useQuery({ const pipelineStatsQuery = useQuery({
queryKey: SETTINGS_PIPELINE_STATS_KEY, queryKey: SETTINGS_PIPELINE_STATS_KEY,
queryFn: library.maintenance.pipelineStats, queryFn: () => library.maintenance.pipelineStats('global'),
enabled: isOpen, refetchInterval: 5000,
refetchInterval: isOpen ? 5000 : false,
staleTime: 0, staleTime: 0,
}) })
// Scan status — polls fast (2s) so the progress bar feels live during
// a scan, and slow (15s) when idle to cut chatter. `isScanning` is
// read from the latest fetched value so the cadence flips on its own
// the moment a scan kicks off or finishes.
const scanStatusQuery = useQuery<ScanStatus>({ const scanStatusQuery = useQuery<ScanStatus>({
queryKey: SETTINGS_SCAN_STATUS_KEY, queryKey: SETTINGS_SCAN_STATUS_KEY,
queryFn: library.scanStatus, queryFn: library.scanStatus,
enabled: isOpen,
refetchInterval: (q) => refetchInterval: (q) =>
isOpen ? ((q.state.data as ScanStatus | undefined)?.is_scanning ? 2000 : 15000) : false, (q.state.data as ScanStatus | undefined)?.is_scanning ? 2000 : 15000,
staleTime: 0, staleTime: 0,
}) })
// Duplicates: shares the cache with DuplicatesView so a regroup
// triggered from Settings updates the grid view immediately.
const duplicatesQuery = useQuery({ const duplicatesQuery = useQuery({
queryKey: DUPLICATE_GROUPS_QUERY_KEY, queryKey: [...DUPLICATE_GROUPS_QUERY_KEY, 'global'],
queryFn: library.duplicates.groups, queryFn: () => library.duplicates.groups('global'),
enabled: isOpen,
staleTime: 0, staleTime: 0,
}) })
@@ -170,29 +148,17 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
// Surface fetch errors once (React Query de-dupes retries but we still // Surface fetch errors once (React Query de-dupes retries but we still
// want a single toast so the user knows something went wrong). // want a single toast so the user knows something went wrong).
useEffect(() => { useEffect(() => {
if (!isOpen) return
if (thumbStatsQuery.error || libStatsQuery.error) { if (thumbStatsQuery.error || libStatsQuery.error) {
console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error) console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error)
toast.error('Could not load library stats') toast.error('Could not load library stats')
} }
}, [isOpen, thumbStatsQuery.error, libStatsQuery.error]) }, [thumbStatsQuery.error, libStatsQuery.error])
useEffect(() => { useEffect(() => {
if (!isOpen) return
if (workerStatusQuery.error || missingStatsQuery.error) { if (workerStatusQuery.error || missingStatsQuery.error) {
console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error) console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error)
toast.error('Could not load worker status') toast.error('Could not load worker status')
} }
}, [isOpen, workerStatusQuery.error, missingStatsQuery.error]) }, [workerStatusQuery.error, missingStatsQuery.error])
// Esc closes.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
const runAction = useCallback( const runAction = useCallback(
async <T,>( async <T,>(
@@ -229,39 +195,40 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
) => ) =>
runAction( runAction(
key, key,
() => library.maintenance.regenerateThumbnails(body), () => library.maintenance.regenerateThumbnails(body, 'global'),
'Regeneration queued', 'Regeneration queued',
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared` (r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
), ),
[runAction] [runAction]
) )
if (!isOpen) return null const visibleTabs = TABS.filter((t) => !t.adminOnly || isAdmin)
return ( return (
<div className="fixed inset-0 z-[2000]"> <div className="flex h-full flex-col">
<div {/* Tab bar */}
className="absolute inset-0 bg-black/60 backdrop-blur-sm" <div className="flex items-center gap-1 border-b border-border bg-surface px-4 py-1.5">
onClick={onClose} {visibleTabs.map((tab) => (
/> <button
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"> key={tab.id}
<div className="relative z-10 flex max-h-[85vh] w-[640px] flex-col rounded-lg border border-border bg-surface shadow-2xl"> onClick={() => setActiveTab(tab.id)}
{/* Header */} className={clsx(
<div className="flex items-center justify-between border-b border-border px-5 py-3"> 'rounded px-3 py-1 text-xs font-medium transition-colors',
<h2 className="text-base font-semibold text-text">Settings</h2> activeTab === tab.id
<button ? 'bg-primary/20 text-primary'
onClick={onClose} : 'text-text-muted hover:bg-surface-2 hover:text-text',
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text" )}
title="Close (Esc)" >
> {tab.label}
<X className="h-4 w-4" /> </button>
</button> ))}
</div> </div>
<div className="flex-1 overflow-y-auto p-5"> {/* Tab content */}
{/* ----------------------------------------------------- */} <div className="flex-1 overflow-y-auto p-5">
{/* Library overview */} <div className="mx-auto max-w-2xl">
{/* ----------------------------------------------------- */}
{activeTab === 'library' && (<>
<Section <Section
icon={<Database className="h-4 w-4" />} icon={<Database className="h-4 w-4" />}
title="Library" title="Library"
@@ -334,9 +301,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div> </div>
</Section> </Section>
{/* ----------------------------------------------------- */}
{/* Pipeline progress — per-stage done/total */}
{/* ----------------------------------------------------- */}
<Section <Section
icon={<Activity className="h-4 w-4" />} icon={<Activity className="h-4 w-4" />}
title="Pipeline progress" title="Pipeline progress"
@@ -379,9 +343,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
)} )}
</Section> </Section>
{/* ----------------------------------------------------- */}
{/* Duplicate detection */}
{/* ----------------------------------------------------- */}
<Section <Section
icon={<Copy className="h-4 w-4" />} icon={<Copy className="h-4 w-4" />}
title="Duplicates" title="Duplicates"
@@ -433,9 +394,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div> </div>
</Section> </Section>
{/* ----------------------------------------------------- */}
{/* Thumbnail maintenance */}
{/* ----------------------------------------------------- */}
<Section <Section
icon={<ImageIcon className="h-4 w-4" />} icon={<ImageIcon className="h-4 w-4" />}
title="Thumbnails" title="Thumbnails"
@@ -524,9 +483,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div> </div>
</Section> </Section>
{/* ----------------------------------------------------- */}
{/* Worker fleet diagnostics */}
{/* ----------------------------------------------------- */}
<Section <Section
icon={<Cpu className="h-4 w-4" />} icon={<Cpu className="h-4 w-4" />}
title="Workers" title="Workers"
@@ -848,9 +804,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
)} )}
</Section> </Section>
{/* ----------------------------------------------------- */}
{/* Data integrity */}
{/* ----------------------------------------------------- */}
<Section <Section
icon={<Wrench className="h-4 w-4" />} icon={<Wrench className="h-4 w-4" />}
title="Maintenance" title="Maintenance"
@@ -876,7 +829,17 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</ActionButton> </ActionButton>
</div> </div>
</Section> </Section>
</div> </>)}
{activeTab === 'users' && isAdmin && (
<Section
icon={<Shield className="h-4 w-4" />}
title="User Management"
>
<UserManagement />
</Section>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -20,6 +20,9 @@ import {
Users, Users,
Eye, Eye,
EyeOff, EyeOff,
User as UserIcon,
LogOut,
Shield,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api' import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -40,6 +43,7 @@ import {
import { registerUndoable } from '../../store/undoStore' import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
import { useAuth } from '../../contexts/AuthContext'
interface TreeItem { interface TreeItem {
id: string id: string
@@ -55,10 +59,10 @@ interface TreeItem {
interface LeftSidebarProps { interface LeftSidebarProps {
onCollapse: () => void onCollapse: () => void
onOpenSettings: () => void
} }
export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const { user, isAdmin, logout } = useAuth()
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps'])) const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
// Inline rename state for source-root rows. Stores the id being edited // Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start. // and the draft name. Double-click a folder row to start.
@@ -797,17 +801,40 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
<HeapsPanel /> <HeapsPanel />
</div> </div>
{/* Settings entry point — pinned to the bottom of the panel so it {/* Bottom panel — user identity + settings, pinned below the tree. */}
* sits out of the way of the library tree but is always reachable. */} <div className="border-t border-border p-1.5 space-y-0.5">
<div className="border-t border-border p-1.5"> {/* User row */}
<button <div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
onClick={onOpenSettings} <UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted hover:bg-surface-2 hover:text-text" <span className="flex-1 truncate text-text">{user?.username}</span>
title="Settings" {isAdmin && (
> <span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
<Settings className="h-3.5 w-3.5" /> <Shield className="inline h-2.5 w-2.5" />
Settings </span>
</button> )}
<button
onClick={logout}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-red-400 flex-shrink-0"
title="Sign out"
>
<LogOut className="h-3 w-3" />
</button>
</div>
{/* Settings — admin only, navigates to the settings section */}
{isAdmin && (
<button
onClick={() => navigateToSection('settings', {})}
className={clsx(
'flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] hover:bg-surface-2 hover:text-text',
currentSection === 'settings' ? 'text-primary' : 'text-text-muted',
)}
title="Settings"
>
<Settings className="h-3.5 w-3.5" />
Settings
</button>
)}
</div> </div>
<DeleteFolderDialog <DeleteFolderDialog

View File

@@ -96,22 +96,20 @@ export function TopBar({
{MULIMAGO_ASCII} {MULIMAGO_ASCII}
</pre> </pre>
</div> </div>
<div className="flex h-full items-end gap-2 self-stretch pb-1"> <div className="flex items-center gap-2">
<span className="text-[10px] font-serif text-black/80">
Built with hubris {toRoman(new Date().getFullYear())}
</span>
{!rightSidebarOpen && ( {!rightSidebarOpen && (
<button <button
onClick={onExpandRight} onClick={onExpandRight}
className="self-center rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text" className="rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
title="Expand panel (I)" title="Expand panel (I)"
aria-label="Expand right panel" aria-label="Expand right panel"
> >
<PanelRightOpen className="h-4 w-4" /> <PanelRightOpen className="h-4 w-4" />
</button> </button>
)} )}
<span
className="text-[10px] font-serif text-black/80"
>
Built with hubris {toRoman(new Date().getFullYear())}
</span>
</div> </div>
</header> </header>
) )

View File

@@ -0,0 +1,170 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from 'react'
import api from '../services/api'
export interface AuthUser {
id: string
username: string
email: string | null
role: 'admin' | 'user'
is_active: boolean
}
interface AuthContextValue {
user: AuthUser | null
isAdmin: boolean
isLoading: boolean
/** True when the backend has no users yet (first-run). */
needsSetup: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
/** Called after the setup endpoint creates the first admin. */
onSetupComplete: (accessToken: string, refreshToken: string) => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
// ── Token helpers ──────────────────────────────────────────────────────
function getStoredToken(): string | null {
return localStorage.getItem('access_token')
}
function storeToken(token: string) {
localStorage.setItem('access_token', token)
}
function clearToken() {
localStorage.removeItem('access_token')
}
// ── Provider ───────────────────────────────────────────────────────────
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
// Keep refresh token in memory only (not localStorage).
const refreshTokenRef = useRef<string | null>(null)
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isAdmin = user?.role === 'admin'
// Schedule a token refresh ~5 min before expiry.
const scheduleRefresh = useCallback((accessToken: string) => {
try {
const payload = JSON.parse(atob(accessToken.split('.')[1]))
const expiresAt = payload.exp * 1000
const refreshIn = Math.max(expiresAt - Date.now() - 5 * 60 * 1000, 10_000)
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
refreshTimerRef.current = setTimeout(async () => {
if (!refreshTokenRef.current) return
try {
const res = await api.post('/auth/refresh', {
refresh_token: refreshTokenRef.current,
})
const { access_token, refresh_token } = res.data
storeToken(access_token)
refreshTokenRef.current = refresh_token
scheduleRefresh(access_token)
} catch {
// Refresh failed — force re-login.
clearToken()
refreshTokenRef.current = null
setUser(null)
}
}, refreshIn)
} catch {
// Malformed token — ignore.
}
}, [])
const fetchMe = useCallback(async () => {
try {
const res = await api.get('/auth/me')
setUser(res.data)
} catch {
clearToken()
setUser(null)
}
}, [])
// Boot: check if setup is needed, then try to restore session.
useEffect(() => {
;(async () => {
try {
const statusRes = await api.get('/auth/status')
if (!statusRes.data.setup_completed) {
setNeedsSetup(true)
setIsLoading(false)
return
}
} catch {
// Backend unreachable — fall through to login screen.
}
const token = getStoredToken()
if (token) {
await fetchMe()
scheduleRefresh(token)
}
setIsLoading(false)
})()
return () => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
}
}, [fetchMe, scheduleRefresh])
const login = useCallback(
async (username: string, password: string) => {
const res = await api.post('/auth/login', { username, password })
const { access_token, refresh_token } = res.data
storeToken(access_token)
refreshTokenRef.current = refresh_token
scheduleRefresh(access_token)
await fetchMe()
},
[fetchMe, scheduleRefresh],
)
const logout = useCallback(() => {
clearToken()
refreshTokenRef.current = null
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
setUser(null)
}, [])
const onSetupComplete = useCallback(
async (accessToken: string, refreshToken: string) => {
storeToken(accessToken)
refreshTokenRef.current = refreshToken
setNeedsSetup(false)
scheduleRefresh(accessToken)
await fetchMe()
},
[fetchMe, scheduleRefresh],
)
return (
<AuthContext.Provider
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}
>
{children}
</AuthContext.Provider>
)
}

View File

@@ -11,7 +11,7 @@ export const DUPLICATE_GROUPS_QUERY_KEY = ['library', 'duplicates'] as const
export function useDuplicateGroupsQuery() { export function useDuplicateGroupsQuery() {
return useQuery<DuplicateGroupsResponse>({ return useQuery<DuplicateGroupsResponse>({
queryKey: DUPLICATE_GROUPS_QUERY_KEY, queryKey: DUPLICATE_GROUPS_QUERY_KEY,
queryFn: library.duplicates.groups, queryFn: () => library.duplicates.groups(),
staleTime: 30_000, staleTime: 30_000,
}) })
} }

View File

@@ -13,7 +13,7 @@ export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const
export function useLibraryStatsQuery() { export function useLibraryStatsQuery() {
return useQuery<LibraryStats>({ return useQuery<LibraryStats>({
queryKey: LIBRARY_STATS_QUERY_KEY, queryKey: LIBRARY_STATS_QUERY_KEY,
queryFn: library.stats, queryFn: () => library.stats(),
staleTime: 30_000, staleTime: 30_000,
}) })
} }

View File

@@ -14,6 +14,61 @@ const api = axios.create({
}, },
}) })
// ── Auth interceptors ──────────────────────────────────────────────────
// Attach the stored JWT to every outgoing request.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// On 401 responses, attempt one silent token refresh. If that also
// fails, clear stored credentials so the AuthContext falls back to the
// login screen on its next render.
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
// Skip retry for auth endpoints themselves to avoid loops.
if (original.url?.startsWith('/auth/')) {
return Promise.reject(error)
}
original._retry = true
if (!isRefreshing) {
isRefreshing = true
// The refresh token lives in AuthContext memory, not in
// localStorage. The interceptor can't access it directly, so we
// rely on the AuthContext's scheduled refresh to keep the access
// token fresh. If the access token is truly expired and no
// refresh has happened, we just force a logout.
localStorage.removeItem('access_token')
isRefreshing = false
// Reject — AuthContext will detect the missing token and show login.
return Promise.reject(error)
}
// Another request is already refreshing — queue this one.
return new Promise((resolve) => {
refreshSubscribers.push((token: string) => {
original.headers.Authorization = `Bearer ${token}`
resolve(api(original))
})
})
},
)
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in // Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
// .env → bootstrap on backend startup), so the UI only reads them and // .env → bootstrap on backend startup), so the UI only reads them and
// optionally renames the display label. // optionally renames the display label.
@@ -394,15 +449,19 @@ export const library = {
return response.data return response.data
}, },
stats: async (): Promise<LibraryStats> => { stats: async (scope?: 'global'): Promise<LibraryStats> => {
const response = await api.get('/library/stats') const response = await api.get('/library/stats', {
params: scope ? { scope } : undefined,
})
return response.data return response.data
}, },
/** Maintenance / admin actions surfaced via the Settings panel. */ /** Maintenance / admin actions surfaced via the Settings panel. */
maintenance: { maintenance: {
thumbnailStats: async (): Promise<ThumbnailStats> => { thumbnailStats: async (scope?: 'global'): Promise<ThumbnailStats> => {
const response = await api.get('/library/maintenance/thumbnail-stats') const response = await api.get('/library/maintenance/thumbnail-stats', {
params: scope ? { scope } : undefined,
})
return response.data return response.data
}, },
@@ -413,28 +472,30 @@ export const library = {
media_types?: MediaType[] media_types?: MediaType[]
only_failed?: boolean only_failed?: boolean
only_pending?: boolean only_pending?: boolean
} = {} } = {},
scope?: 'global',
): Promise<RegenerateResult> => { ): Promise<RegenerateResult> => {
const response = await api.post( const response = await api.post(
'/library/maintenance/regenerate-thumbnails', '/library/maintenance/regenerate-thumbnails',
body body,
{ params: scope ? { scope } : undefined },
) )
return response.data return response.data
}, },
/** Celery worker fleet diagnostics + recent task failures. Surfaced /** Celery worker fleet diagnostics + recent task failures. */
* in the Settings panel so users can debug stuck queues without workerStatus: async (scope?: 'global'): Promise<WorkerStatus> => {
* tailing container logs. */ const response = await api.get('/library/maintenance/worker-status', {
workerStatus: async (): Promise<WorkerStatus> => { params: scope ? { scope } : undefined,
const response = await api.get('/library/maintenance/worker-status') })
return response.data return response.data
}, },
/** Per-stage ingestion progress — thumbnails, EXIF, GPS, phash, /** Per-stage ingestion progress. */
* embeddings, object tags, OCR, faces, face clusters, duplicate pipelineStats: async (scope?: 'global'): Promise<PipelineStats> => {
* groups. Drives the Pipeline Progress card in Settings. */ const response = await api.get('/library/maintenance/pipeline-stats', {
pipelineStats: async (): Promise<PipelineStats> => { params: scope ? { scope } : undefined,
const response = await api.get('/library/maintenance/pipeline-stats') })
return response.data return response.data
}, },
@@ -477,8 +538,10 @@ export const library = {
/** Duplicate groups computed by app.services.duplicates.regroup_duplicates. /** Duplicate groups computed by app.services.duplicates.regroup_duplicates.
* Drives the grouped grid view in the Duplicates section. */ * Drives the grouped grid view in the Duplicates section. */
duplicates: { duplicates: {
groups: async (): Promise<DuplicateGroupsResponse> => { groups: async (scope?: 'global'): Promise<DuplicateGroupsResponse> => {
const response = await api.get('/library/duplicates/groups') const response = await api.get('/library/duplicates/groups', {
params: scope ? { scope } : undefined,
})
return response.data return response.data
}, },
}, },
@@ -725,4 +788,45 @@ export const discard = {
}, },
} }
// Admin API — user management (admin only)
export interface AdminUser {
id: string
username: string
email: string | null
role: 'admin' | 'user'
is_active: boolean
media_path: string
created_at: string | null
photo_count: number
}
export const admin = {
listUsers: async (): Promise<{ users: AdminUser[]; total: number }> => {
const response = await api.get('/admin/users')
return response.data
},
createUser: async (data: {
username: string
password: string
role: string
}): Promise<AdminUser> => {
const response = await api.post('/admin/users', data)
return response.data
},
updateUser: async (
userId: string,
data: { role?: string; is_active?: boolean; new_password?: string },
): Promise<AdminUser> => {
const response = await api.patch(`/admin/users/${userId}`, data)
return response.data
},
deleteUser: async (userId: string): Promise<{ status: string }> => {
const response = await api.delete(`/admin/users/${userId}`)
return response.data
},
}
export default api export default api