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>
365 lines
12 KiB
Python
365 lines
12 KiB
Python
"""
|
|
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()
|