fix: dedicate watcher to own worker, fix media auth + memories nav

- Move watch_folders to dedicated 'watcher' queue with its own
  single-concurrency container so it never blocks scan/thumbnail slots
- Add get_current_user_media dependency that accepts ?token= query
  param for <img src> / <video src> media endpoints (thumb, original,
  proxy) — fixes 401 on thumbnails
- Append JWT token to all media URLs in the frontend
- Add missing 'memories' case in sidebar navigation switch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 23:34:40 +02:00
parent d693569f59
commit 35d87a2749
7 changed files with 101 additions and 10 deletions

View File

@@ -1,7 +1,9 @@
"""
FastAPI dependencies for authentication and user-scoped data access.
"""
from fastapi import Depends, HTTPException, status
from typing import Optional
from fastapi import Depends, HTTPException, Query, Request, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy import select
@@ -45,6 +47,54 @@ async def get_current_user(
return user
async def get_current_user_media(
request: Request,
token: Optional[str] = Query(None, alias="token"),
db: AsyncSession = Depends(get_db),
) -> User:
"""Authenticate via Authorization header OR ?token= query parameter.
Used for media endpoints (thumbnails, originals, proxies) where the
URL is set as an <img src> or <video src> and the browser can't
attach an Authorization header. The frontend appends ?token=JWT to
media URLs so they pass auth without custom fetch logic.
"""
# Try Authorization header first.
auth_header = request.headers.get("Authorization", "")
jwt_token = None
if auth_header.startswith("Bearer "):
jwt_token = auth_header[7:]
elif token:
jwt_token = token
if not jwt_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing token",
headers={"WWW-Authenticate": "Bearer"},
)
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(jwt_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:

View File

@@ -26,7 +26,7 @@ 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.dependencies import get_current_user, get_current_user_media, get_user_photo
from app.config import settings
router = APIRouter()
@@ -506,7 +506,7 @@ async def get_thumbnail(
size: str,
response: Response,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_media),
):
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
if size not in ['small', 'medium', 'large']:
@@ -593,7 +593,7 @@ async def get_thumbnail(
async def get_original(
photo_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_media),
):
"""Serve original file (download for RAW, inline for web-safe formats)"""
photo = await get_user_photo(photo_id, current_user, db)
@@ -696,7 +696,7 @@ async def get_proxy(
photo_id: str,
response: Response,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_media),
):
"""Serve a full-resolution WebP proxy for non-web-safe formats."""
photo = await get_user_photo(photo_id, current_user, db)

View File

@@ -29,6 +29,7 @@ celery_app.conf.update(
'extract_faces': {'queue': 'vision'},
'classify_content': {'queue': 'vision'},
'vision_fanout': {'queue': 'vision'},
'watch_folders': {'queue': 'watcher'},
},
task_default_queue='default',
task_default_exchange='default',