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:
364
backend/app/routers/sharing.py
Normal file
364
backend/app/routers/sharing.py
Normal 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()
|
||||
Reference in New Issue
Block a user