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

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