Adds optional SSO via Authentik (or any OIDC provider) alongside the existing password flow, and pulls profile images from the provider's `picture` claim or Gravatar so the sharing UI stops looking anonymous. Password login stays available as a recovery path; JIT provisioning and admin-group mapping are env-configurable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
614 lines
21 KiB
Python
614 lines
21 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,
|
|
)
|
|
from app.services.gravatar import gravatar_url
|
|
|
|
|
|
def _user_avatar(user: User) -> Optional[str]:
|
|
"""OIDC `picture` claim wins, Gravatar fills the gap. Returns None
|
|
when neither source can produce a URL so the frontend can fall back
|
|
to the initials bubble."""
|
|
return user.avatar_url or gravatar_url(user.email)
|
|
|
|
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
|
|
shared_with_avatar_url: Optional[str] = None
|
|
shared_with_display_name: Optional[str] = None
|
|
permission: str
|
|
status: str # 'pending' | 'accepted'
|
|
created_at: str
|
|
|
|
|
|
class SharedHeapResponse(BaseModel):
|
|
# `id` is the heap id (used for navigation). `share_id` is the
|
|
# heap_shares row id, needed so the recipient can "Leave" via the
|
|
# existing DELETE endpoint without a separate lookup.
|
|
id: str
|
|
share_id: str
|
|
name: str
|
|
owner_username: str
|
|
owner_avatar_url: Optional[str] = None
|
|
owner_display_name: Optional[str] = None
|
|
permission: str
|
|
photo_count: int
|
|
|
|
|
|
class SharedFolderResponse(BaseModel):
|
|
id: str
|
|
share_id: str
|
|
name: str
|
|
folder_type: str
|
|
owner_username: str
|
|
owner_avatar_url: Optional[str] = None
|
|
owner_display_name: Optional[str] = None
|
|
permission: str
|
|
photo_count: int
|
|
|
|
|
|
class PendingInvite(BaseModel):
|
|
"""A share that exists in the DB but hasn't been accepted yet. Powers
|
|
the notification bell in the left-sidebar user section."""
|
|
share_id: str
|
|
target_id: str # heap id or folder id
|
|
target_name: str
|
|
owner_username: str
|
|
owner_avatar_url: Optional[str] = None
|
|
owner_display_name: Optional[str] = None
|
|
permission: str
|
|
created_at: str
|
|
|
|
|
|
class PendingInvitesResponse(BaseModel):
|
|
heaps: list[PendingInvite]
|
|
folders: list[PendingInvite]
|
|
|
|
|
|
class ShareableUser(BaseModel):
|
|
id: str
|
|
username: str
|
|
avatar_url: Optional[str] = None
|
|
display_name: Optional[str] = None
|
|
|
|
|
|
# ── Shareable users ──────────────────────────────────────────────────────
|
|
|
|
@router.get("/users", response_model=list[ShareableUser])
|
|
async def list_shareable_users(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""List every active user other than the caller, for the share-picker
|
|
dropdown. Sharing only requires knowing a username today, so surfacing
|
|
the list is no wider an attack surface than the free-text input it
|
|
replaces. Inactive accounts are filtered out."""
|
|
result = await db.execute(
|
|
select(User)
|
|
.where(User.id != current_user.id)
|
|
.where(User.is_active.is_(True))
|
|
.order_by(User.username)
|
|
)
|
|
return [
|
|
ShareableUser(
|
|
id=str(u.id),
|
|
username=u.username,
|
|
avatar_url=_user_avatar(u),
|
|
display_name=u.display_name,
|
|
)
|
|
for u in result.scalars().all()
|
|
]
|
|
|
|
|
|
# ── Pending invites (recipient-facing, cross-type) ───────────────────────
|
|
|
|
@router.get("/pending", response_model=PendingInvitesResponse)
|
|
async def list_pending_invites(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Every share targeting the current user that's still waiting on
|
|
them to accept. Feeds the notification bell in the sidebar."""
|
|
heap_rows = (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)
|
|
.where(HeapShare.status == "pending")
|
|
)).all()
|
|
|
|
heaps = [
|
|
PendingInvite(
|
|
share_id=share.id,
|
|
target_id=heap.id,
|
|
target_name=heap.name,
|
|
owner_username=owner.username,
|
|
owner_avatar_url=_user_avatar(owner),
|
|
owner_display_name=owner.display_name,
|
|
permission=share.permission,
|
|
created_at=share.created_at.isoformat() if share.created_at else "",
|
|
)
|
|
for share, heap, owner in heap_rows
|
|
]
|
|
|
|
folder_rows = (await db.execute(
|
|
select(FolderShare, User)
|
|
.join(User, FolderShare.owner_id == User.id)
|
|
.where(FolderShare.shared_with_id == current_user.id)
|
|
.where(FolderShare.status == "pending")
|
|
)).all()
|
|
|
|
folders: list[PendingInvite] = []
|
|
for share, owner in folder_rows:
|
|
if share.folder_type == "source_root":
|
|
entity = (await db.execute(
|
|
select(SourceRoot).where(SourceRoot.id == share.folder_id)
|
|
)).scalar_one_or_none()
|
|
else:
|
|
entity = (await db.execute(
|
|
select(Folder).where(Folder.id == share.folder_id)
|
|
)).scalar_one_or_none()
|
|
# If the underlying folder was deleted while an invite was
|
|
# still pending, just skip — the share is effectively orphaned
|
|
# and the owner's revoke path will clean it up.
|
|
if entity is None:
|
|
continue
|
|
folders.append(PendingInvite(
|
|
share_id=share.id,
|
|
target_id=share.folder_id,
|
|
target_name=entity.name,
|
|
owner_username=owner.username,
|
|
owner_avatar_url=_user_avatar(owner),
|
|
owner_display_name=owner.display_name,
|
|
permission=share.permission,
|
|
created_at=share.created_at.isoformat() if share.created_at else "",
|
|
))
|
|
|
|
return PendingInvitesResponse(heaps=heaps, folders=folders)
|
|
|
|
|
|
# ── 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 accepted heap shares for the current user. Pending
|
|
invites are hidden here and surfaced via /sharing/pending instead."""
|
|
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)
|
|
.where(HeapShare.status == "accepted")
|
|
)
|
|
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,
|
|
share_id=share.id,
|
|
name=heap.name,
|
|
owner_username=owner.username,
|
|
owner_avatar_url=_user_avatar(owner),
|
|
owner_display_name=owner.display_name,
|
|
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,
|
|
shared_with_avatar_url=_user_avatar(user),
|
|
shared_with_display_name=user.display_name,
|
|
permission=share.permission,
|
|
status=share.status,
|
|
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.post("/heaps/{heap_id}/accept", status_code=200)
|
|
async def accept_heap_share(
|
|
heap_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Recipient accepts a pending heap invite. Idempotent — if the
|
|
share is already accepted, returns 200 anyway so double-clicks in
|
|
the notification popover are harmless."""
|
|
result = await db.execute(
|
|
select(HeapShare).where(
|
|
HeapShare.heap_id == heap_id,
|
|
HeapShare.shared_with_id == current_user.id,
|
|
)
|
|
)
|
|
share = result.scalar_one_or_none()
|
|
if share is None:
|
|
raise HTTPException(status_code=404, detail="Invite not found")
|
|
if share.status != "accepted":
|
|
share.status = "accepted"
|
|
share.accepted_at = func.now()
|
|
await db.commit()
|
|
return {"status": "accepted"}
|
|
|
|
|
|
@router.post("/heaps/{heap_id}/decline", status_code=200)
|
|
async def decline_heap_share(
|
|
heap_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Recipient declines a pending heap invite. The share row is
|
|
deleted — there's no separate 'declined' status. A re-invite just
|
|
creates a fresh pending row."""
|
|
result = await db.execute(
|
|
select(HeapShare).where(
|
|
HeapShare.heap_id == heap_id,
|
|
HeapShare.shared_with_id == current_user.id,
|
|
)
|
|
)
|
|
share = result.scalar_one_or_none()
|
|
if share is None:
|
|
raise HTTPException(status_code=404, detail="Invite not found")
|
|
await db.delete(share)
|
|
await db.commit()
|
|
return {"status": "declined"}
|
|
|
|
|
|
@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 accepted folder/source-root shares for the current
|
|
user. Pending invites are hidden here and surfaced via
|
|
/sharing/pending instead."""
|
|
result = await db.execute(
|
|
select(FolderShare, User)
|
|
.join(User, FolderShare.owner_id == User.id)
|
|
.where(FolderShare.shared_with_id == current_user.id)
|
|
.where(FolderShare.status == "accepted")
|
|
)
|
|
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,
|
|
share_id=share.id,
|
|
name=name,
|
|
folder_type=share.folder_type,
|
|
owner_username=owner.username,
|
|
owner_avatar_url=_user_avatar(owner),
|
|
owner_display_name=owner.display_name,
|
|
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,
|
|
shared_with_avatar_url=_user_avatar(user),
|
|
shared_with_display_name=user.display_name,
|
|
permission=share.permission,
|
|
status=share.status,
|
|
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.post("/folders/{folder_id}/accept", status_code=200)
|
|
async def accept_folder_share(
|
|
folder_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Recipient accepts a pending folder invite. Idempotent."""
|
|
result = await db.execute(
|
|
select(FolderShare).where(
|
|
FolderShare.folder_id == folder_id,
|
|
FolderShare.shared_with_id == current_user.id,
|
|
)
|
|
)
|
|
share = result.scalar_one_or_none()
|
|
if share is None:
|
|
raise HTTPException(status_code=404, detail="Invite not found")
|
|
if share.status != "accepted":
|
|
share.status = "accepted"
|
|
share.accepted_at = func.now()
|
|
await db.commit()
|
|
return {"status": "accepted"}
|
|
|
|
|
|
@router.post("/folders/{folder_id}/decline", status_code=200)
|
|
async def decline_folder_share(
|
|
folder_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Recipient declines a pending folder invite. Row is deleted."""
|
|
result = await db.execute(
|
|
select(FolderShare).where(
|
|
FolderShare.folder_id == folder_id,
|
|
FolderShare.shared_with_id == current_user.id,
|
|
)
|
|
)
|
|
share = result.scalar_one_or_none()
|
|
if share is None:
|
|
raise HTTPException(status_code=404, detail="Invite not found")
|
|
await db.delete(share)
|
|
await db.commit()
|
|
return {"status": "declined"}
|
|
|
|
|
|
@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()
|