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