253 lines
7.4 KiB
Python
253 lines
7.4 KiB
Python
"""Document sharing endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from pydantic import BaseModel, field_validator
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.models import Document, Share, User
|
|
from deps.auth import get_regular_user
|
|
from deps.db import get_db
|
|
from deps.utils import get_client_ip
|
|
from services.audit import write_audit_log
|
|
|
|
router = APIRouter(prefix="/api/shares", tags=["shares"])
|
|
|
|
|
|
class ShareCreate(BaseModel):
|
|
document_id: str
|
|
recipient_handle: str
|
|
permission: str = "view"
|
|
|
|
@field_validator("permission")
|
|
@classmethod
|
|
def validate_permission(cls, v: str) -> str:
|
|
if v not in {"view", "edit"}:
|
|
raise ValueError("permission must be 'view' or 'edit'")
|
|
return v
|
|
|
|
|
|
class SharePermissionPatch(BaseModel):
|
|
permission: str
|
|
|
|
@field_validator("permission")
|
|
@classmethod
|
|
def validate_permission(cls, v: str) -> str:
|
|
if v not in {"view", "edit"}:
|
|
raise ValueError("permission must be 'view' or 'edit'")
|
|
return v
|
|
|
|
|
|
def _parse_uuid(value: str, detail: str) -> uuid.UUID:
|
|
try:
|
|
return uuid.UUID(value)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail=detail)
|
|
|
|
|
|
async def _get_owned_document(
|
|
session: AsyncSession,
|
|
document_id: str,
|
|
owner_id: uuid.UUID,
|
|
) -> Document:
|
|
uid = _parse_uuid(document_id, "Document not found")
|
|
doc = await session.get(Document, uid)
|
|
if doc is None or doc.user_id != owner_id:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
return doc
|
|
|
|
|
|
async def _get_owned_share(
|
|
session: AsyncSession,
|
|
share_id: str,
|
|
owner_id: uuid.UUID,
|
|
) -> Share:
|
|
sid = _parse_uuid(share_id, "Share not found")
|
|
share = await session.get(Share, sid)
|
|
if share is None or share.owner_id != owner_id:
|
|
raise HTTPException(status_code=404, detail="Share not found")
|
|
return share
|
|
|
|
|
|
def _share_to_dict(share: Share, recipient: User) -> dict:
|
|
return {
|
|
"id": str(share.id),
|
|
"document_id": str(share.document_id),
|
|
"owner_id": str(share.owner_id),
|
|
"recipient_id": str(share.recipient_id),
|
|
"recipient_handle": recipient.handle,
|
|
"permission": share.permission,
|
|
"created_at": share.created_at.isoformat() if share.created_at else None,
|
|
}
|
|
|
|
|
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
async def grant_share(
|
|
body: ShareCreate,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
doc = await _get_owned_document(session, body.document_id, current_user.id)
|
|
|
|
# Recipient lookup by exact handle (D-04)
|
|
result = await session.execute(
|
|
select(User).where(User.handle == body.recipient_handle)
|
|
)
|
|
recipient = result.scalar_one_or_none()
|
|
if recipient is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Self-share prevention
|
|
if recipient.id == current_user.id:
|
|
raise HTTPException(status_code=400, detail="Cannot share with yourself")
|
|
|
|
# Create the share row
|
|
share = Share(
|
|
document_id=doc.id,
|
|
owner_id=current_user.id,
|
|
recipient_id=recipient.id,
|
|
permission=body.permission,
|
|
)
|
|
session.add(share)
|
|
|
|
try:
|
|
await session.flush()
|
|
except IntegrityError:
|
|
await session.rollback()
|
|
raise HTTPException(
|
|
status_code=409, detail="Document already shared with this user"
|
|
)
|
|
|
|
# Audit log — write within same transaction (D-14)
|
|
await write_audit_log(
|
|
session=session,
|
|
event_type="share.granted",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=doc.id,
|
|
ip_address=get_client_ip(request),
|
|
metadata_={"recipient_id": str(recipient.id)},
|
|
)
|
|
|
|
await session.commit()
|
|
|
|
return _share_to_dict(share, recipient)
|
|
|
|
|
|
@router.get("")
|
|
async def list_shares(
|
|
document_id: str = Query(...),
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
doc = await _get_owned_document(session, document_id, current_user.id)
|
|
|
|
stmt = (
|
|
select(Share, User)
|
|
.join(User, User.id == Share.recipient_id)
|
|
.where(Share.document_id == doc.id)
|
|
)
|
|
result = await session.execute(stmt)
|
|
items = [
|
|
{
|
|
"id": str(share.id),
|
|
"recipient_id": str(share.recipient_id),
|
|
"recipient_handle": recipient.handle,
|
|
"permission": share.permission,
|
|
"created_at": share.created_at.isoformat() if share.created_at else None,
|
|
}
|
|
for share, recipient in result.all()
|
|
]
|
|
|
|
return {"items": items}
|
|
|
|
|
|
# CRITICAL: This endpoint MUST be defined BEFORE DELETE /api/shares/{share_id}.
|
|
# Defining it after would cause FastAPI to route GET /api/shares/received as
|
|
# DELETE with share_id="received" (path parameter conflict).
|
|
|
|
|
|
@router.get("/received")
|
|
async def list_shared_with_me(
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
stmt = (
|
|
select(Document, User)
|
|
.join(Share, Share.document_id == Document.id)
|
|
.join(User, User.id == Share.owner_id)
|
|
.where(Share.recipient_id == current_user.id)
|
|
.order_by(Document.created_at.desc())
|
|
)
|
|
result = await session.execute(stmt)
|
|
rows = result.all()
|
|
|
|
items = [
|
|
{
|
|
"id": str(doc.id),
|
|
"filename": doc.filename,
|
|
"content_type": doc.content_type,
|
|
"size_bytes": doc.size_bytes,
|
|
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
|
"owner_handle": owner.handle,
|
|
}
|
|
for doc, owner in rows
|
|
]
|
|
|
|
return {"items": items}
|
|
|
|
|
|
@router.patch("/{share_id}", status_code=200)
|
|
async def update_share_permission(
|
|
share_id: str,
|
|
body: SharePermissionPatch,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
share = await _get_owned_share(session, share_id, current_user.id)
|
|
share.permission = body.permission
|
|
|
|
await write_audit_log(
|
|
session=session,
|
|
event_type="share.permission_changed",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=share.document_id,
|
|
ip_address=get_client_ip(request),
|
|
metadata_={"share_id": str(share.id), "new_permission": body.permission},
|
|
)
|
|
await session.commit()
|
|
|
|
return {"id": str(share.id), "permission": share.permission}
|
|
|
|
|
|
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def revoke_share(
|
|
share_id: str,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> None:
|
|
share = await _get_owned_share(session, share_id, current_user.id)
|
|
document_id = share.document_id
|
|
recipient_id = share.recipient_id
|
|
|
|
await session.delete(share)
|
|
|
|
await write_audit_log(
|
|
session=session,
|
|
event_type="share.revoked",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=document_id,
|
|
ip_address=get_client_ip(request),
|
|
metadata_={"recipient_id": str(recipient_id)},
|
|
)
|
|
|
|
await session.commit()
|