From edd569d0955c30d4067c0f47d5271a5772558d29 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Apr 2026 10:59:07 +0200 Subject: [PATCH] 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) --- backend/alembic/versions/0011_sharing.py | 54 +++ backend/app/config.py | 4 +- backend/app/dependencies.py | 175 ++++++++- backend/app/main.py | 3 +- backend/app/models/__init__.py | 3 + backend/app/models/sharing.py | 52 +++ backend/app/routers/heaps.py | 18 +- backend/app/routers/photos.py | 82 +++- backend/app/routers/sharing.py | 364 ++++++++++++++++++ backend/app/schemas/photos.py | 3 +- .../app/services/vision/bootstrap_models.py | 38 +- backend/app/tasks/vision.py | 19 +- frontend/src/components/heaps/HeapsPanel.tsx | 66 ++++ .../src/components/layout/LeftSidebar.tsx | 69 ++++ .../src/components/sharing/ShareDialog.tsx | 177 +++++++++ .../components/timeline/PhotoThumbnail.tsx | 19 + frontend/src/contexts/AuthContext.tsx | 102 ++--- frontend/src/hooks/useSharingQueries.ts | 21 + frontend/src/services/api.ts | 63 +++ frontend/src/types/photo.ts | 2 + 20 files changed, 1247 insertions(+), 87 deletions(-) create mode 100644 backend/alembic/versions/0011_sharing.py create mode 100644 backend/app/models/sharing.py create mode 100644 backend/app/routers/sharing.py create mode 100644 frontend/src/components/sharing/ShareDialog.tsx create mode 100644 frontend/src/hooks/useSharingQueries.ts diff --git a/backend/alembic/versions/0011_sharing.py b/backend/alembic/versions/0011_sharing.py new file mode 100644 index 0000000..c60ca16 --- /dev/null +++ b/backend/alembic/versions/0011_sharing.py @@ -0,0 +1,54 @@ +"""Add sharing tables for heaps and folders + +Revision ID: 0011_sharing +Revises: 0010_embeddings_768d +Create Date: 2026-04-13 + +Adds heap_shares and folder_shares tables so users can share +heaps and folders with other users (read or read+write). +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0011_sharing" +down_revision: Union[str, None] = "0010_embeddings_768d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS heap_shares ( + id VARCHAR NOT NULL PRIMARY KEY, + heap_id VARCHAR NOT NULL REFERENCES heaps(id) ON DELETE CASCADE, + owner_id VARCHAR NOT NULL REFERENCES users(id), + shared_with_id VARCHAR NOT NULL REFERENCES users(id), + permission VARCHAR NOT NULL DEFAULT 'read', + created_at TIMESTAMP DEFAULT now(), + CONSTRAINT uq_heap_share UNIQUE (heap_id, shared_with_id) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_heap_shares_shared_with ON heap_shares(shared_with_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_heap_shares_heap_id ON heap_shares(heap_id)") + + op.execute(""" + CREATE TABLE IF NOT EXISTS folder_shares ( + id VARCHAR NOT NULL PRIMARY KEY, + folder_id VARCHAR NOT NULL, + folder_type VARCHAR NOT NULL DEFAULT 'folder', + owner_id VARCHAR NOT NULL REFERENCES users(id), + shared_with_id VARCHAR NOT NULL REFERENCES users(id), + permission VARCHAR NOT NULL DEFAULT 'read', + created_at TIMESTAMP DEFAULT now(), + CONSTRAINT uq_folder_share UNIQUE (folder_id, shared_with_id) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_folder_shares_shared_with ON folder_shares(shared_with_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_folder_shares_folder_id ON folder_shares(folder_id)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS folder_shares") + op.execute("DROP TABLE IF EXISTS heap_shares") diff --git a/backend/app/config.py b/backend/app/config.py index 8a78858..3687364 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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]: diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 34d3fff..a2fab9c 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index cecc468..353edfe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 945cc70..d29e145 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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', ] \ No newline at end of file diff --git a/backend/app/models/sharing.py b/backend/app/models/sharing.py new file mode 100644 index 0000000..29164a8 --- /dev/null +++ b/backend/app/models/sharing.py @@ -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"), + ) diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py index ccd72ef..ca25718 100644 --- a/backend/app/routers/heaps.py +++ b/backend/app/routers/heaps.py @@ -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} diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 0643120..4c203de 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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") diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py new file mode 100644 index 0000000..1602b68 --- /dev/null +++ b/backend/app/routers/sharing.py @@ -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() diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py index fb00ff6..0ad9fbb 100644 --- a/backend/app/schemas/photos.py +++ b/backend/app/schemas/photos.py @@ -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 diff --git a/backend/app/services/vision/bootstrap_models.py b/backend/app/services/vision/bootstrap_models.py index 57f38e5..425eb98 100644 --- a/backend/app/services/vision/bootstrap_models.py +++ b/backend/app/services/vision/bootstrap_models.py @@ -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 = [ diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index b5edb83..412dfe4 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -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 diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index dc9cbc5..125f750 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -10,15 +10,18 @@ import { Pencil, Copy, Trash2, + Users, } from 'lucide-react' import clsx from 'clsx' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' +import { useSharedHeapsQuery } from '../../hooks/useSharingQueries' import { heaps as heapsApi, type Heap } from '../../services/api' import { useFilterStore } from '../../store/filterStore' import { toast } from '../ToastContainer' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { HeapConvertDialog } from './HeapConvertDialog' +import { ShareDialog } from '../sharing/ShareDialog' /** * Heaps panel for the left sidebar. Renders the list of heaps with the @@ -43,6 +46,8 @@ export function HeapsPanel() { // the drop highlight ring. Only one heap can be the target at a time. const [dropTargetId, setDropTargetId] = useState(null) const [convertingHeap, setConvertingHeap] = useState(null) + const [sharingHeap, setSharingHeap] = useState(null) + const { data: sharedHeaps = [] } = useSharedHeapsQuery() // Inline rename state for heap rows: stores the heap id being edited and // the draft name. Mirrors the folder rename pattern in LeftSidebar. const [renamingId, setRenamingId] = useState(null) @@ -435,6 +440,14 @@ export function HeapsPanel() { setConvertingHeap(heap) }} /> + } + label="Share…" + onClick={() => { + setOpenMenuId(null) + setSharingHeap(heap) + }} + />
} @@ -460,10 +473,63 @@ export function HeapsPanel() {
)} + {/* Shared with me */} + {sharedHeaps.length > 0 && expanded && ( +
+
+ Shared with me +
+ {sharedHeaps.map((sh) => { + const isFiltered = currentSection === `heap-${sh.id}` + return ( +
+ navigateToSection(`heap-${sh.id}`, { heapId: sh.id }) + } + > + + + {sh.name} + + + {sh.owner_username} + + + {sh.permission} + + {sh.photo_count > 0 && ( + + {sh.photo_count} + + )} +
+ ) + })} +
+ )} + setConvertingHeap(null)} /> + setSharingHeap(null)} + /> ) } diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 2086d89..c07ff4d 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -44,6 +44,8 @@ import { import { registerUndoable } from '../../store/undoStore' import type { Photo } from '../../types/photo' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' +import { ShareDialog } from '../sharing/ShareDialog' +import { useSharedFoldersQuery } from '../../hooks/useSharingQueries' import { useAuth } from '../../contexts/AuthContext' interface TreeItem { @@ -111,6 +113,11 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { name: string photoCount?: number } | null>(null) + const [sharingFolder, setSharingFolder] = useState<{ + id: string + name: string + } | null>(null) + const { data: sharedFolders = [] } = useSharedFoldersQuery() // Bulk discard mutation for the drag-onto-Discarded interaction. const discardDropMutation = useMutation({ @@ -703,6 +710,14 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { }) }} /> + } + label="Share…" + onClick={() => { + setOpenMenuId(null) + setSharingFolder({ id: folderId, name: item.label }) + }} + />
} @@ -803,6 +818,53 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { {/* Tree View */}
{libraryTree.map((item) => renderTreeItem(item))} + + {/* Shared with me — folders shared by other users */} + {sharedFolders.length > 0 && ( +
+
+ Shared with me +
+ {sharedFolders.map((sf) => { + const isSelected = currentSection === `folder-${sf.id}` + return ( +
+ navigateToSection(`folder-${sf.id}`, { folderId: sf.id }) + } + > + + + {sf.name} + + + {sf.owner_username} + + + {sf.permission} + + {sf.photo_count > 0 && ( + + {sf.photo_count} + + )} +
+ ) + })} +
+ )} +
@@ -853,6 +915,13 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { } }} /> + setSharingFolder(null)} + />
) } diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx new file mode 100644 index 0000000..b706ab8 --- /dev/null +++ b/frontend/src/components/sharing/ShareDialog.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Users, Trash2, X } from 'lucide-react' +import clsx from 'clsx' +import { sharing, type ShareInfo } from '../../services/api' +import { SHARED_HEAPS_KEY, SHARED_FOLDERS_KEY } from '../../hooks/useSharingQueries' + +interface ShareDialogProps { + isOpen: boolean + type: 'heap' | 'folder' + targetId: string + targetName: string + onClose: () => void +} + +export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: ShareDialogProps) { + const [username, setUsername] = useState('') + const [permission, setPermission] = useState<'read' | 'write'>('read') + const [error, setError] = useState(null) + const queryClient = useQueryClient() + + const sharesQueryKey = [type, 'shares', targetId] + + const { data: shares = [], isLoading } = useQuery({ + queryKey: sharesQueryKey, + queryFn: () => + type === 'heap' + ? sharing.heapShares(targetId) + : sharing.folderShares(targetId), + enabled: isOpen, + }) + + const addMutation = useMutation({ + mutationFn: () => + type === 'heap' + ? sharing.shareHeap(targetId, username, permission) + : sharing.shareFolder(targetId, username, permission), + onSuccess: () => { + setUsername('') + setPermission('read') + setError(null) + queryClient.invalidateQueries({ queryKey: sharesQueryKey }) + queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY }) + }, + onError: (err: any) => { + setError(err?.response?.data?.detail || 'Failed to share') + }, + }) + + const revokeMutation = useMutation({ + mutationFn: (shareId: string) => + type === 'heap' + ? sharing.revokeHeapShare(targetId, shareId) + : sharing.revokeFolderShare(targetId, shareId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: sharesQueryKey }) + queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY }) + }, + }) + + useEffect(() => { + if (!isOpen) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [isOpen, onClose]) + + useEffect(() => { + if (isOpen) { + setUsername('') + setPermission('read') + setError(null) + } + }, [isOpen]) + + if (!isOpen) return null + + return ( +
+
+
+
+
+
+ +

+ Share {type === 'heap' ? 'heap' : 'folder'} +

+
+ +
+ +
+ Sharing {targetName} +
+ + {/* Existing shares */} + {shares.length > 0 && ( +
+ {shares.map((share) => ( +
+
+ {share.shared_with_username} + + {share.permission} + +
+ +
+ ))} +
+ )} + {isLoading &&
Loading shares...
} + + {/* Add new share */} +
{ + e.preventDefault() + if (username.trim()) addMutation.mutate() + }} + className="space-y-3" + > +
+ { + setUsername(e.target.value) + setError(null) + }} + placeholder="Username" + className="flex-1 rounded border border-border bg-surface-2 px-3 py-1.5 text-sm text-text placeholder:text-text-muted focus:border-primary focus:outline-none" + autoFocus + /> + +
+ + {error &&
{error}
} + +
+ +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index c387fdd..a562429 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -7,6 +7,7 @@ import { Check, Copy, AlertTriangle, + Users, } from 'lucide-react' import clsx from 'clsx' import { photos as photosApi } from '../../services/api' @@ -328,6 +329,24 @@ export function PhotoThumbnail({
)} + {/* TL offset — owner badge for shared photos. Sits below the + * selection check so both can show simultaneously. */} + {photo.owner_username && ( +
+ + {photo.owner_username} +
+ )} + {/* BL — color label + rating. Color comes first (left of rating) * so the swatch reads as a "category dot" prefixing the stars. */} {(photo.color_label || photo.rating > 0) && ( diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 7828d12..5edc837 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useCallback, - useRef, type ReactNode, } from 'react' import api from '../services/api' @@ -47,8 +46,17 @@ function storeToken(token: string) { localStorage.setItem('access_token', token) } -function clearToken() { +function getStoredRefreshToken(): string | null { + return localStorage.getItem('refresh_token') +} + +function storeRefreshToken(token: string) { + localStorage.setItem('refresh_token', token) +} + +function clearTokens() { localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') } // ── Provider ─────────────────────────────────────────────────────────── @@ -57,48 +65,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) const [needsSetup, setNeedsSetup] = useState(false) - // Keep refresh token in memory only (not localStorage). - const refreshTokenRef = useRef(null) - const refreshTimerRef = useRef | null>(null) const isAdmin = user?.role === 'admin' - // Schedule a token refresh ~5 min before expiry. - const scheduleRefresh = useCallback((accessToken: string) => { - try { - const payload = JSON.parse(atob(accessToken.split('.')[1])) - const expiresAt = payload.exp * 1000 - const refreshIn = Math.max(expiresAt - Date.now() - 5 * 60 * 1000, 10_000) - - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) - refreshTimerRef.current = setTimeout(async () => { - if (!refreshTokenRef.current) return - try { - const res = await api.post('/auth/refresh', { - refresh_token: refreshTokenRef.current, - }) - const { access_token, refresh_token } = res.data - storeToken(access_token) - refreshTokenRef.current = refresh_token - scheduleRefresh(access_token) - } catch { - // Refresh failed — force re-login. - clearToken() - refreshTokenRef.current = null - setUser(null) - } - }, refreshIn) - } catch { - // Malformed token — ignore. - } - }, []) - const fetchMe = useCallback(async () => { try { const res = await api.get('/auth/me') setUser(res.data) } catch { - clearToken() + clearTokens() setUser(null) } }, []) @@ -120,46 +95,71 @@ export function AuthProvider({ children }: { children: ReactNode }) { const token = getStoredToken() if (token) { await fetchMe() - scheduleRefresh(token) } setIsLoading(false) })() - - return () => { - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) - } - }, [fetchMe, scheduleRefresh]) + }, [fetchMe]) const login = useCallback( async (username: string, password: string) => { const res = await api.post('/auth/login', { username, password }) const { access_token, refresh_token } = res.data storeToken(access_token) - refreshTokenRef.current = refresh_token - scheduleRefresh(access_token) + storeRefreshToken(refresh_token) await fetchMe() }, - [fetchMe, scheduleRefresh], + [fetchMe], ) const logout = useCallback(() => { - clearToken() - refreshTokenRef.current = null - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) + clearTokens() setUser(null) }, []) const onSetupComplete = useCallback( async (accessToken: string, refreshToken: string) => { storeToken(accessToken) - refreshTokenRef.current = refreshToken + storeRefreshToken(refreshToken) setNeedsSetup(false) - scheduleRefresh(accessToken) await fetchMe() }, - [fetchMe, scheduleRefresh], + [fetchMe], ) + // Axios interceptor: on 401, try to refresh once using the stored + // refresh token. If that fails, sign out. + useEffect(() => { + const interceptor = api.interceptors.response.use( + (res) => res, + async (error) => { + const original = error.config + if ( + error.response?.status === 401 && + !original._retry && + !original.url?.includes('/auth/') + ) { + original._retry = true + const rt = getStoredRefreshToken() + if (rt) { + try { + const res = await api.post('/auth/refresh', { refresh_token: rt }) + const { access_token, refresh_token } = res.data + storeToken(access_token) + storeRefreshToken(refresh_token) + original.headers['Authorization'] = `Bearer ${access_token}` + return api(original) + } catch { + clearTokens() + setUser(null) + } + } + } + return Promise.reject(error) + }, + ) + return () => api.interceptors.response.eject(interceptor) + }, []) + return ( ({ + queryKey: SHARED_HEAPS_KEY, + queryFn: sharing.sharedHeaps, + staleTime: 30_000, + }) +} + +export function useSharedFoldersQuery() { + return useQuery({ + queryKey: SHARED_FOLDERS_KEY, + queryFn: sharing.sharedFolders, + staleTime: 30_000, + }) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2e62624..5c237f6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -713,6 +713,69 @@ export const heaps = { }, } +// Sharing API + +export interface SharedHeap { + id: string + name: string + owner_username: string + permission: 'read' | 'write' + photo_count: number +} + +export interface SharedFolder { + id: string + name: string + folder_type: 'folder' | 'source_root' + owner_username: string + permission: 'read' | 'write' + photo_count: number +} + +export interface ShareInfo { + id: string + shared_with_id: string + shared_with_username: string + permission: string + created_at: string +} + +export const sharing = { + // Heap shares + shareHeap: async (heapId: string, username: string, permission: 'read' | 'write' = 'read') => { + const response = await api.post(`/sharing/heaps/${heapId}`, { username, permission }) + return response.data + }, + heapShares: async (heapId: string): Promise => { + const response = await api.get(`/sharing/heaps/${heapId}`) + return response.data + }, + revokeHeapShare: async (heapId: string, shareId: string) => { + await api.delete(`/sharing/heaps/${heapId}/${shareId}`) + }, + sharedHeaps: async (): Promise => { + const response = await api.get('/sharing/heaps/shared-with-me') + return response.data + }, + + // Folder shares + shareFolder: async (folderId: string, username: string, permission: 'read' | 'write' = 'read') => { + const response = await api.post(`/sharing/folders/${folderId}`, { username, permission }) + return response.data + }, + folderShares: async (folderId: string): Promise => { + const response = await api.get(`/sharing/folders/${folderId}`) + return response.data + }, + revokeFolderShare: async (folderId: string, shareId: string) => { + await api.delete(`/sharing/folders/${folderId}/${shareId}`) + }, + sharedFolders: async (): Promise => { + const response = await api.get('/sharing/folders/shared-with-me') + return response.data + }, +} + // Tags API export type TagKind = 'user' | 'object' | 'scene' | 'face_cluster' diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index 4ceb705..e65c4db 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -27,6 +27,8 @@ export interface Photo { latitude?: number | null longitude?: number | null tags?: PhotoTagSummary[] + /** Present when viewing shared content — the username of the photo owner. */ + owner_username?: string | null } /** Minimal payload returned by GET /api/v1/photos/map — only what the