From 6f2752bdf804789e704983a71afe42e04ea852b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:14:50 +0000 Subject: [PATCH] feat(sharing): add file sharing and role-based access management Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/8091889d-4810-4794-b9d3-6b8f7f5257c4 --- app/api/__init__.py | 2 + app/api/comments.py | 82 ++- app/api/files.py | 28 +- app/api/sharing.py | 355 +++++++++++ app/models.py | 49 ++ app/utils/user_scope.py | 96 ++- app/views/files.py | 9 + frontend/static/js/sharing.js | 222 +++++++ frontend/templates/file_annotations.html | 74 +++ frontend/translations/en.json | 13 + migrations/versions/042_add_file_shares.py | 56 ++ tests/conftest.py | 1 + tests/test_sharing.py | 691 +++++++++++++++++++++ 13 files changed, 1669 insertions(+), 9 deletions(-) create mode 100644 app/api/sharing.py create mode 100644 frontend/static/js/sharing.js create mode 100644 migrations/versions/042_add_file_shares.py create mode 100644 tests/test_sharing.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 325b514c..c72813e7 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/comments.py b/app/api/comments.py index cb4451eb..84d00382 100644 --- a/app/api/comments.py +++ b/app/api/comments.py @@ -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,45 @@ 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 commenter themselves and the file owner + if mentioned_user in (owner_id, file_record.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 +463,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 +531,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, diff --git a/app/api/files.py b/app/api/files.py index 8eae6e55..da9907c1 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -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] diff --git a/app/api/sharing.py b/app/api/sharing.py new file mode 100644 index 00000000..2d66d92b --- /dev/null +++ b/app/api/sharing.py @@ -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 diff --git a/app/models.py b/app/models.py index 7fcc72a2..4591a8f2 100644 --- a/app/models.py +++ b/app/models.py @@ -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"),) diff --git a/app/utils/user_scope.py b/app/utils/user_scope.py index a2d169c7..fe44c948 100644 --- a/app/utils/user_scope.py +++ b/app/utils/user_scope.py @@ -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) diff --git a/app/views/files.py b/app/views/files.py index af9e2e5d..5fee6f65 100644 --- a/app/views/files.py +++ b/app/views/files.py @@ -497,6 +497,14 @@ 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")) + current_user_role = "owner" if is_admin else (get_file_role(file_record, current_owner_id, db) or "viewer") + return templates.TemplateResponse( "file_annotations.html", { @@ -505,6 +513,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: diff --git a/frontend/static/js/sharing.js b/frontend/static/js/sharing.js new file mode 100644 index 00000000..d6aba064 --- /dev/null +++ b/frontend/static/js/sharing.js @@ -0,0 +1,222 @@ +/** + * sharing.js – File sharing management UI. + * + * Renders the current shares for a document and lets the file owner + * add new shares, change roles, or revoke access. + * + * Usage: + * initSharing(fileId, i18n) + * + * The i18n object is expected to contain all keys used below. + */ + +/* global fetch */ + +(function () { + 'use strict'; + + var _fileId = null; + var _i18n = {}; + + // ── DOM helpers ────────────────────────────────────────────────────────── + + function _el(id) { + return document.getElementById(id); + } + + function _esc(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function _t(key) { + return _i18n[key] || key; + } + + // ── API helpers ────────────────────────────────────────────────────────── + + function _apiUrl(suffix) { + return '/api/files/' + _fileId + suffix; + } + + function _fetchShares() { + return fetch(_apiUrl('/shares'), { credentials: 'same-origin' }) + .then(function (r) { return r.json(); }); + } + + function _addShare(userId, role) { + return fetch(_apiUrl('/shares'), { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shared_with_user_id: userId, role: role }), + }).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error((body && body.detail) || r.statusText); + return body; + }); + }); + } + + function _updateRole(shareId, role) { + return fetch(_apiUrl('/shares/' + shareId), { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: role }), + }).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error((body && body.detail) || r.statusText); + return body; + }); + }); + } + + function _revokeShare(shareId) { + return fetch(_apiUrl('/shares/' + shareId), { + method: 'DELETE', + credentials: 'same-origin', + }).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error((body && body.detail) || r.statusText); + return body; + }); + }); + } + + // ── Render ─────────────────────────────────────────────────────────────── + + function _renderShares(shares) { + var list = _el('sharing-list'); + if (!list) return; + + if (!shares || shares.length === 0) { + list.innerHTML = '
' + _esc(_t('no_shares')) + '
'; + return; + } + + var rows = shares.map(function (s) { + var roleLabel = s.role === 'editor' ? _t('role_editor') : _t('role_viewer'); + return ( + '' + _esc(_t('loading')) + '
'; + _fetchShares() + .then(function (data) { + // GET /files/{id}/shares returns an array; /files/{id}/shared-with also returns array + var shares = Array.isArray(data) ? data : (data.shares || []); + // Normalise keys: shares list uses share_id, but the shares endpoint returns id + shares = shares.map(function (s) { + return { + share_id: s.share_id || s.id, + user_id: s.user_id || s.shared_with_user_id, + display_name: s.display_name || s.shared_with_user_id || s.user_id, + role: s.role, + }; + }); + _renderShares(shares); + }) + .catch(function (err) { + if (list) list.innerHTML = '' + _esc(err.message) + '
'; + }); + } + + function _showError(msg) { + var el = _el('sharing-form-error'); + if (!el) return; + el.textContent = msg; + el.style.display = 'block'; + setTimeout(function () { el.style.display = 'none'; }, 5000); + } + + // ── Init ───────────────────────────────────────────────────────────────── + + function initSharing(fileId, i18n) { + _fileId = fileId; + _i18n = i18n || {}; + + _loadAndRender(); + + var form = _el('sharing-form'); + if (!form) return; + + form.addEventListener('submit', function (e) { + e.preventDefault(); + var userInput = _el('share-user-input'); + var roleInput = _el('share-role-input'); + var userId = userInput ? userInput.value.trim() : ''; + var role = roleInput ? roleInput.value : 'viewer'; + + if (!userId) { + _showError(_t('error_empty_user')); + return; + } + + _addShare(userId, role) + .then(function () { + if (userInput) userInput.value = ''; + _loadAndRender(); + }) + .catch(function (err) { _showError(err.message); }); + }); + } + + // Expose + window.initSharing = initSharing; +})(); diff --git a/frontend/templates/file_annotations.html b/frontend/templates/file_annotations.html index c070d550..1a73daba 100644 --- a/frontend/templates/file_annotations.html +++ b/frontend/templates/file_annotations.html @@ -660,6 +660,57 @@ + + {% if current_user_role == 'owner' %} +