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:
144
backend/alembic/versions/0009_users_and_auth.py
Normal file
144
backend/alembic/versions/0009_users_and_auth.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""users table and user_id foreign keys
|
||||
|
||||
Revision ID: 0009_users_and_auth
|
||||
Revises: 0008_photos_date_warning
|
||||
Create Date: 2026-04-12
|
||||
|
||||
Introduces multi-user support:
|
||||
1. Creates the `users` table.
|
||||
2. Adds `user_id` FK columns to photos, folders, source_roots, heaps, tags.
|
||||
3. For existing installs: creates a default admin user and assigns all
|
||||
existing rows to that user. The generated password is printed to the
|
||||
backend logs — the admin should change it on first login.
|
||||
4. Replaces the unique constraint on tags (name, kind) with
|
||||
(name, kind, user_id) so each user can have their own tags.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import uuid
|
||||
import secrets
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0009_users_and_auth"
|
||||
down_revision: Union[str, None] = "0008_photos_date_warning"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. Create users table (IF NOT EXISTS — safe on fresh installs where
|
||||
# init_db's create_all has already laid down the schema).
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR UNIQUE,
|
||||
hashed_password VARCHAR NOT NULL,
|
||||
role VARCHAR NOT NULL DEFAULT 'user',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
|
||||
media_path VARCHAR NOT NULL
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_username ON users (username)"
|
||||
))
|
||||
|
||||
# 2. Add user_id columns (nullable initially for the data migration)
|
||||
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
|
||||
conn.execute(sa.text(
|
||||
f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS user_id VARCHAR"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"
|
||||
))
|
||||
# FK — check if it already exists before adding
|
||||
fk_name = f"fk_{table}_user_id"
|
||||
fk_exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = :name AND table_name = :tbl"
|
||||
), {"name": fk_name, "tbl": table}).scalar()
|
||||
if not fk_exists:
|
||||
conn.execute(sa.text(
|
||||
f"ALTER TABLE {table} ADD CONSTRAINT {fk_name} "
|
||||
f"FOREIGN KEY (user_id) REFERENCES users(id)"
|
||||
))
|
||||
|
||||
# 3. Data migration: if rows exist, create a default admin and assign
|
||||
conn = op.get_bind()
|
||||
photo_count = conn.execute(sa.text("SELECT COUNT(*) FROM photos")).scalar()
|
||||
|
||||
if photo_count > 0:
|
||||
admin_id = str(uuid.uuid4())
|
||||
generated_password = secrets.token_urlsafe(16)
|
||||
|
||||
# Hash the password using passlib at migration time
|
||||
from passlib.context import CryptContext
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
hashed = pwd_context.hash(generated_password)
|
||||
|
||||
# The default admin's media_path is the legacy /photos root
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO users (id, username, hashed_password, role, media_path) "
|
||||
"VALUES (:id, :username, :hashed, :role, :media_path)"
|
||||
),
|
||||
{
|
||||
"id": admin_id,
|
||||
"username": "admin",
|
||||
"hashed": hashed,
|
||||
"role": "admin",
|
||||
"media_path": "/photos",
|
||||
},
|
||||
)
|
||||
|
||||
# Assign all existing rows to the default admin
|
||||
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
|
||||
conn.execute(
|
||||
sa.text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
|
||||
{"uid": admin_id},
|
||||
)
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("alembic.migration")
|
||||
logger.warning(
|
||||
f"=== MIGRATION 0009 === Default admin created. "
|
||||
f"Username: admin | Password: {generated_password} | "
|
||||
f"Change this password on first login!"
|
||||
)
|
||||
|
||||
# 4. Replace tag unique constraint to include user_id
|
||||
# Check whether the old constraint exists before trying to drop it
|
||||
# (on fresh installs create_all creates the new constraint directly).
|
||||
old_uq_exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = 'uq_tags_name_kind' AND table_name = 'tags'"
|
||||
)).scalar()
|
||||
if old_uq_exists:
|
||||
op.drop_constraint("uq_tags_name_kind", "tags", type_="unique")
|
||||
|
||||
new_uq_exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE constraint_name = 'uq_tags_name_kind_user' AND table_name = 'tags'"
|
||||
)).scalar()
|
||||
if not new_uq_exists:
|
||||
op.create_unique_constraint("uq_tags_name_kind_user", "tags", ["name", "kind", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse the tag constraint
|
||||
op.drop_constraint("uq_tags_name_kind_user", "tags", type_="unique")
|
||||
op.create_unique_constraint("uq_tags_name_kind", "tags", ["name", "kind"])
|
||||
|
||||
# Drop user_id columns and FKs
|
||||
for table in ("photos", "folders", "source_roots", "heaps", "tags"):
|
||||
op.drop_constraint(f"fk_{table}_user_id", table, type_="foreignkey")
|
||||
op.drop_index(f"ix_{table}_user_id", table_name=table)
|
||||
op.drop_column(table, "user_id")
|
||||
|
||||
# Drop users table
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.drop_table("users")
|
||||
47
backend/app/auth.py
Normal file
47
backend/app/auth.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Authentication utilities — password hashing and JWT token management.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(user_id: str, role: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and validate a JWT. Raises JWTError on any problem."""
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||
@@ -127,6 +127,17 @@ class Settings(BaseSettings):
|
||||
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
|
||||
# Auth — JWT signing key. Set SECRET_KEY in .env for production.
|
||||
# If unset, a deterministic fallback is used (acceptable for
|
||||
# single-machine homelab deploys, but set a real key if the instance
|
||||
# is network-exposed).
|
||||
secret_key: str = Field(
|
||||
default="mulita-dev-secret-change-me",
|
||||
env="SECRET_KEY",
|
||||
)
|
||||
access_token_expire_minutes: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES")
|
||||
refresh_token_expire_days: int = Field(default=30, env="REFRESH_TOKEN_EXPIRE_DAYS")
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
||||
|
||||
@@ -100,7 +100,7 @@ async def init_db():
|
||||
"""Initialize database, create tables if they don't exist"""
|
||||
async with engine.begin() as conn:
|
||||
# Import all models to register them with Base
|
||||
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
|
||||
from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
|
||||
|
||||
# Postgres: ensure pgvector is available before create_all touches
|
||||
# any Vector columns (added in later PRs but the extension is cheap
|
||||
|
||||
132
backend/app/dependencies.py
Normal file
132
backend/app/dependencies.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
FastAPI dependencies for authentication and user-scoped data access.
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.auth import decode_token
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import Folder, SourceRoot
|
||||
from app.models.heaps import Heap
|
||||
from app.models.tags import Tag
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Decode JWT, look up user, raise 401 if invalid or inactive."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(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:
|
||||
"""Raise 403 if user is not an admin."""
|
||||
if user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-scoped query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def user_photos_query(user: User):
|
||||
"""Base select for photos owned by user, with tags eager-loaded."""
|
||||
return (
|
||||
select(Photo)
|
||||
.options(selectinload(Photo.tags))
|
||||
.where(Photo.user_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
async def get_user_photo(
|
||||
photo_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Photo:
|
||||
"""Fetch a single photo by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Photo)
|
||||
.options(selectinload(Photo.tags))
|
||||
.where(Photo.id == photo_id, Photo.user_id == user.id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
if photo is None:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
return photo
|
||||
|
||||
|
||||
async def get_user_folder(
|
||||
folder_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Folder:
|
||||
"""Fetch a single folder by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
return folder
|
||||
|
||||
|
||||
async def get_user_heap(
|
||||
heap_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Heap:
|
||||
"""Fetch a single heap by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Heap).where(Heap.id == heap_id, Heap.user_id == user.id)
|
||||
)
|
||||
heap = result.scalar_one_or_none()
|
||||
if heap is None:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
return heap
|
||||
|
||||
|
||||
async def get_user_tag(
|
||||
tag_id: str,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Tag:
|
||||
"""Fetch a single tag by ID, scoped to the user. Raises 404."""
|
||||
result = await db.execute(
|
||||
select(Tag).where(Tag.id == tag_id, Tag.user_id == user.id)
|
||||
)
|
||||
tag = result.scalar_one_or_none()
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return tag
|
||||
@@ -11,7 +11,7 @@ import os
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.routers import photos, folders, heaps, tags, discard, library, search
|
||||
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin
|
||||
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
|
||||
from app.services.cleanup import cleanup_data_integrity
|
||||
|
||||
@@ -85,6 +85,8 @@ if os.path.exists("/data/thumbs"):
|
||||
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||||
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
||||
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Database models for Mulita
|
||||
"""
|
||||
from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import Folder, SourceRoot
|
||||
from app.models.tags import Tag, PhotoTag
|
||||
@@ -10,6 +11,7 @@ from app.models.ocr_text import OCRText
|
||||
from app.models.face_embedding import FaceEmbedding
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Photo',
|
||||
'Folder',
|
||||
'SourceRoot',
|
||||
|
||||
@@ -10,13 +10,16 @@ from app.database import Base
|
||||
|
||||
class SourceRoot(Base):
|
||||
__tablename__ = 'source_roots'
|
||||
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
path = Column(String, unique=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
added_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
folders = relationship("Folder", back_populates="source_root")
|
||||
|
||||
@@ -28,6 +31,9 @@ class Folder(Base):
|
||||
path = Column(String, unique=True, nullable=False)
|
||||
parent_id = Column(String, ForeignKey('folders.id'))
|
||||
source_root_id = Column(String, ForeignKey('source_roots.id'))
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
photo_count = Column(Integer, default=0)
|
||||
last_scanned = Column(DateTime)
|
||||
|
||||
|
||||
@@ -22,12 +22,15 @@ heap_photos = Table(
|
||||
|
||||
class Heap(Base):
|
||||
__tablename__ = 'heaps'
|
||||
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
is_active = Column(Boolean, default=False) # For active heap feature
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
|
||||
|
||||
@@ -14,6 +14,9 @@ class Photo(Base):
|
||||
# Primary key
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
|
||||
# File information
|
||||
filepath = Column(String, unique=True, nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
|
||||
@@ -30,13 +30,16 @@ photo_tags = Table(
|
||||
class Tag(Base):
|
||||
__tablename__ = 'tags'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('name', 'kind', name='uq_tags_name_kind'),
|
||||
UniqueConstraint('name', 'kind', 'user_id', name='uq_tags_name_kind_user'),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False, index=True)
|
||||
color = Column(String) # Hex color code for UI display
|
||||
|
||||
# Owner
|
||||
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||
|
||||
# Tag classification
|
||||
kind = Column(String, nullable=False, default='user', index=True)
|
||||
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
|
||||
|
||||
23
backend/app/models/user.py
Normal file
23
backend/app/models/user.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
User model definition
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
email = Column(String, unique=True, nullable=True)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
role = Column(String, nullable=False, default='user') # 'admin' | 'user'
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Absolute path to this user's photo directory (e.g., "/photos/daniel")
|
||||
media_path = Column(String, nullable=False)
|
||||
260
backend/app/routers/admin.py
Normal file
260
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Admin router — user management and app configuration.
|
||||
All endpoints require admin role.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models.user import User
|
||||
from app.models.photos import Photo
|
||||
from app.models.folders import SourceRoot
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user" # 'admin' | 'user'
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
new_password: Optional[str] = None
|
||||
|
||||
class UserDetailResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
media_path: str
|
||||
created_at: Optional[str]
|
||||
photo_count: int = 0
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: List[UserDetailResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/users", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users with their photo counts."""
|
||||
result = await db.execute(select(User).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
|
||||
user_list = []
|
||||
for u in users:
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == u.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
user_list.append(UserDetailResponse(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
media_path=u.media_path,
|
||||
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||
photo_count=photo_count,
|
||||
))
|
||||
|
||||
return UserListResponse(users=user_list, total=len(user_list))
|
||||
|
||||
|
||||
@router.post("/users", status_code=201, response_model=UserDetailResponse)
|
||||
async def create_user(
|
||||
body: CreateUserRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new user. Creates their media directory and source root."""
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
if len(body.username.strip()) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
|
||||
# Check for duplicate username
|
||||
existing = await db.execute(
|
||||
select(User).where(User.username == body.username.strip())
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="Username already taken")
|
||||
|
||||
media_path = os.path.join(settings.photo_dirs, body.username.strip())
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
hashed_password=hash_password(body.password),
|
||||
role=body.role,
|
||||
media_path=media_path,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # get user.id before creating source root
|
||||
|
||||
source_root = SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(source_root)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin '{admin.username}' created user '{user.username}' (role={user.role})")
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single user's details."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=photo_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UpdateUserRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a user's role, active status, or password."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if body.role is not None:
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
# Prevent demoting the last admin
|
||||
if user.role == "admin" and body.role == "user":
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
||||
user.role = body.role
|
||||
|
||||
if body.is_active is not None:
|
||||
# Prevent deactivating the last admin
|
||||
if user.role == "admin" and not body.is_active:
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate the last admin")
|
||||
user.is_active = body.is_active
|
||||
|
||||
if body.new_password is not None:
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
user.hashed_password = hash_password(body.new_password)
|
||||
|
||||
await db.commit()
|
||||
|
||||
count_result = await db.execute(
|
||||
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
|
||||
)
|
||||
photo_count = count_result.scalar() or 0
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
media_path=user.media_path,
|
||||
created_at=user.created_at.isoformat() if user.created_at else None,
|
||||
photo_count=photo_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soft-delete a user by deactivating them. Media is preserved."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# Prevent deleting the last admin
|
||||
if user.role == "admin":
|
||||
admin_count = (await db.execute(
|
||||
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
|
||||
)).scalar()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
|
||||
|
||||
user.is_active = False
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'")
|
||||
return {"status": "ok", "detail": f"User '{user.username}' deactivated"}
|
||||
200
backend/app/routers/auth.py
Normal file
200
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Authentication router — login, token refresh, profile, first-run setup.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.folders import SourceRoot
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: Optional[str]
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate with username + password, receive JWT tokens."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == body.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange a valid refresh token for a new access + refresh pair."""
|
||||
try:
|
||||
payload = decode_token(body.refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise ValueError("not a refresh token")
|
||||
user_id = payload["sub"]
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
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 HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or deactivated",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the authenticated user's profile."""
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
role=current_user.role,
|
||||
is_active=current_user.is_active,
|
||||
created_at=current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change the authenticated user's password."""
|
||||
if not verify_password(body.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
current_user.hashed_password = hash_password(body.new_password)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/setup", response_model=TokenResponse, status_code=201)
|
||||
async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""First-run only: create the initial admin account.
|
||||
|
||||
Returns 409 if any user already exists. This endpoint is
|
||||
unauthenticated by design — it can only run once.
|
||||
"""
|
||||
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||
if count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Setup already completed — users exist",
|
||||
)
|
||||
|
||||
if len(body.username.strip()) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
|
||||
media_path = os.path.join(settings.photo_dirs, body.username.strip())
|
||||
os.makedirs(media_path, exist_ok=True)
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
hashed_password=hash_password(body.password),
|
||||
role="admin",
|
||||
media_path=media_path,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# Create a source root for the new admin's media directory
|
||||
source_root = SourceRoot(
|
||||
name=f"{user.username}'s Library",
|
||||
path=media_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(source_root)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Initial admin account created: {user.username}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.role),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint: returns whether setup has been completed.
|
||||
|
||||
The frontend calls this to decide whether to show the setup page
|
||||
or the login page.
|
||||
"""
|
||||
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
||||
return {"setup_completed": count > 0}
|
||||
@@ -9,25 +9,27 @@ 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()
|
||||
|
||||
@router.get("")
|
||||
async def list_discarded(db: AsyncSession = Depends(get_db)):
|
||||
async def list_discarded(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""List discarded photos"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True)
|
||||
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return photos
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
|
||||
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Restore photos from the discard pile"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
|
||||
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id))
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
@@ -39,12 +41,12 @@ async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db
|
||||
return {"status": "success", "restored": len(photos)}
|
||||
|
||||
@router.delete("/empty")
|
||||
async def empty_discard(db: AsyncSession = Depends(get_db)):
|
||||
async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Permanently delete all discarded photos and unlink their files from
|
||||
disk. Failures on individual files are logged but don't abort the batch.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.is_discarded == True)
|
||||
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
return await _permanently_delete(db, photos)
|
||||
@@ -54,6 +56,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db)):
|
||||
async def delete_discarded(
|
||||
photo_ids: list[str] = Body(..., embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Permanently delete a specific subset of discarded photos. The photos
|
||||
must already be in the discard pile — non-discarded ids are skipped so
|
||||
@@ -63,7 +66,7 @@ async def delete_discarded(
|
||||
return {"status": "success", "deleted": 0, "file_errors": 0}
|
||||
result = await db.execute(
|
||||
select(Photo).where(
|
||||
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True)
|
||||
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
photos = result.scalars().all()
|
||||
|
||||
@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Folder, SourceRoot, Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_folder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,10 +50,10 @@ def _validate_folder_name(name: str) -> str:
|
||||
return name
|
||||
|
||||
@router.get("")
|
||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||
async def get_folders(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Get all source folders"""
|
||||
# Get source roots instead of regular folders
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id))
|
||||
source_roots = result.scalars().all()
|
||||
|
||||
folders_list = []
|
||||
@@ -73,7 +75,7 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||
return {"folders": folders_list}
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
async def get_folder_tree(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Recursive folder tree, one root per active SourceRoot. The tree
|
||||
starts at the Folder row matching the SourceRoot.path (the scanner
|
||||
creates one for every walked directory), with the SourceRoot's
|
||||
@@ -97,7 +99,7 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||
creates as a parent walk) are skipped via path-prefix filtering.
|
||||
"""
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||
select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id) # noqa: E712
|
||||
)
|
||||
source_roots = sr_result.scalars().all()
|
||||
|
||||
@@ -196,6 +198,7 @@ async def rename_folder(
|
||||
folder_id: str,
|
||||
body: FolderRename,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename a folder. Two cases:
|
||||
|
||||
@@ -212,7 +215,7 @@ async def rename_folder(
|
||||
|
||||
# Try SourceRoot first (display-only rename).
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
if source_root:
|
||||
@@ -225,14 +228,11 @@ async def rename_folder(
|
||||
}
|
||||
|
||||
# Otherwise it's a Folder row.
|
||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
# Refuse to rename the bare source root mount through here.
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
@@ -291,7 +291,7 @@ async def rename_folder(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Create a new sub-folder under an existing Folder. Mirrors the
|
||||
create to disk so the next scan sees it. Body: { name, parent_id }.
|
||||
parent_id MUST be an existing Folder row id (any descendant of a
|
||||
@@ -300,12 +300,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
name = _validate_folder_name(body.name)
|
||||
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(Folder.id == body.parent_id)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await get_user_folder(body.parent_id, current_user, db)
|
||||
|
||||
new_path = os.path.join(parent.path, name)
|
||||
if os.path.exists(new_path):
|
||||
@@ -323,6 +318,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
name=name,
|
||||
path=new_path,
|
||||
source_root_id=parent.source_root_id,
|
||||
user_id=current_user.id,
|
||||
photo_count=0,
|
||||
)
|
||||
db.add(new_folder)
|
||||
@@ -341,6 +337,7 @@ async def delete_folder(
|
||||
folder_id: str,
|
||||
mode: Literal['discard', 'permanent'] = Query('discard'),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a folder. Behavior depends on mode:
|
||||
|
||||
@@ -357,13 +354,10 @@ async def delete_folder(
|
||||
Refuses to delete the bare source-root mount in either mode (deleting
|
||||
the docker mount through the UI would be a footgun).
|
||||
"""
|
||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
sr_check = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
sr = sr_check.scalar_one_or_none()
|
||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
||||
@@ -484,6 +478,7 @@ async def set_folder_hidden(
|
||||
folder_id: str,
|
||||
body: FolderHide,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Toggle the "hide from views" flag on a folder or source root.
|
||||
|
||||
@@ -502,7 +497,7 @@ async def set_folder_hidden(
|
||||
"""
|
||||
# SourceRoot path — resolve to the Folder row at the mount point.
|
||||
sr_result = await db.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
|
||||
)
|
||||
source_root = sr_result.scalar_one_or_none()
|
||||
|
||||
@@ -511,6 +506,7 @@ async def set_folder_hidden(
|
||||
root_folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.source_root_id == source_root.id,
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.path == os.path.normpath(source_root.path),
|
||||
)
|
||||
)
|
||||
@@ -521,12 +517,7 @@ async def set_folder_hidden(
|
||||
detail="Source root has no indexed Folder row yet; scan first.",
|
||||
)
|
||||
else:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(Folder.id == folder_id)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
folder = await get_user_folder(folder_id, current_user, db)
|
||||
|
||||
folder.is_hidden = bool(body.hidden)
|
||||
await db.flush()
|
||||
@@ -547,11 +538,11 @@ async def set_folder_hidden(
|
||||
|
||||
|
||||
@router.post("/{folder_id}/scan")
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Trigger manual re-scan of source root folder"""
|
||||
from app.tasks.celery import celery_app
|
||||
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
|
||||
|
||||
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id))
|
||||
source_root = result.scalar_one_or_none()
|
||||
|
||||
if not source_root:
|
||||
|
||||
@@ -14,6 +14,8 @@ from app.database import get_db
|
||||
from app.models import Heap, Photo, Folder
|
||||
from app.models.folders import SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_heap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +50,10 @@ class HeapConvertBody(BaseModel):
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
async def list_heaps(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all heaps with photo counts."""
|
||||
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
||||
count_subq = (
|
||||
@@ -63,6 +68,7 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
stmt = (
|
||||
select(Heap, count_subq.c.photo_count)
|
||||
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
||||
.where(Heap.user_id == current_user.id)
|
||||
.order_by(Heap.created_at.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
@@ -82,12 +88,16 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_heap(
|
||||
body: HeapCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new heap."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap = Heap(name=name)
|
||||
heap = Heap(name=name, user_id=current_user.id)
|
||||
db.add(heap)
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
@@ -103,14 +113,14 @@ async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.patch("/{heap_id}")
|
||||
async def update_heap(
|
||||
heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db)
|
||||
heap_id: str,
|
||||
body: HeapUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename a heap and/or toggle active state. Setting is_active=true on
|
||||
one heap deactivates all others (single-active invariant)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
@@ -120,8 +130,12 @@ async def update_heap(
|
||||
|
||||
if body.is_active is not None:
|
||||
if body.is_active:
|
||||
# Clear active flag on all other heaps in one statement
|
||||
await db.execute(update(Heap).values(is_active=False))
|
||||
# Clear active flag on all other heaps for this user
|
||||
await db.execute(
|
||||
update(Heap)
|
||||
.where(Heap.user_id == current_user.id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
heap.is_active = True
|
||||
else:
|
||||
heap.is_active = False
|
||||
@@ -138,17 +152,18 @@ async def update_heap(
|
||||
|
||||
|
||||
@router.post("/{heap_id}/duplicate", status_code=201)
|
||||
async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def duplicate_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new heap with the same membership as an existing one. The
|
||||
new heap is named "{original} (copy)" and is never the active target —
|
||||
duplicating shouldn't quietly steal the user's T-key destination.
|
||||
"""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
source = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
new_heap = Heap(name=f"{source.name} (copy)", is_active=False)
|
||||
new_heap = Heap(name=f"{source.name} (copy)", is_active=False, user_id=current_user.id)
|
||||
db.add(new_heap)
|
||||
await db.flush() # populate new_heap.id without committing yet
|
||||
|
||||
@@ -178,24 +193,30 @@ async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/{heap_id}", status_code=204)
|
||||
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_heap(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||
rows in heap_photos cascade-delete."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
await db.delete(heap)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{heap_id}/photo_ids")
|
||||
async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def get_heap_photo_ids(
|
||||
heap_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return just the photo ids belonging to a heap. Used by the frontend
|
||||
to maintain a fast client-side membership lookup for the active heap
|
||||
(for the basket affordance on thumbnails) without fetching full photo
|
||||
records."""
|
||||
await get_user_heap(heap_id, current_user, db)
|
||||
result = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
@@ -204,14 +225,14 @@ async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("/{heap_id}/photos")
|
||||
async def add_photos_to_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
heap_id: str,
|
||||
body: HeapPhotosBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add photos to a heap. Idempotent: re-adding existing members is a
|
||||
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "added": 0}
|
||||
@@ -241,6 +262,7 @@ async def convert_heap_to_folder(
|
||||
heap_id: str,
|
||||
body: HeapConvertBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert a heap into a folder by moving (or copying) every member
|
||||
photo into the target directory. Optionally deletes the heap row at
|
||||
@@ -249,10 +271,7 @@ async def convert_heap_to_folder(
|
||||
target_id may be a Folder id or a SourceRoot id (matches the
|
||||
/photos/move convention so the same dropdown can populate it).
|
||||
"""
|
||||
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = heap_result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
heap = await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
# Resolve target_id → (target_dir, target_folder)
|
||||
sr_check = await db.execute(
|
||||
@@ -402,13 +421,13 @@ async def convert_heap_to_folder(
|
||||
|
||||
@router.delete("/{heap_id}/photos")
|
||||
async def remove_photos_from_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
heap_id: str,
|
||||
body: HeapPhotosBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove photos from a heap. Removing a non-member is a no-op."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
await get_user_heap(heap_id, current_user, db)
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "removed": 0}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models import Photo
|
||||
from app.services.search import hybrid_search
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,7 +24,7 @@ class SearchRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
|
||||
by default — the user never picks a mode.
|
||||
|
||||
@@ -47,7 +49,7 @@ async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db))
|
||||
|
||||
# Hydrate with photo data
|
||||
photo_ids = [r["photo_id"] for r in results]
|
||||
stmt = select(Photo).where(Photo.id.in_(photo_ids))
|
||||
stmt = select(Photo).where(Photo.id.in_(photo_ids), Photo.user_id == current_user.id)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
photo_map = {p.id: p for p in rows}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.models import Photo, Tag
|
||||
from app.models.tags import photo_tags
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -42,6 +44,7 @@ class TagMerge(BaseModel):
|
||||
async def list_tags(
|
||||
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all tags with their photo counts, optionally filtered by kind.
|
||||
|
||||
@@ -61,6 +64,7 @@ async def list_tags(
|
||||
photo_tags.join(Photo, Photo.id == photo_tags.c.photo_id)
|
||||
)
|
||||
.where(
|
||||
Photo.user_id == current_user.id,
|
||||
Photo.is_discarded.is_(False),
|
||||
Photo.is_hidden.is_(False),
|
||||
)
|
||||
@@ -70,6 +74,7 @@ async def list_tags(
|
||||
stmt = (
|
||||
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
|
||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||
.where(Tag.user_id == current_user.id)
|
||||
)
|
||||
if kind:
|
||||
stmt = stmt.where(Tag.kind == kind)
|
||||
@@ -93,7 +98,7 @@ async def list_tags(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Create a new tag. The (name, kind) pair is unique — re-creating an
|
||||
existing pair returns the existing row (idempotent for autocomplete)."""
|
||||
name = (body.name or "").strip()
|
||||
@@ -101,7 +106,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||
|
||||
existing = await db.execute(
|
||||
select(Tag).where(Tag.name == name, Tag.kind == body.kind)
|
||||
select(Tag).where(Tag.name == name, Tag.kind == body.kind, Tag.user_id == current_user.id)
|
||||
)
|
||||
found = existing.scalar_one_or_none()
|
||||
if found:
|
||||
@@ -110,7 +115,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
"kind": found.kind, "photo_count": 0,
|
||||
}
|
||||
|
||||
tag = Tag(name=name, color=body.color, kind=body.kind)
|
||||
tag = Tag(name=name, color=body.color, kind=body.kind, user_id=current_user.id)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
@@ -122,10 +127,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
async def update_tag(
|
||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
|
||||
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
@@ -145,7 +151,8 @@ async def update_tag(
|
||||
|
||||
@router.post("/{tag_id}/merge")
|
||||
async def merge_tag(
|
||||
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db)
|
||||
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Merge tag_id INTO target_id. All photo associations from the source
|
||||
tag are moved to the target, then the source tag is deleted.
|
||||
@@ -155,8 +162,8 @@ async def merge_tag(
|
||||
if tag_id == body.target_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
|
||||
|
||||
source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none()
|
||||
source = (await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Tag).where(Tag.id == body.target_id, Tag.user_id == current_user.id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source tag not found")
|
||||
if not target:
|
||||
@@ -189,9 +196,9 @@ async def merge_tag(
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Delete a tag. Photo associations cascade-delete via the FK."""
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""
|
||||
Scanner service for initial library scan and one-time bootstrap of the
|
||||
default source root on first boot.
|
||||
Scanner service for initial library scan and per-user source root bootstrap.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
@@ -8,44 +7,74 @@ 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__)
|
||||
|
||||
# The single host → container mount path. The compose file mounts whatever
|
||||
# the user set as PHOTO_DIRS at this path.
|
||||
DEFAULT_LIBRARY_PATH = "/photos"
|
||||
DEFAULT_LIBRARY_NAME = "Library"
|
||||
|
||||
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:
|
||||
"""If no source roots exist in the DB, create one pointing at the default
|
||||
library mount. Lets a fresh install pick up photos with zero
|
||||
configuration: the user only needs to set PHOTO_DIRS in .env.
|
||||
"""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.
|
||||
"""
|
||||
if not os.path.isdir(DEFAULT_LIBRARY_PATH):
|
||||
logger.warning(
|
||||
f"Default library path {DEFAULT_LIBRARY_PATH} is not mounted; "
|
||||
"set PHOTO_DIRS in .env and recreate the container."
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SourceRoot))
|
||||
if result.scalars().first() is not None:
|
||||
return # Already have at least one source root, leave it alone.
|
||||
return # Already have source roots.
|
||||
|
||||
source_root = SourceRoot(
|
||||
name=DEFAULT_LIBRARY_NAME,
|
||||
path=DEFAULT_LIBRARY_PATH,
|
||||
)
|
||||
session.add(source_root)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME} → "
|
||||
f"{DEFAULT_LIBRARY_PATH}"
|
||||
)
|
||||
# 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():
|
||||
|
||||
@@ -122,6 +122,13 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
if not source_root_id:
|
||||
source_root = await get_or_create_source_root(session, folder_path)
|
||||
source_root_id = source_root.id
|
||||
else:
|
||||
source_root = (await session.execute(
|
||||
select(SourceRoot).where(SourceRoot.id == source_root_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Inherit user_id from the source root's owner
|
||||
owner_user_id = source_root.user_id if source_root else None
|
||||
|
||||
# Per-scan memoization cache for "is this folder's effective
|
||||
# is_hidden true?" Populated on first lookup by walking the
|
||||
@@ -173,7 +180,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
# Get or create folder entry
|
||||
folder = await get_or_create_folder(session, root, source_root_id)
|
||||
folder = await get_or_create_folder(session, root, source_root_id, owner_user_id)
|
||||
progress_set(REDIS_KEY_CURRENT_FOLDER, root)
|
||||
|
||||
# Filter supported files
|
||||
@@ -237,6 +244,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
filepath=filepath,
|
||||
filename=filename,
|
||||
folder_id=folder.id,
|
||||
user_id=owner_user_id,
|
||||
file_hash=file_hash,
|
||||
media_type=get_media_type(filepath),
|
||||
original_format=Path(filepath).suffix.upper()[1:],
|
||||
@@ -342,7 +350,9 @@ async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceR
|
||||
return source_root
|
||||
|
||||
|
||||
async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder:
|
||||
async def get_or_create_folder(
|
||||
session: AsyncSession, path: str, source_root_id: str, user_id: str = None
|
||||
) -> Folder:
|
||||
"""Get or create a folder entry, matching by normalized path."""
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -364,7 +374,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
|
||||
parent_id = parent.id
|
||||
else:
|
||||
# Recursively create parent
|
||||
parent = await get_or_create_folder(session, parent_path, source_root_id)
|
||||
parent = await get_or_create_folder(session, parent_path, source_root_id, user_id)
|
||||
parent_id = parent.id
|
||||
else:
|
||||
parent_id = None
|
||||
@@ -374,6 +384,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
|
||||
path=norm,
|
||||
parent_id=parent_id,
|
||||
source_root_id=source_root_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
session.add(folder)
|
||||
await session.flush()
|
||||
|
||||
@@ -47,9 +47,16 @@ THUMB_SIZES = {
|
||||
'large': settings.thumbnails.large
|
||||
}
|
||||
|
||||
def get_thumb_path(photo_id: str, size: str) -> str:
|
||||
"""Get the path for a thumbnail file"""
|
||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||
def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
|
||||
"""Get the path for a thumbnail file.
|
||||
|
||||
When user_id is provided, thumbnails are stored under a user-specific
|
||||
subdirectory to enforce isolation between users.
|
||||
"""
|
||||
if user_id:
|
||||
thumb_dir = f"/data/thumbs/{user_id}/{photo_id}"
|
||||
else:
|
||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
|
||||
|
||||
@@ -302,7 +309,7 @@ async def _generate_thumbnails_async(photo_id: str, task):
|
||||
|
||||
# Generate thumbnails for each size
|
||||
for size_name, size_value in THUMB_SIZES.items():
|
||||
thumb_path = get_thumb_path(photo_id, size_name)
|
||||
thumb_path = get_thumb_path(photo_id, size_name, photo.user_id)
|
||||
generate_thumbnail(image, size_value, thumb_path)
|
||||
|
||||
# Update database with thumbnail path
|
||||
|
||||
54
backend/bootstrap.py
Normal file
54
backend/bootstrap.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Post-init_db bootstrap: run or stamp Alembic migrations.
|
||||
|
||||
On a FRESH Postgres install, init_db's create_all has already built the
|
||||
full schema from the current models. Running `alembic upgrade head` would
|
||||
fail because the older migrations try ADD COLUMN on columns that already
|
||||
exist. So we detect the fresh-install case (alembic_version table is
|
||||
missing or empty) and `stamp head` instead.
|
||||
|
||||
On an EXISTING install, the alembic_version table has a revision and
|
||||
`upgrade head` applies only the new deltas.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from sqlalchemy import create_engine, text, inspect
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def run():
|
||||
# Use a sync engine for this one-shot script.
|
||||
sync_url = settings.database_url.replace("+asyncpg", "").replace("+aiosqlite", "")
|
||||
engine = create_engine(sync_url)
|
||||
|
||||
with engine.connect() as conn:
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if "alembic_version" not in tables:
|
||||
# Fresh install — create_all built everything. Stamp head.
|
||||
print("Fresh install detected — stamping alembic head")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "stamp", "head"],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
row = conn.execute(text("SELECT version_num FROM alembic_version")).first()
|
||||
if row is None:
|
||||
print("Empty alembic_version — stamping head")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "stamp", "head"],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
print(f"Existing install at revision {row[0]} — running alembic upgrade head")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -54,6 +54,7 @@ aiofiles==23.2.1
|
||||
# Security and authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
|
||||
# Development
|
||||
pytest==7.4.4
|
||||
|
||||
Reference in New Issue
Block a user