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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user