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

@@ -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")

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,9 +60,33 @@ 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,6 +41,7 @@ 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:

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

View File

@@ -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<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
const [sharingHeap, setSharingHeap] = useState<Heap | null>(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<string | null>(null)
@@ -435,6 +440,14 @@ export function HeapsPanel() {
setConvertingHeap(heap)
}}
/>
<MenuItem
icon={<Users className="h-3.5 w-3.5" />}
label="Share…"
onClick={() => {
setOpenMenuId(null)
setSharingHeap(heap)
}}
/>
<div className="my-1 h-px bg-border" />
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
@@ -460,10 +473,63 @@ export function HeapsPanel() {
</div>
)}
{/* Shared with me */}
{sharedHeaps.length > 0 && expanded && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-text-faint">
Shared with me
</div>
{sharedHeaps.map((sh) => {
const isFiltered = currentSection === `heap-${sh.id}`
return (
<div
key={sh.id}
className={clsx(
'group flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
style={{ paddingLeft: '20px' }}
onClick={() =>
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
}
>
<Users
className={clsx(
'h-3.5 w-3.5 flex-shrink-0',
isFiltered ? 'text-primary' : 'text-text-muted'
)}
/>
<span className="truncate" title={sh.name}>
{sh.name}
</span>
<span className="ml-0.5 truncate text-[10px] text-text-faint">
{sh.owner_username}
</span>
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
{sh.permission}
</span>
{sh.photo_count > 0 && (
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
{sh.photo_count}
</span>
)}
</div>
)
})}
</div>
)}
<HeapConvertDialog
heap={convertingHeap}
onClose={() => setConvertingHeap(null)}
/>
<ShareDialog
isOpen={!!sharingHeap}
type="heap"
targetId={sharingHeap?.id ?? ''}
targetName={sharingHeap?.name ?? ''}
onClose={() => setSharingHeap(null)}
/>
</div>
)
}

View File

@@ -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) {
})
}}
/>
<FolderMenuItem
icon={<Users className="h-3.5 w-3.5" />}
label="Share…"
onClick={() => {
setOpenMenuId(null)
setSharingFolder({ id: folderId, name: item.label })
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
@@ -803,6 +818,53 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
{/* Tree View */}
<div className="flex-1 overflow-y-auto pb-2">
{libraryTree.map((item) => renderTreeItem(item))}
{/* Shared with me — folders shared by other users */}
{sharedFolders.length > 0 && (
<div className="mt-1">
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
Shared with me
</div>
{sharedFolders.map((sf) => {
const isSelected = currentSection === `folder-${sf.id}`
return (
<div
key={sf.id}
className={clsx(
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
style={{ paddingLeft: '20px' }}
onClick={() =>
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
}
>
<Users
className={clsx(
'h-3.5 w-3.5 flex-shrink-0',
isSelected ? 'text-primary' : 'text-text-muted'
)}
/>
<span className="truncate" title={sf.name}>
{sf.name}
</span>
<span className="ml-0.5 truncate text-[10px] text-text-faint">
{sf.owner_username}
</span>
<span className="ml-auto rounded bg-surface-offset px-1 text-[9px] uppercase text-text-faint">
{sf.permission}
</span>
{sf.photo_count > 0 && (
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
{sf.photo_count}
</span>
)}
</div>
)
})}
</div>
)}
<HeapsPanel />
</div>
@@ -853,6 +915,13 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
}
}}
/>
<ShareDialog
isOpen={!!sharingFolder}
type="folder"
targetId={sharingFolder?.id ?? ''}
targetName={sharingFolder?.name ?? ''}
onClose={() => setSharingFolder(null)}
/>
</div>
)
}

View File

@@ -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<string | null>(null)
const queryClient = useQueryClient()
const sharesQueryKey = [type, 'shares', targetId]
const { data: shares = [], isLoading } = useQuery<ShareInfo[]>({
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 (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-text-muted" />
<h2 className="text-base font-semibold text-text">
Share {type === 'heap' ? 'heap' : 'folder'}
</h2>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text">
<X className="h-4 w-4" />
</button>
</div>
<div className="mb-3 text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span>
</div>
{/* Existing shares */}
{shares.length > 0 && (
<div className="mb-4 space-y-1.5">
{shares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between rounded border border-border bg-surface-2 px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium text-text">{share.shared_with_username}</span>
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
{share.permission}
</span>
</div>
<button
onClick={() => revokeMutation.mutate(share.id)}
className="text-text-muted hover:text-reject"
title="Revoke access"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{isLoading && <div className="mb-4 text-xs text-text-muted">Loading shares...</div>}
{/* Add new share */}
<form
onSubmit={(e) => {
e.preventDefault()
if (username.trim()) addMutation.mutate()
}}
className="space-y-3"
>
<div className="flex gap-2">
<input
type="text"
value={username}
onChange={(e) => {
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
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as 'read' | 'write')}
className="rounded border border-border bg-surface-2 px-2 py-1.5 text-sm text-text"
>
<option value="read">Read</option>
<option value="write">Read + Write</option>
</select>
</div>
{error && <div className="text-xs text-reject">{error}</div>}
<div className="flex justify-end">
<button
type="submit"
disabled={!username.trim() || addMutation.isPending}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
'bg-primary hover:bg-primary/80 disabled:opacity-50'
)}
>
{addMutation.isPending ? 'Sharing...' : 'Share'}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -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({
</div>
)}
{/* TL offset — owner badge for shared photos. Sits below the
* selection check so both can show simultaneously. */}
{photo.owner_username && (
<div
className={clsx(
'absolute left-1',
isSelected ? 'top-7' : 'top-1',
THUMB_BADGE_BASE,
THUMB_BADGE_NEUTRAL,
'max-w-[90px]'
)}
title={`Photo owned by ${photo.owner_username}`}
>
<Users className={THUMB_BADGE_ICON} strokeWidth={2.5} />
<span className="truncate">{photo.owner_username}</span>
</div>
)}
{/* 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) && (

View File

@@ -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<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
// Keep refresh token in memory only (not localStorage).
const refreshTokenRef = useRef<string | null>(null)
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<AuthContext.Provider
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { sharing, type SharedHeap, type SharedFolder } from '../services/api'
export const SHARED_HEAPS_KEY = ['sharing', 'heaps'] as const
export const SHARED_FOLDERS_KEY = ['sharing', 'folders'] as const
export function useSharedHeapsQuery() {
return useQuery<SharedHeap[]>({
queryKey: SHARED_HEAPS_KEY,
queryFn: sharing.sharedHeaps,
staleTime: 30_000,
})
}
export function useSharedFoldersQuery() {
return useQuery<SharedFolder[]>({
queryKey: SHARED_FOLDERS_KEY,
queryFn: sharing.sharedFolders,
staleTime: 30_000,
})
}

View File

@@ -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<ShareInfo[]> => {
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<SharedHeap[]> => {
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<ShareInfo[]> => {
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<SharedFolder[]> => {
const response = await api.get('/sharing/folders/shared-with-me')
return response.data
},
}
// Tags API
export type TagKind = 'user' | 'object' | 'scene' | 'face_cluster'

View File

@@ -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