feat: share heaps and folders with other users, fix auth and vision pipeline

Sharing:
- New HeapShare and FolderShare models with read/write permissions
- Sharing API router (CRUD for heap and folder shares)
- Heap endpoints accept shared access (photo_ids, add/remove with write)
- Photo list drops user_id filter in shared context, adds owner_username
- Media serving (thumb/original/proxy) falls back to share check on 404
- ShareDialog component for managing shares from kebab menus
- HeapsPanel shows "Shared with me" section for shared heaps
- LeftSidebar shows "Shared with me" section for shared folders
- Owner badge on PhotoThumbnail for photos from other users

Auth:
- Access token default bumped to 1 year, refresh to 10 years
- Refresh token persisted in localStorage (survives page reload)
- Timer-based refresh replaced with 401 axios interceptor

Vision pipeline fixes:
- Bootstrap sets Redis ready key even on partial export failure
- Export functions run conditionally (only for actually missing models)
- _load_thumb handles multi-user path (/data/thumbs/{user_id}/{photo_id}/)
- can_access_photo_via_share uses single subquery instead of N+1 loop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-13 10:59:07 +02:00
parent f090a809a9
commit edd569d095
20 changed files with 1247 additions and 87 deletions

View File

@@ -141,8 +141,8 @@ class Settings(BaseSettings):
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")
access_token_expire_minutes: int = Field(default=525600, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 1 year
refresh_token_expire_days: int = Field(default=3650, env="REFRESH_TOKEN_EXPIRE_DAYS") # 10 years
@property
def cors_origins(self) -> list[str]:

View File

@@ -15,8 +15,9 @@ 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.heaps import Heap, heap_photos
from app.models.tags import Tag
from app.models.sharing import HeapShare, FolderShare
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
@@ -180,3 +181,175 @@ async def get_user_tag(
if tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
return tag
# ---------------------------------------------------------------------------
# Sharing helpers
# ---------------------------------------------------------------------------
async def get_user_or_shared_heap(
heap_id: str,
user: User,
db: AsyncSession,
) -> tuple:
"""Fetch a heap the user owns OR has a share for.
Returns ``(heap, permission)`` where *permission* is
``'owner'``, ``'read'``, or ``'write'``. Raises 404 if no access.
"""
# Fast path: owned by current user.
result = await db.execute(
select(Heap).where(Heap.id == heap_id, Heap.user_id == user.id)
)
heap = result.scalar_one_or_none()
if heap:
return heap, "owner"
# Shared path.
result = await db.execute(
select(HeapShare).where(
HeapShare.heap_id == heap_id,
HeapShare.shared_with_id == user.id,
)
)
share = result.scalar_one_or_none()
if share:
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if heap:
return heap, share.permission
raise HTTPException(status_code=404, detail="Heap not found")
async def get_user_or_shared_folder(
folder_id: str,
user: User,
db: AsyncSession,
) -> tuple:
"""Fetch a folder (or source root) the user owns OR has a share for.
Returns ``(entity, permission)`` where *entity* is a Folder or
SourceRoot and *permission* is ``'owner'``, ``'read'``, or ``'write'``.
"""
# Try owned folder first.
result = await db.execute(
select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id)
)
folder = result.scalar_one_or_none()
if folder:
return folder, "owner"
# Try owned source root.
result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == user.id)
)
sr = result.scalar_one_or_none()
if sr:
return sr, "owner"
# Shared path.
result = await db.execute(
select(FolderShare).where(
FolderShare.folder_id == folder_id,
FolderShare.shared_with_id == user.id,
)
)
share = result.scalar_one_or_none()
if share:
if share.folder_type == "source_root":
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
else:
result = await db.execute(select(Folder).where(Folder.id == folder_id))
entity = result.scalar_one_or_none()
if entity:
return entity, share.permission
raise HTTPException(status_code=404, detail="Folder not found")
async def resolve_username(
username: str,
db: AsyncSession,
) -> User:
"""Look up an active user by username. Raises 404 if not found."""
result = await db.execute(
select(User).where(User.username == username, User.is_active.is_(True))
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
async def can_access_photo_via_share(
photo_id: str,
user: User,
db: AsyncSession,
) -> bool:
"""Check whether *user* can access *photo_id* through any share.
Returns True if the photo belongs to a heap or folder that has been
shared with the user. Used as a fallback in media-serving endpoints
after the direct ownership check fails.
"""
import os
# Check heap shares: photo in any heap shared with user?
result = await db.execute(
select(heap_photos.c.photo_id).where(
heap_photos.c.photo_id == photo_id,
heap_photos.c.heap_id.in_(
select(HeapShare.heap_id).where(HeapShare.shared_with_id == user.id)
),
).limit(1)
)
if result.scalar_one_or_none() is not None:
return True
# Check folder shares: photo in any folder (or descendant) shared with user?
result = await db.execute(
select(Photo.folder_id).where(Photo.id == photo_id)
)
photo_folder_id = result.scalar_one_or_none()
if photo_folder_id is None:
return False
# Get the photo's folder path for prefix matching.
result = await db.execute(
select(Folder.path, Folder.source_root_id).where(Folder.id == photo_folder_id)
)
row = result.one_or_none()
if row is None:
return False
photo_path, photo_sr_id = row
# Check source root shares — photo's source root matches a shared root?
result = await db.execute(
select(FolderShare.folder_id).where(
FolderShare.shared_with_id == user.id,
FolderShare.folder_type == "source_root",
FolderShare.folder_id == photo_sr_id,
).limit(1)
)
if result.scalar_one_or_none() is not None:
return True
# Check folder shares — photo's folder is at or below a shared folder?
# Single query: join folder_shares → folders to get shared paths, then
# check if the photo's path starts with any of them.
result = await db.execute(
select(Folder.path).where(
Folder.id.in_(
select(FolderShare.folder_id).where(
FolderShare.shared_with_id == user.id,
FolderShare.folder_type == "folder",
)
)
)
)
for (shared_path,) in result.all():
if photo_path == shared_path or photo_path.startswith(shared_path + os.sep):
return True
return False

View File

@@ -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, auth, admin
from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing
from app.services.scanner import start_initial_scan, bootstrap_default_source_root
from app.services.cleanup import cleanup_data_integrity
@@ -87,6 +87,7 @@ if os.path.exists("/data/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(sharing.router, prefix="/api/v1", tags=["sharing"])
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"])

View File

@@ -9,6 +9,7 @@ from app.models.heaps import Heap, HeapPhoto
from app.models.embeddings import Embedding
from app.models.ocr_text import OCRText
from app.models.face_embedding import FaceEmbedding
from app.models.sharing import HeapShare, FolderShare
__all__ = [
'User',
@@ -22,4 +23,6 @@ __all__ = [
'Embedding',
'OCRText',
'FaceEmbedding',
'HeapShare',
'FolderShare',
]

View File

@@ -0,0 +1,52 @@
"""
Sharing models — cross-user access to heaps and folders.
HeapShare grants another user read or read+write access to a heap.
FolderShare does the same for a folder (or source root).
"""
import uuid
from sqlalchemy import (
Column, DateTime, ForeignKey, Index, String, UniqueConstraint, func,
)
from app.database import Base
class HeapShare(Base):
__tablename__ = "heap_shares"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
heap_id = Column(
String, ForeignKey("heaps.id", ondelete="CASCADE"), nullable=False,
)
# Denormalized from heap.user_id for fast "shares I own" lookups.
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint("heap_id", "shared_with_id", name="uq_heap_share"),
Index("ix_heap_shares_shared_with", "shared_with_id"),
Index("ix_heap_shares_heap_id", "heap_id"),
)
class FolderShare(Base):
__tablename__ = "folder_shares"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
# Can reference either a Folder.id or a SourceRoot.id.
folder_id = Column(String, nullable=False)
folder_type = Column(String, nullable=False, default="folder") # 'folder' | 'source_root'
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint("folder_id", "shared_with_id", name="uq_folder_share"),
Index("ix_folder_shares_shared_with", "shared_with_id"),
Index("ix_folder_shares_folder_id", "folder_id"),
)

View File

@@ -15,7 +15,7 @@ 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
from app.dependencies import get_current_user, get_user_heap, get_user_or_shared_heap
logger = logging.getLogger(__name__)
@@ -216,7 +216,7 @@ async def get_heap_photo_ids(
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)
await get_user_or_shared_heap(heap_id, current_user, db)
result = await db.execute(
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
)
@@ -231,8 +231,11 @@ async def add_photos_to_heap(
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)."""
await get_user_heap(heap_id, current_user, db)
no-op (handled by an INSERT OR IGNORE-style filter on duplicates).
Shared users with write permission can add their own photos."""
_heap, permission = await get_user_or_shared_heap(heap_id, current_user, db)
if permission == "read":
raise HTTPException(status_code=403, detail="Read-only access to this heap")
if not body.photo_ids:
return {"status": "success", "added": 0}
@@ -426,8 +429,11 @@ async def remove_photos_from_heap(
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."""
await get_user_heap(heap_id, current_user, db)
"""Remove photos from a heap. Removing a non-member is a no-op.
Shared users with write permission can remove photos."""
_heap, permission = await get_user_or_shared_heap(heap_id, current_user, db)
if permission == "read":
raise HTTPException(status_code=403, detail="Read-only access to this heap")
if not body.photo_ids:
return {"status": "success", "removed": 0}

View File

@@ -26,7 +26,11 @@ 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_current_user_media, get_user_photo
from app.dependencies import (
get_current_user, get_current_user_media, get_user_photo,
get_user_or_shared_heap, get_user_or_shared_folder,
can_access_photo_via_share,
)
from app.config import settings
router = APIRouter()
@@ -56,10 +60,34 @@ async def list_photos(
):
"""List photos with filters and pagination"""
# Determine if this request is for shared content. When viewing a
# shared heap or folder the user_id filter is replaced by the
# heap/folder join that already encodes authorization.
is_shared_context = False
if heap_id:
try:
_heap, _perm = await get_user_or_shared_heap(heap_id, current_user, db)
if _perm != "owner":
is_shared_context = True
except Exception:
raise HTTPException(status_code=404, detail="Heap not found")
if folder_id and not is_shared_context:
try:
_folder, _perm = await get_user_or_shared_folder(folder_id, current_user, db)
if _perm != "owner":
is_shared_context = True
except Exception:
raise HTTPException(status_code=404, detail="Folder not found")
# Build query — eager-load tags so the response can include them
# 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)
# without an N+1 round-trip per photo. Scoped to the current user
# unless we're in a shared context (scoped by heap/folder instead).
query = select(Photo).options(selectinload(Photo.tags))
if not is_shared_context:
query = query.where(Photo.user_id == current_user.id)
# Apply filters
filters = []
@@ -299,6 +327,17 @@ async def list_photos(
# Convert to response, attaching tags inline so the frontend can group
# client-side without a second round-trip.
# In shared context, resolve owner usernames for photos from other users.
owner_cache: dict[str, str] = {} # user_id → username
if is_shared_context:
other_user_ids = {p.user_id for p in photos if p.user_id != current_user.id}
if other_user_ids:
from app.models.user import User as UserModel
user_result = await db.execute(
select(UserModel.id, UserModel.username).where(UserModel.id.in_(other_user_ids))
)
owner_cache = {uid: uname for uid, uname in user_result.all()}
photo_dicts = []
for photo in photos:
d = PhotoResponse.from_orm(photo).dict()
@@ -306,6 +345,8 @@ async def list_photos(
{"id": t.id, "name": t.name, "color": t.color}
for t in (photo.tags or [])
]
if is_shared_context and photo.user_id != current_user.id:
d["owner_username"] = owner_cache.get(photo.user_id)
photo_dicts.append(d)
response = {
@@ -500,6 +541,33 @@ async def remove_photo_tag(
await db.commit()
return None
async def _get_photo_with_share_fallback(
photo_id: str, user: User, db: AsyncSession,
) -> Photo:
"""Fetch a photo the user owns, or one they can access via a share.
The fast path (owned photo) does a single indexed query. The share
fallback only runs when the first query returns nothing — this
happens only for shared photos, not during normal browsing.
"""
# Fast path — owned photo (single indexed query, no extra joins).
result = await db.execute(
select(Photo).where(Photo.id == photo_id, Photo.user_id == user.id)
)
photo = result.scalar_one_or_none()
if photo:
return photo
# Slow path — check share access (only for shared photos).
if await can_access_photo_via_share(photo_id, user, db):
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if photo:
return photo
raise HTTPException(status_code=404, detail="Photo not found")
@router.get("/{photo_id}/thumb/{size}")
async def get_thumbnail(
photo_id: str,
@@ -512,7 +580,7 @@ async def get_thumbnail(
if size not in ['small', 'medium', 'large']:
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
photo = await get_user_photo(photo_id, current_user, db)
photo = await _get_photo_with_share_fallback(photo_id, current_user, db)
# Check if thumbnail exists, generate if not.
# User-prefixed path for isolation.
@@ -596,7 +664,7 @@ async def get_original(
current_user: User = Depends(get_current_user_media),
):
"""Serve original file (download for RAW, inline for web-safe formats)"""
photo = await get_user_photo(photo_id, current_user, db)
photo = await _get_photo_with_share_fallback(photo_id, current_user, db)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found")
@@ -699,7 +767,7 @@ async def get_proxy(
current_user: User = Depends(get_current_user_media),
):
"""Serve a full-resolution WebP proxy for non-web-safe formats."""
photo = await get_user_photo(photo_id, current_user, db)
photo = await _get_photo_with_share_fallback(photo_id, current_user, db)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found")

View File

@@ -0,0 +1,364 @@
"""
Sharing API router — manage cross-user access to heaps and folders.
"""
import logging
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.heaps import Heap, heap_photos
from app.models.folders import Folder, SourceRoot
from app.models.photos import Photo
from app.models.sharing import HeapShare, FolderShare
from app.models.user import User
from app.dependencies import (
get_current_user,
get_user_heap,
get_user_folder,
resolve_username,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sharing", tags=["sharing"])
# ── Schemas ──────────────────────────────────────────────────────────────
class ShareCreate(BaseModel):
username: str
permission: Literal["read", "write"] = "read"
class ShareResponse(BaseModel):
id: str
shared_with_id: str
shared_with_username: str
permission: str
created_at: str
class SharedHeapResponse(BaseModel):
id: str
name: str
owner_username: str
permission: str
photo_count: int
class SharedFolderResponse(BaseModel):
id: str
name: str
folder_type: str
owner_username: str
permission: str
photo_count: int
# ── Heap sharing ─────────────────────────────────────────────────────────
@router.get("/heaps/shared-with-me")
async def list_shared_heaps(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all heaps that have been shared with the current user."""
result = await db.execute(
select(HeapShare, Heap, User)
.join(Heap, HeapShare.heap_id == Heap.id)
.join(User, HeapShare.owner_id == User.id)
.where(HeapShare.shared_with_id == current_user.id)
)
rows = result.all()
items = []
for share, heap, owner in rows:
# Count photos in this heap.
count_result = await db.execute(
select(func.count()).select_from(heap_photos).where(
heap_photos.c.heap_id == heap.id
)
)
count = count_result.scalar() or 0
items.append(SharedHeapResponse(
id=heap.id,
name=heap.name,
owner_username=owner.username,
permission=share.permission,
photo_count=count,
))
return items
@router.get("/heaps/{heap_id}")
async def list_heap_shares(
heap_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all shares for a heap (owner only)."""
heap = await get_user_heap(heap_id, current_user, db)
result = await db.execute(
select(HeapShare, User)
.join(User, HeapShare.shared_with_id == User.id)
.where(HeapShare.heap_id == heap.id)
)
return [
ShareResponse(
id=share.id,
shared_with_id=user.id,
shared_with_username=user.username,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
)
for share, user in result.all()
]
@router.post("/heaps/{heap_id}", status_code=201)
async def share_heap(
heap_id: str,
body: ShareCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Share a heap with another user (owner only)."""
heap = await get_user_heap(heap_id, current_user, db)
target_user = await resolve_username(body.username, db)
if target_user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot share with yourself")
# Check for existing share.
existing = await db.execute(
select(HeapShare).where(
HeapShare.heap_id == heap.id,
HeapShare.shared_with_id == target_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Already shared with this user")
share = HeapShare(
heap_id=heap.id,
owner_id=current_user.id,
shared_with_id=target_user.id,
permission=body.permission,
)
db.add(share)
await db.commit()
logger.info("Heap %s shared with %s (%s)", heap.name, target_user.username, body.permission)
return {"status": "shared", "share_id": share.id}
@router.delete("/heaps/{heap_id}/{share_id}", status_code=204)
async def revoke_heap_share(
heap_id: str,
share_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Revoke a heap share. The owner can revoke any share; a recipient
can revoke their own share (i.e. leave)."""
result = await db.execute(
select(HeapShare).where(HeapShare.id == share_id, HeapShare.heap_id == heap_id)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Share not found")
# Must be the owner or the recipient themselves.
if share.owner_id != current_user.id and share.shared_with_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
await db.delete(share)
await db.commit()
# ── Folder sharing ───────────────────────────────────────────────────────
@router.get("/folders/shared-with-me")
async def list_shared_folders(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all folders/source roots shared with the current user."""
result = await db.execute(
select(FolderShare, User)
.join(User, FolderShare.owner_id == User.id)
.where(FolderShare.shared_with_id == current_user.id)
)
rows = result.all()
items = []
for share, owner in rows:
# Resolve the folder/source root name and photo count.
if share.folder_type == "source_root":
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.id == share.folder_id)
)
entity = sr_result.scalar_one_or_none()
if not entity:
continue
name = entity.name
# Count all photos under this source root's folders.
count_result = await db.execute(
select(func.count()).select_from(Photo).where(
Photo.folder_id.in_(
select(Folder.id).where(Folder.source_root_id == entity.id)
),
Photo.is_discarded.is_(False),
)
)
else:
folder_result = await db.execute(
select(Folder).where(Folder.id == share.folder_id)
)
entity = folder_result.scalar_one_or_none()
if not entity:
continue
name = entity.name
import os
target_path = os.path.normpath(entity.path).rstrip(os.sep)
count_result = await db.execute(
select(func.count()).select_from(Photo).where(
Photo.folder_id.in_(
select(Folder.id).where(
(Folder.path == target_path)
| (Folder.path.like(target_path + os.sep + "%"))
)
),
Photo.is_discarded.is_(False),
)
)
count = count_result.scalar() or 0
items.append(SharedFolderResponse(
id=share.folder_id,
name=name,
folder_type=share.folder_type,
owner_username=owner.username,
permission=share.permission,
photo_count=count,
))
return items
@router.get("/folders/{folder_id}")
async def list_folder_shares(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all shares for a folder (owner only)."""
# Verify ownership — try folder then source root.
owned = False
result = await db.execute(
select(Folder).where(Folder.id == folder_id, Folder.user_id == current_user.id)
)
if result.scalar_one_or_none():
owned = True
else:
result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
)
if result.scalar_one_or_none():
owned = True
if not owned:
raise HTTPException(status_code=404, detail="Folder not found")
result = await db.execute(
select(FolderShare, User)
.join(User, FolderShare.shared_with_id == User.id)
.where(FolderShare.folder_id == folder_id)
)
return [
ShareResponse(
id=share.id,
shared_with_id=user.id,
shared_with_username=user.username,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
)
for share, user in result.all()
]
@router.post("/folders/{folder_id}", status_code=201)
async def share_folder(
folder_id: str,
body: ShareCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Share a folder or source root with another user (owner only)."""
# Determine folder_type and verify ownership.
folder_type = "folder"
result = await db.execute(
select(Folder).where(Folder.id == folder_id, Folder.user_id == current_user.id)
)
entity = result.scalar_one_or_none()
if entity is None:
result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
)
entity = result.scalar_one_or_none()
if entity is None:
raise HTTPException(status_code=404, detail="Folder not found")
folder_type = "source_root"
target_user = await resolve_username(body.username, db)
if target_user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot share with yourself")
existing = await db.execute(
select(FolderShare).where(
FolderShare.folder_id == folder_id,
FolderShare.shared_with_id == target_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Already shared with this user")
share = FolderShare(
folder_id=folder_id,
folder_type=folder_type,
owner_id=current_user.id,
shared_with_id=target_user.id,
permission=body.permission,
)
db.add(share)
await db.commit()
logger.info("Folder %s shared with %s (%s)", entity.name, target_user.username, body.permission)
return {"status": "shared", "share_id": share.id}
@router.delete("/folders/{folder_id}/{share_id}", status_code=204)
async def revoke_folder_share(
folder_id: str,
share_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Revoke a folder share (owner or self-remove)."""
result = await db.execute(
select(FolderShare).where(FolderShare.id == share_id, FolderShare.folder_id == folder_id)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Share not found")
if share.owner_id != current_user.id and share.shared_with_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
await db.delete(share)
await db.commit()

View File

@@ -41,8 +41,9 @@ class PhotoResponse(PhotoBase):
is_duplicate: bool = False
has_date_warning: bool = False
live_photo_video_id: Optional[str] = None
owner_username: Optional[str] = None
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
class Config:
orm_mode = True
from_attributes = True

View File

@@ -73,21 +73,31 @@ def bootstrap(models_dir: str | None = None):
for rel_path, desc in missing:
logger.warning(" %s%s", base / rel_path, desc)
try:
from app.services.vision import export_models
from app.services.vision import export_models
export_models.export_openclip(base)
export_models.export_siglip2(base)
export_models.export_yolov8n(base)
except Exception as e:
logger.error(
"Automatic export failed: %s. "
"Run `python -m app.services.vision.export_models "
"--models-dir %s` manually before starting the worker.",
e,
base,
)
return
missing_paths = {rel for rel, _ in missing}
# Only run the export functions whose outputs are actually missing.
# Each function is mapped to the file(s) it produces.
export_map = [
(export_models.export_openclip, ["embed/visual.onnx", "embed/textual.onnx"]),
(export_models.export_siglip2, ["embed_siglip2/visual.onnx", "embed_siglip2/textual.onnx"]),
(export_models.export_yolov8n, ["detect/yolov8n.onnx"]),
]
for export_fn, outputs in export_map:
if not any(o in missing_paths for o in outputs):
continue
try:
export_fn(base)
except Exception as e:
logger.error(
"Export step %s failed: %s. "
"Run `python -m app.services.vision.export_models "
"--models-dir %s` manually to retry.",
export_fn.__name__,
e,
base,
)
# Re-check what's still missing after the export pass.
still_missing = [

View File

@@ -53,11 +53,22 @@ def _get_sync_session() -> Session:
def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
"""Load a thumbnail as an RGB numpy array."""
thumb_path = Path(f"/data/thumbs/{photo_id}/{size}.webp")
"""Load a thumbnail as an RGB numpy array.
Thumbnails may live at ``/data/thumbs/{photo_id}/`` (legacy) or
``/data/thumbs/{user_id}/{photo_id}/`` (multi-user). Try both.
"""
thumb_base = Path("/data/thumbs")
# Try legacy flat path first.
thumb_path = thumb_base / photo_id / f"{size}.webp"
if not thumb_path.exists():
logger.warning("Thumbnail not found: %s", thumb_path)
return None
# Try user-prefixed paths: /data/thumbs/*/photo_id/size.webp
matches = list(thumb_base.glob(f"*/{photo_id}/{size}.webp"))
if matches:
thumb_path = matches[0]
else:
logger.warning("Thumbnail not found: %s", thumb_path)
return None
try:
img = Image.open(thumb_path).convert("RGB")
img.load() # force decode to catch corruption early