Files
mule-image/backend/app/services/scanner.py
dtoro 348e9c3585 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>
2026-04-12 21:47:43 +02:00

95 lines
3.1 KiB
Python

"""
Scanner service for initial library scan and per-user source root bootstrap.
"""
import os
import logging
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import SourceRoot
from app.models.user import User
from app.tasks.scan import scan_all_source_roots
from app.config import settings
logger = logging.getLogger(__name__)
async def bootstrap_user_source_root(user: User, session=None) -> None:
"""Create the media directory and a source root for a user.
Called when a new user is created (by the admin or the setup endpoint).
If the user already has a source root, this is a no-op.
"""
own_session = session is None
if own_session:
session = AsyncSessionLocal()
try:
# Check if user already has a source root
result = await session.execute(
select(SourceRoot).where(SourceRoot.user_id == user.id)
)
if result.scalar_one_or_none() is not None:
return
os.makedirs(user.media_path, exist_ok=True)
source_root = SourceRoot(
name=f"{user.username}'s Library",
path=user.media_path,
user_id=user.id,
)
session.add(source_root)
if own_session:
await session.commit()
else:
await session.flush()
logger.info(
f"Bootstrapped source root for user '{user.username}': "
f"{user.media_path}"
)
finally:
if own_session:
await session.close()
async def bootstrap_default_source_root() -> None:
"""Legacy bootstrap — for existing installs that have source roots
without user_id (pre-auth migration). On fresh installs, source roots
are created per-user via bootstrap_user_source_root. If there are
already source roots in the DB, this is a no-op.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(select(SourceRoot))
if result.scalars().first() is not None:
return # Already have source roots.
# No source roots and no users means fresh install — the setup
# endpoint will create the first user + source root.
user_count = (await session.execute(
select(User)
)).scalars().first()
if user_count is None:
logger.info(
"No users or source roots — waiting for first-run setup."
)
return
async def start_initial_scan():
"""Start the initial library scan.
NOTE: the folder watcher (watch_folders task) is intentionally NOT
dispatched here. It's an infinite loop celery task and every backend
restart was queuing a new instance, eventually pinning every worker
and starving scan_folder dispatches. Re-enabling it needs a Redis
lock or a dedicated long-running container — until then the user
triggers scans manually via "Scan all folders".
"""
try:
scan_all_source_roots.delay()
logger.info("Initial scan queued successfully")
except Exception as e:
logger.error(f"Failed to start initial scan: {e}")