Merge pull request #793 from christianlouis/copilot/add-sharing-and-rights-management
feat(sharing): File sharing and role-based access control for multi-user mode
This commit is contained in:
@@ -46,6 +46,7 @@ from app.api.sessions import router as sessions_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.shared_links import public_router as shared_links_public_router
|
||||
from app.api.shared_links import router as shared_links_router
|
||||
from app.api.sharing import router as sharing_router
|
||||
from app.api.similarity import router as similarity_router
|
||||
from app.api.subscriptions import router as subscriptions_router
|
||||
from app.api.system_reset import router as system_reset_router
|
||||
@@ -110,3 +111,4 @@ router.include_router(translation_router)
|
||||
router.include_router(classification_rules_router)
|
||||
router.include_router(automation_router)
|
||||
router.include_router(comments_router)
|
||||
router.include_router(sharing_router)
|
||||
|
||||
+80
-3
@@ -15,7 +15,15 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user_id, require_login
|
||||
from app.database import get_db
|
||||
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
|
||||
from app.models import (
|
||||
FILE_SHARE_ROLE_VIEWER,
|
||||
DocumentAnnotation,
|
||||
DocumentComment,
|
||||
FileRecord,
|
||||
FileShare,
|
||||
UserProfile,
|
||||
)
|
||||
from app.utils.user_scope import get_current_owner_id, has_file_role
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -141,7 +149,7 @@ def list_comments(request: Request, file_id: int, db: DbSession):
|
||||
"""List all comments for a document, organized into threads.
|
||||
|
||||
Returns a threaded tree where top-level comments contain nested
|
||||
``replies``.
|
||||
``replies``. Requires at least viewer access.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
@@ -149,10 +157,17 @@ def list_comments(request: Request, file_id: int, db: DbSession):
|
||||
Returns:
|
||||
A dict with ``file_id``, ``comments`` (threaded), and ``total``.
|
||||
"""
|
||||
user_id = get_current_owner_id(request)
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
comments = (
|
||||
db.query(DocumentComment).filter(DocumentComment.file_id == file_id).order_by(DocumentComment.created_at).all()
|
||||
)
|
||||
@@ -176,7 +191,10 @@ def create_comment(
|
||||
"""Create a new comment on a document.
|
||||
|
||||
Automatically extracts @mentions from the comment body and stores
|
||||
them for later notification or UI highlighting.
|
||||
them for later notification or UI highlighting. When multi-user
|
||||
mode is enabled, any mentioned user that does not already have
|
||||
access to the document is automatically granted ``viewer`` access by
|
||||
the file owner so they can read the file and continue the discussion.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document to comment on.
|
||||
@@ -189,11 +207,17 @@ def create_comment(
|
||||
The created comment object.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
owner_id = get_current_owner_id(request)
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -230,8 +254,46 @@ def create_comment(
|
||||
|
||||
try:
|
||||
db.add(comment)
|
||||
db.flush() # write comment so we can get its id before committing
|
||||
|
||||
# Auto-share the file with mentioned users that don't have access yet.
|
||||
# Only do this in multi-user mode and only when the file has an owner
|
||||
# (unowned files are already visible to all authenticated users).
|
||||
if mentions and file_record.owner_id is not None:
|
||||
from app.config import settings as _settings
|
||||
|
||||
if _settings.multi_user_enabled:
|
||||
for mentioned_user in mentions:
|
||||
# Skip the file owner (already has full access) and the commenter
|
||||
# themselves (they already have access to be posting a comment).
|
||||
if mentioned_user in {file_record.owner_id, owner_id}:
|
||||
continue
|
||||
existing_share = (
|
||||
db.query(FileShare)
|
||||
.filter(
|
||||
FileShare.file_id == file_id,
|
||||
FileShare.shared_with_user_id == mentioned_user,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing_share:
|
||||
auto_share = FileShare(
|
||||
file_id=file_id,
|
||||
owner_id=file_record.owner_id,
|
||||
shared_with_user_id=mentioned_user,
|
||||
role=FILE_SHARE_ROLE_VIEWER,
|
||||
)
|
||||
db.add(auto_share)
|
||||
logger.info(
|
||||
"Auto-shared file_id=%s with mentioned user=%s as viewer",
|
||||
file_id,
|
||||
mentioned_user,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create comment on file_id=%s", file_id)
|
||||
@@ -402,16 +464,25 @@ def resolve_comment(
|
||||
def list_annotations(request: Request, file_id: int, db: DbSession):
|
||||
"""List all annotations for a document.
|
||||
|
||||
Requires at least viewer access.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
|
||||
Returns:
|
||||
A dict with ``file_id``, ``annotations``, and ``total``.
|
||||
"""
|
||||
user_id = get_current_owner_id(request)
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
annotations = (
|
||||
db.query(DocumentAnnotation)
|
||||
.filter(DocumentAnnotation.file_id == file_id)
|
||||
@@ -461,11 +532,17 @@ def create_annotation(
|
||||
The created annotation object.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
owner_id = get_current_owner_id(request)
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
|
||||
+27
-1
@@ -30,7 +30,7 @@ from app.utils.file_queries import apply_status_filter
|
||||
from app.utils.file_status import get_files_processing_status
|
||||
from app.utils.filename_utils import sanitize_filename
|
||||
from app.utils.input_validation import validate_search_query, validate_sort_field, validate_sort_order
|
||||
from app.utils.user_scope import apply_owner_filter, get_current_owner_id
|
||||
from app.utils.user_scope import apply_owner_filter, get_current_owner_id, get_file_role
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -300,6 +300,7 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
|
||||
"""
|
||||
Delete a file record from the database.
|
||||
This only removes the database entry, not the actual file.
|
||||
Only the file owner (or an admin) may delete a document.
|
||||
"""
|
||||
# Check if file deletion is allowed
|
||||
if not settings.allow_file_delete:
|
||||
@@ -314,6 +315,18 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
|
||||
|
||||
# Enforce owner-only deletion in multi-user mode
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
if not is_admin:
|
||||
owner_id = get_current_owner_id(request)
|
||||
role = get_file_role(file_record, owner_id, db)
|
||||
if role != "owner":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only the file owner can delete this document",
|
||||
)
|
||||
|
||||
# Log the deletion
|
||||
logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}")
|
||||
|
||||
@@ -340,6 +353,7 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
|
||||
"""
|
||||
Delete multiple file records from the database.
|
||||
This only removes the database entries, not the actual files.
|
||||
Only the file owner (or an admin) may delete each document.
|
||||
"""
|
||||
# Check if file deletion is allowed
|
||||
if not settings.allow_file_delete:
|
||||
@@ -354,6 +368,18 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
|
||||
if not file_records:
|
||||
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
|
||||
|
||||
# Enforce owner-only deletion in multi-user mode
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
if not is_admin:
|
||||
owner_id = get_current_owner_id(request)
|
||||
non_owner_ids = [f.id for f in file_records if get_file_role(f, owner_id, db) != "owner"]
|
||||
if non_owner_ids:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"You can only delete files you own. Not owner of file IDs: {non_owner_ids}",
|
||||
)
|
||||
|
||||
deleted_count = len(file_records)
|
||||
deleted_ids = [f.id for f in file_records]
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""File-sharing API endpoints.
|
||||
|
||||
Provides CRUD operations for ``FileShare`` records, which grant named
|
||||
users ``viewer`` or ``editor`` access to a document owned by someone
|
||||
else. Only the file owner may create, update, or revoke shares.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_login
|
||||
from app.database import get_db
|
||||
from app.models import FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLES, FileRecord, FileShare, UserProfile
|
||||
from app.utils.user_scope import get_current_owner_id, get_file_role
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_share(share: FileShare) -> dict[str, Any]:
|
||||
"""Serialize a ``FileShare`` to a JSON-friendly dict."""
|
||||
return {
|
||||
"id": share.id,
|
||||
"file_id": share.file_id,
|
||||
"owner_id": share.owner_id,
|
||||
"shared_with_user_id": share.shared_with_user_id,
|
||||
"role": share.role,
|
||||
"created_at": share.created_at.isoformat() if share.created_at else None,
|
||||
"updated_at": share.updated_at.isoformat() if share.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _require_owner(file_record: FileRecord, user_id: str | None, db: Session) -> None:
|
||||
"""Raise 403 unless the calling user is the file owner."""
|
||||
if get_file_role(file_record, user_id, db) != "owner":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the file owner can manage shares",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List shares
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/shares")
|
||||
@require_login
|
||||
def list_shares(request: Request, file_id: int, db: DbSession):
|
||||
"""List all shares for a document.
|
||||
|
||||
Only the file owner (or an admin) may call this endpoint.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
|
||||
Returns:
|
||||
A list of share objects.
|
||||
"""
|
||||
user_id = get_current_owner_id(request)
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
role = get_file_role(file_record, user_id, db)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if role != "owner" and not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the file owner can view shares",
|
||||
)
|
||||
|
||||
shares = db.query(FileShare).filter(FileShare.file_id == file_id).all()
|
||||
return [_serialize_share(s) for s in shares]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create share
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/shares", status_code=status.HTTP_201_CREATED)
|
||||
@require_login
|
||||
def create_share(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
db: DbSession,
|
||||
shared_with_user_id: str = Body(..., embed=True),
|
||||
role: str = Body(FILE_SHARE_ROLE_VIEWER, embed=True),
|
||||
):
|
||||
"""Share a document with another user.
|
||||
|
||||
Only the file owner may share the document. Sharing with a user
|
||||
that already has access updates their role instead of creating a
|
||||
duplicate record.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document to share.
|
||||
|
||||
Request body (JSON):
|
||||
shared_with_user_id: The stable user identifier of the recipient.
|
||||
role: ``"viewer"`` (default) or ``"editor"``.
|
||||
|
||||
Returns:
|
||||
The created or updated share object.
|
||||
"""
|
||||
owner_id = get_current_owner_id(request)
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
_require_owner(file_record, owner_id, db)
|
||||
|
||||
if role not in FILE_SHARE_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}",
|
||||
)
|
||||
|
||||
if not shared_with_user_id or not shared_with_user_id.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="shared_with_user_id must be a non-empty string",
|
||||
)
|
||||
shared_with_user_id = shared_with_user_id.strip()
|
||||
|
||||
# Cannot share with yourself
|
||||
if shared_with_user_id == owner_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="You cannot share a file with yourself",
|
||||
)
|
||||
|
||||
try:
|
||||
existing = (
|
||||
db.query(FileShare)
|
||||
.filter(FileShare.file_id == file_id, FileShare.shared_with_user_id == shared_with_user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update role if different
|
||||
if existing.role != role:
|
||||
existing.role = role
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
logger.info(
|
||||
"Share updated: file_id=%s, shared_with=%s, role=%s, by owner=%s",
|
||||
file_id,
|
||||
shared_with_user_id,
|
||||
role,
|
||||
owner_id,
|
||||
)
|
||||
return _serialize_share(existing)
|
||||
|
||||
share = FileShare(
|
||||
file_id=file_id,
|
||||
owner_id=owner_id,
|
||||
shared_with_user_id=shared_with_user_id,
|
||||
role=role,
|
||||
)
|
||||
db.add(share)
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create share: file_id=%s, shared_with=%s", file_id, shared_with_user_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create share",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Share created: id=%s, file_id=%s, shared_with=%s, role=%s, by owner=%s",
|
||||
share.id,
|
||||
file_id,
|
||||
shared_with_user_id,
|
||||
role,
|
||||
owner_id,
|
||||
)
|
||||
return _serialize_share(share)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update share role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.put("/files/{file_id}/shares/{share_id}")
|
||||
@require_login
|
||||
def update_share(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
share_id: int,
|
||||
db: DbSession,
|
||||
role: str = Body(..., embed=True),
|
||||
):
|
||||
"""Update the role of an existing share.
|
||||
|
||||
Only the file owner may change the role of a share.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
share_id: The ID of the share record to update.
|
||||
|
||||
Request body (JSON):
|
||||
role: New role — ``"viewer"`` or ``"editor"``.
|
||||
|
||||
Returns:
|
||||
The updated share object.
|
||||
"""
|
||||
owner_id = get_current_owner_id(request)
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
_require_owner(file_record, owner_id, db)
|
||||
|
||||
if role not in FILE_SHARE_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}",
|
||||
)
|
||||
|
||||
share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first()
|
||||
if not share:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found")
|
||||
|
||||
try:
|
||||
share.role = role
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to update share: share_id=%s", share_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update share",
|
||||
)
|
||||
|
||||
logger.info("Share updated: id=%s, file_id=%s, new_role=%s, by owner=%s", share_id, file_id, role, owner_id)
|
||||
return _serialize_share(share)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revoke share
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/shares/{share_id}", status_code=status.HTTP_200_OK)
|
||||
@require_login
|
||||
def revoke_share(request: Request, file_id: int, share_id: int, db: DbSession):
|
||||
"""Revoke a share, removing the user's access.
|
||||
|
||||
Only the file owner may revoke shares.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
share_id: The ID of the share record to delete.
|
||||
|
||||
Returns:
|
||||
A success message.
|
||||
"""
|
||||
owner_id = get_current_owner_id(request)
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
_require_owner(file_record, owner_id, db)
|
||||
|
||||
share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first()
|
||||
if not share:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found")
|
||||
|
||||
try:
|
||||
db.delete(share)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to revoke share: share_id=%s", share_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to revoke share",
|
||||
)
|
||||
|
||||
logger.info("Share revoked: id=%s, file_id=%s, by owner=%s", share_id, file_id, owner_id)
|
||||
return {"status": "success", "message": "Share revoked successfully"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List users that the file is already shared with (for the share-picker UI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/shared-with")
|
||||
@require_login
|
||||
def list_shared_with(request: Request, file_id: int, db: DbSession):
|
||||
"""Return the list of users a document is shared with and their roles.
|
||||
|
||||
Accessible to any user that has at least viewer access to the file,
|
||||
so that editors/viewers can see who else has access.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
|
||||
Returns:
|
||||
A list of ``{share_id, user_id, display_name, role}`` objects.
|
||||
"""
|
||||
user_id = get_current_owner_id(request)
|
||||
user = request.session.get("user")
|
||||
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
role = get_file_role(file_record, user_id, db)
|
||||
if role is None and not is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
shares = db.query(FileShare).filter(FileShare.file_id == file_id).all()
|
||||
|
||||
results = []
|
||||
for s in shares:
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == s.shared_with_user_id).first()
|
||||
results.append(
|
||||
{
|
||||
"share_id": s.id,
|
||||
"user_id": s.shared_with_user_id,
|
||||
"display_name": (profile.display_name if profile and profile.display_name else s.shared_with_user_id),
|
||||
"role": s.role,
|
||||
}
|
||||
)
|
||||
return results
|
||||
@@ -1237,3 +1237,52 @@ class DocumentAnnotation(Base):
|
||||
color = Column(String(20), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File sharing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Valid roles for FileShare.role
|
||||
FILE_SHARE_ROLE_VIEWER = "viewer"
|
||||
FILE_SHARE_ROLE_EDITOR = "editor"
|
||||
FILE_SHARE_ROLES = (FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLE_EDITOR)
|
||||
|
||||
|
||||
class FileShare(Base):
|
||||
"""Grants a named user access to a ``FileRecord`` owned by someone else.
|
||||
|
||||
The ``owner_id`` column records who created the share (must be the file
|
||||
owner). ``shared_with_user_id`` is the recipient's stable user
|
||||
identifier (the same kind of string used in ``FileRecord.owner_id``).
|
||||
|
||||
Roles
|
||||
-----
|
||||
``viewer`` — can read the file, comments, and annotations; may add
|
||||
comments/annotations; cannot delete or share.
|
||||
``editor`` — all viewer rights plus the ability to edit document
|
||||
metadata; cannot delete or re-share.
|
||||
|
||||
Only the file owner may create, update, or revoke shares.
|
||||
"""
|
||||
|
||||
__tablename__ = "file_shares"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# The document being shared.
|
||||
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||
|
||||
# The user who granted the share (must match FileRecord.owner_id).
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
|
||||
# The user receiving the share.
|
||||
shared_with_user_id = Column(String, nullable=False, index=True)
|
||||
|
||||
# "viewer" or "editor"
|
||||
role = Column(String(20), nullable=False, default=FILE_SHARE_ROLE_VIEWER)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (UniqueConstraint("file_id", "shared_with_user_id", name="uq_file_share_file_user"),)
|
||||
|
||||
+91
-5
@@ -11,14 +11,21 @@ import logging
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Query
|
||||
from sqlalchemy.orm import Query, Session
|
||||
from sqlalchemy.sql import false
|
||||
|
||||
from app.config import settings
|
||||
from app.models import FileRecord
|
||||
from app.models import FILE_SHARE_ROLE_EDITOR, FILE_SHARE_ROLE_VIEWER, FileRecord, FileShare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Role hierarchy: higher index = more rights
|
||||
_ROLE_RANK: dict[str, int] = {
|
||||
FILE_SHARE_ROLE_VIEWER: 1,
|
||||
FILE_SHARE_ROLE_EDITOR: 2,
|
||||
"owner": 3,
|
||||
}
|
||||
|
||||
|
||||
def _owner_id_from_user(user: dict) -> str | None:
|
||||
"""Extract the owner identifier from a user dict.
|
||||
@@ -93,8 +100,9 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
|
||||
"""Conditionally filter a ``FileRecord`` query by the current user.
|
||||
|
||||
When multi-user mode is enabled, only files whose ``owner_id``
|
||||
matches the authenticated user are returned. Admin users bypass
|
||||
the filter and see all documents.
|
||||
matches the authenticated user are returned, **plus** any files that
|
||||
have been explicitly shared with the user via ``FileShare``. Admin
|
||||
users bypass the filter and see all documents.
|
||||
|
||||
When ``unowned_docs_visible_to_all`` is ``True`` (default), documents
|
||||
with ``owner_id IS NULL`` (unclaimed) are also included for every
|
||||
@@ -122,11 +130,89 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
|
||||
# No authenticated user — return empty result set
|
||||
return query.filter(false())
|
||||
|
||||
# Build filter: user's own documents
|
||||
# Build filter: user's own documents + documents shared with them
|
||||
conditions = [FileRecord.owner_id == owner_id]
|
||||
|
||||
# Include files explicitly shared with this user
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
conditions.append(FileRecord.id.in_(sa_select(FileShare.file_id).where(FileShare.shared_with_user_id == owner_id)))
|
||||
|
||||
# Optionally include unclaimed (owner_id IS NULL) documents
|
||||
if settings.unowned_docs_visible_to_all:
|
||||
conditions.append(FileRecord.owner_id.is_(None))
|
||||
|
||||
return query.filter(or_(*conditions))
|
||||
|
||||
|
||||
def get_file_role(file_record: FileRecord, user_id: str | None, db: Session) -> str | None:
|
||||
"""Return the effective role a user has on a ``FileRecord``.
|
||||
|
||||
Roles (in descending order of privilege):
|
||||
|
||||
``"owner"`` — the user's ``owner_id`` matches ``file_record.owner_id``,
|
||||
or multi-user mode is disabled (everyone is effectively an
|
||||
owner in single-user mode).
|
||||
``"editor"`` — the user has an explicit ``FileShare`` with role=editor.
|
||||
``"viewer"`` — the user has an explicit ``FileShare`` with role=viewer,
|
||||
or the file is unclaimed (``owner_id IS NULL``) and
|
||||
``unowned_docs_visible_to_all`` is True.
|
||||
``None`` — no access.
|
||||
|
||||
Args:
|
||||
file_record: The ``FileRecord`` to check.
|
||||
user_id: The stable identifier of the requesting user.
|
||||
db: An active SQLAlchemy session.
|
||||
|
||||
Returns:
|
||||
One of ``"owner"``, ``"editor"``, ``"viewer"``, or ``None``.
|
||||
"""
|
||||
if not settings.multi_user_enabled:
|
||||
# Single-user mode: full access for everyone
|
||||
return "owner"
|
||||
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
# Owner always has full access
|
||||
if file_record.owner_id == user_id:
|
||||
return "owner"
|
||||
|
||||
# Unclaimed document — limited access when setting allows it
|
||||
if file_record.owner_id is None and settings.unowned_docs_visible_to_all:
|
||||
return FILE_SHARE_ROLE_VIEWER
|
||||
|
||||
# Check for an explicit share
|
||||
share = (
|
||||
db.query(FileShare)
|
||||
.filter(FileShare.file_id == file_record.id, FileShare.shared_with_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if share:
|
||||
return share.role
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_file_role(
|
||||
file_record: FileRecord,
|
||||
user_id: str | None,
|
||||
db: Session,
|
||||
minimum_role: str = FILE_SHARE_ROLE_VIEWER,
|
||||
) -> bool:
|
||||
"""Return ``True`` if the user's effective role meets the minimum required.
|
||||
|
||||
Args:
|
||||
file_record: The document to check.
|
||||
user_id: Requesting user's stable identifier.
|
||||
db: Active SQLAlchemy session.
|
||||
minimum_role: The minimum role required (``"viewer"``, ``"editor"``,
|
||||
or ``"owner"``).
|
||||
|
||||
Returns:
|
||||
``True`` when the user's role rank is >= the minimum rank.
|
||||
"""
|
||||
role = get_file_role(file_record, user_id, db)
|
||||
if role is None:
|
||||
return False
|
||||
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK.get(minimum_role, 0)
|
||||
|
||||
@@ -497,6 +497,18 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
|
||||
mime = file_record.mime_type or ""
|
||||
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
|
||||
|
||||
# Determine the current user's role on this file
|
||||
from app.utils.user_scope import get_current_owner_id, get_file_role
|
||||
|
||||
current_owner_id = get_current_owner_id(request)
|
||||
user_session = request.session.get("user")
|
||||
is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
|
||||
if is_admin:
|
||||
current_user_role: str | None = "owner"
|
||||
else:
|
||||
current_user_role = get_file_role(file_record, current_owner_id, db)
|
||||
# None means no access — the template will not show owner-only UI
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"file_annotations.html",
|
||||
{
|
||||
@@ -505,6 +517,7 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
|
||||
"original_file_exists": original_file_exists,
|
||||
"processed_file_exists": processed_file_exists,
|
||||
"is_pdf": is_pdf,
|
||||
"current_user_role": current_user_role,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user