diff --git a/backend/api/admin/ai.py b/backend/api/admin/ai.py index c356302..5cc06a2 100644 --- a/backend/api/admin/ai.py +++ b/backend/api/admin/ai.py @@ -1,12 +1,3 @@ -"""Admin AI configuration endpoints. - -Handles: get_ai_config_models, test_ai_connection, get_ai_config, update_system_ai_config. - -All handlers require get_current_admin (SEC-07, T-08-04-01). -Sub-router has NO prefix — parent __init__.py carries /api/admin (D-04). - -_ai_config_to_dict is local to this module (only ai.py uses it — not in shared.py). -""" from __future__ import annotations from typing import Optional @@ -126,7 +117,7 @@ async def get_ai_config_models( api_key = config.api_key if config else "" - # Build request headers — Anthropic uses x-api-key; all others use Bearer + # Anthropic uses x-api-key; all others use Bearer (different auth schemes) if provider_id == "anthropic": headers = { "x-api-key": api_key, @@ -143,9 +134,6 @@ async def get_ai_config_models( resp.raise_for_status() data = resp.json() - # Standard OpenAI-compat shape: {"data": [{"id": "...", ...}, ...]} - # Anthropic shape: {"data": [{"id": "...", ...}, ...]} - # Ollama OpenAI-compat: same shape raw_list = data.get("data") or data.get("models") or [] model_ids: list[str] = sorted( { @@ -286,7 +274,6 @@ async def update_system_ai_config( """ from config import settings as _settings # noqa: PLC0415 - # Load existing row or create a new one from PROVIDER_DEFAULTS stmt = select(SystemSettings).where(SystemSettings.provider_id == body.provider_id) result = await session.execute(stmt) row = result.scalar_one_or_none() @@ -303,10 +290,8 @@ async def update_system_ai_config( api_key_enc=None, ) - # Track which fields the caller explicitly set (for audit log — never api_key value) fields_changed: list[str] = [] - # Apply provided fields if body.api_key is not None: fields_changed.append("api_key") if body.api_key == "": @@ -363,7 +348,6 @@ async def update_system_ai_config( await session.commit() - # Reload to pick up DB-generated updated_at after commit await session.refresh(row) return _ai_config_to_dict(row) diff --git a/backend/api/admin/quotas.py b/backend/api/admin/quotas.py index 42bfff0..906abb1 100644 --- a/backend/api/admin/quotas.py +++ b/backend/api/admin/quotas.py @@ -1,10 +1,3 @@ -"""Admin quota management endpoints. - -Handles: get_user_quota, update_user_quota. - -All handlers require get_current_admin (SEC-07, T-08-04-01). -Sub-router has NO prefix — parent __init__.py carries /api/admin (D-04). -""" from __future__ import annotations import uuid @@ -44,11 +37,6 @@ async def get_user_quota( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Return quota details for a user (ADMIN-04). - - Quota info is admin-visible operational data — no PII, no document content - (T-02-31 disposition: accept). - """ quota = await session.get(Quota, user_id) if quota is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quota not found") @@ -70,12 +58,6 @@ async def update_user_quota( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Adjust a user's storage quota (ADMIN-04). - - If the new limit is below current usage, still applies the change but - returns warning=True with an explanatory message. Uploads will be blocked - but existing documents are preserved. - """ quota = await session.get(Quota, user_id) if quota is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quota not found") diff --git a/backend/api/admin/users.py b/backend/api/admin/users.py index a7d833a..facddff 100644 --- a/backend/api/admin/users.py +++ b/backend/api/admin/users.py @@ -1,11 +1,3 @@ -"""Admin user-management endpoints. - -Handles: list_users, create_user, update_user_status, initiate_password_reset, - update_ai_config (per-user), delete_user, create_system_topic. - -All handlers require get_current_admin (SEC-07, T-08-04-01). -Sub-router has NO prefix — parent __init__.py carries /api/admin (D-04). -""" from __future__ import annotations import time @@ -79,11 +71,6 @@ async def list_users( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """List all users, ordered by created_at DESC. - - Response shape: { items: [...safe user fields...] } - Never includes password_hash, credentials_enc, or document content (T-02-27). - """ result = await session.execute( select(User).order_by(User.created_at.desc()) ) @@ -98,14 +85,6 @@ async def create_user( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Admin creates a new user account (ADMIN-01). - - - password_must_change=True forces the user to change their password on - first login (T-02-32, D-06). - - Quota row initialized at 100 MB (D-06). - - Returns 409 if email or handle is already taken. - """ - # Check uniqueness existing_email = await session.execute( select(User).where(User.email == str(body.email)) ) @@ -172,11 +151,6 @@ async def update_user_status( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Deactivate or reactivate a user account (ADMIN-02). - - - Prevents deactivating the last active admin (T-02-29). - - On deactivation: all refresh tokens are revoked (family revocation). - """ user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") @@ -237,15 +211,6 @@ async def initiate_password_reset( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Admin initiates a password reset for a user (ADMIN-03). - - Sends the reset email via Celery. Does NOT: - - return a reset token (T-02-30) - - grant admin access to the account - - log in as the target user (ADMIN-07 — no impersonation) - - Returns 202 immediately regardless of email delivery status. - """ user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") @@ -271,11 +236,6 @@ async def update_ai_config( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Assign AI provider and model for a user (ADMIN-05). - - Users cannot change their own AI provider or model (PROJECT.md Key Decision). - Only admins have this capability. - """ user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") @@ -313,15 +273,6 @@ async def delete_user( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> None: - """Delete a user account and clean up all their MinIO objects (SEC-09, D-19). - - Security invariants: - - Admin password verified via Argon2 before any deletion (T-05-11-01) - - Cannot delete admin accounts (T-04-07-04) - - MinIO objects are deleted BEFORE DB records are removed (SEC-09) - - MinIO deletion is best-effort (try/except) — DB row is deleted regardless - - Audit log written with event_type="admin.user_deleted" - """ # T-05-11-01: Verify admin password before performing any destructive action. # Fail fast — no DB reads for the target user until the admin is confirmed. if not verify_password(body.admin_password, _admin.password_hash): @@ -414,15 +365,6 @@ async def create_system_topic( session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin), ) -> dict: - """Create a system topic visible to all users (D-09, DOC-04). - - System topics have user_id = NULL, making them visible to every user as - defaults in their topic namespace. Only admins can create system topics. - Regular users create per-user topics via POST /api/topics. - - Deduplication: case-insensitive match within the system namespace (user_id IS NULL). - Returns the existing system topic if one with the same name already exists. - """ from services import storage # noqa: PLC0415 topic = await storage.create_topic( diff --git a/backend/api/auth/password.py b/backend/api/auth/password.py index bb94f97..e9e0230 100644 --- a/backend/api/auth/password.py +++ b/backend/api/auth/password.py @@ -1,11 +1,3 @@ -""" -Auth API — password management endpoints. - -Handles: - POST /change-password — update password (requires current password) - POST /password-reset — request a password reset email - POST /password-reset/confirm — confirm a password reset using the token -""" from __future__ import annotations import hashlib @@ -43,34 +35,23 @@ async def change_password( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Update the current user's password. - - Checks: - 1. current_password matches stored hash - 2. new_password has not appeared in HIBP (SEC-03) - 3. new_password meets strength requirements (AUTH-01) - """ - # Verify current password if not auth_service.verify_password(body.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect", ) - # HIBP breach check on new password (SEC-03) if await auth_service.check_hibp(body.new_password): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="This password has appeared in a data breach. Choose a different password.", ) - # Password strength check try: auth_service.validate_password_strength(body.new_password) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) - # Update password _ip = get_client_ip(request) user = await session.get(User, current_user.id) user.password_hash = auth_service.hash_password(body.new_password) @@ -108,12 +89,7 @@ async def password_reset_request( body: PasswordResetRequest, session: AsyncSession = Depends(get_db), ): - """Request a password reset email. - - Always returns 202 regardless of whether the email exists (anti-enumeration, T-02-22). - If the user is found, a signed reset token (1-hour JWT) is generated and a Celery - task is enqueued to send the email (D-02, D-03). - """ + # Always returns 202 regardless of whether email exists — anti-enumeration (T-02-22) from sqlalchemy import select as _select # noqa: PLC0415 (already imported above) result = await session.execute(_select(User).where(User.email == str(body.email))) @@ -142,12 +118,7 @@ async def password_reset_confirm( body: PasswordResetConfirmRequest, session: AsyncSession = Depends(get_db), ): - """Confirm a password reset using the token from the email link. - - Validates the reset token, enforces password strength + HIBP check, updates - the password, and revokes all refresh tokens. Does NOT issue new tokens — - the user must sign in again through /login (AUTH-05, T-02-21). - """ + # Does NOT issue new tokens — user must re-authenticate through /login (AUTH-05, T-02-21) try: user_id_str = auth_service.decode_password_reset_token(body.token) except ValueError: @@ -156,20 +127,17 @@ async def password_reset_confirm( detail="Invalid or expired reset link", ) - # Password strength validation try: auth_service.validate_password_strength(body.new_password) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) - # HIBP breach check (SEC-03) if await auth_service.check_hibp(body.new_password): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="This password has appeared in a data breach. Choose a different password.", ) - # Load user user = await session.get(User, uuid.UUID(user_id_str)) if user is None or not user.is_active: raise HTTPException( @@ -177,7 +145,6 @@ async def password_reset_confirm( detail="Invalid or expired reset link", ) - # Update password and revoke all sessions (forces re-auth through TOTP if enabled) user.password_hash = auth_service.hash_password(body.new_password) await auth_service.revoke_all_refresh_tokens(session, user.id) # Revoke any pre-reset access tokens still within their TTL window (T-7.2-01) diff --git a/backend/api/auth/shared.py b/backend/api/auth/shared.py index 95e413c..9dfa1a2 100644 --- a/backend/api/auth/shared.py +++ b/backend/api/auth/shared.py @@ -1,11 +1,3 @@ -""" -Auth API — shared helpers, Pydantic request models, and the Limiter instance. - -This module is imported by every auth sub-module (tokens.py, totp.py, password.py). -The Limiter instance is re-exported from api/auth/__init__.py so that: - from api.auth import limiter -continues to work in main.py and the 5 test files without modification. -""" from __future__ import annotations from typing import Literal, Optional @@ -18,7 +10,6 @@ from config import settings from deps.utils import get_client_ip # IP-level rate limiter (SEC-02 — 10 req/min on register/login/refresh) -# Re-exported from api/auth/__init__.py via: from api.auth.shared import limiter limiter = Limiter(key_func=get_client_ip) diff --git a/backend/api/auth/tokens.py b/backend/api/auth/tokens.py index d5266ce..e4fad4b 100644 --- a/backend/api/auth/tokens.py +++ b/backend/api/auth/tokens.py @@ -1,17 +1,3 @@ -""" -Auth API — token-issuing endpoints. - -Handles: - POST /register — new user registration with HIBP check - POST /login — login with optional TOTP/backup-code second factor - POST /refresh — rotate refresh token (httpOnly cookie in/out) - POST /logout — revoke current refresh token, clear cookie - POST /logout-all — revoke all refresh tokens for current user - GET /me — return current user profile - GET /me/quota — return current user quota - GET /me/preferences — return current user preferences - PATCH /me/preferences — update current user preferences -""" from __future__ import annotations import hashlib @@ -51,27 +37,17 @@ async def register( body: RegisterRequest, session: AsyncSession = Depends(get_db), ): - """Register a new user account. - - - Validates password strength (min 12 chars, upper, lower, digit, special) - - Checks HIBP k-anonymity API for breached passwords - - Hashes password with Argon2 - - Inserts User + Quota rows in a single transaction - """ - # Password strength check try: auth_service.validate_password_strength(body.password) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) - # HIBP breach check if await auth_service.check_hibp(body.password): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="This password has appeared in a data breach. Choose a different password.", ) - # Duplicate email/handle check result = await session.execute( select(User).where( (User.email == str(body.email)) | (User.handle == body.handle) @@ -83,7 +59,6 @@ async def register( detail="Email or handle already in use", ) - # Create user and quota user_id = uuid.uuid4() new_user = User( id=user_id, @@ -132,23 +107,11 @@ async def login( response: Response, session: AsyncSession = Depends(get_db), ): - """Authenticate a user and issue tokens. - - Per-account rate limiting (SEC-02): checks Redis counter keyed by email - BEFORE any DB lookup to prevent enumeration timing attacks. - - Three login flows: - 1. No TOTP enabled: password → tokens - 2. TOTP enabled, no code provided: requires_totp = True (challenge) - 3. TOTP enabled, totp_code provided: verify TOTP → tokens - 4. TOTP enabled, backup_code provided (no totp_code): verify backup → tokens - """ - # Per-account rate limiting (SEC-02) + # Per-account rate limiting (SEC-02): Redis counter keyed by email, checked before any DB lookup redis_client = request.app.state.redis rate_key = f"login_attempts:{body.email}" count = await redis_client.incr(rate_key) if count == 1: - # Set TTL only on first increment (15-minute window) await redis_client.expire(rate_key, 900) if count > 10: raise HTTPException( @@ -156,11 +119,9 @@ async def login( detail="Too many login attempts. Try again in 15 minutes.", ) - # Look up user by email result = await session.execute(select(User).where(User.email == str(body.email))) user: Optional[User] = result.scalar_one_or_none() - # IP extraction for audit log (used in both success and failure paths) _ip = get_client_ip(request) # Verify password (anti-enumeration: same error regardless of whether user exists) @@ -180,7 +141,6 @@ async def login( detail="Incorrect email or password", ) - # Active check if not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -191,10 +151,8 @@ async def login( if user.password_must_change: return {"requires_password_change": True, "user_id": str(user.id)} - # TOTP second-factor dispatch if user.totp_enabled: if body.totp_code is None and body.backup_code is None: - # Challenge: prompt for second factor return {"requires_totp": True} if body.totp_code is not None: @@ -223,7 +181,6 @@ async def login( ip_address=_ip, ) - # Issue tokens access_token = auth_service.create_access_token( str(user.id), user.role, @@ -266,12 +223,7 @@ async def refresh_token( response: Response, session: AsyncSession = Depends(get_db), ): - """Rotate the refresh token. - - Reads the refresh_token httpOnly cookie; on success issues a new access - token and rotates the refresh cookie. - On token reuse (revoked token presented), revokes entire family and raises 401. - """ + # Token reuse (revoked token presented) revokes entire family — family revocation security contract raw_token = request.cookies.get("refresh_token") if not raw_token: raise HTTPException( @@ -292,7 +244,6 @@ async def refresh_token( detail="Invalid or expired refresh token", ) from exc - # Look up user for response body user = await session.get(User, uuid.UUID(user_id_str)) if user is None or not user.is_active: raise HTTPException( @@ -300,7 +251,6 @@ async def refresh_token( detail="User not found or deactivated", ) - # Set new refresh cookie _set_refresh_cookie(response, new_raw) access_token = auth_service.create_access_token( @@ -325,7 +275,6 @@ async def refresh_token( @router.post("/logout") async def logout(request: Request, response: Response, session: AsyncSession = Depends(get_db)): - """Revoke current refresh token and clear the cookie.""" import hashlib as _hashlib _ip = get_client_ip(request) @@ -365,7 +314,6 @@ async def logout_all( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Sign out of all devices: revoke all refresh tokens for current user.""" _ip = get_client_ip(request) count = await auth_service.revoke_all_refresh_tokens(session, current_user.id) # D-13: sign-out-all event @@ -387,7 +335,6 @@ async def logout_all( @router.get("/me") async def get_me(current_user: User = Depends(get_current_user)): - """Return the current user's profile (requires valid Bearer token).""" return _user_dict(current_user) @@ -398,11 +345,6 @@ async def get_my_quota( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db), ): - """Return the current user's quota usage (STORE-04). - - Returns {"used_bytes": int, "limit_bytes": int} for the sidebar quota bar. - Quota row is created at registration (100 MB default — STORE-01). - """ q = await session.get(Quota, current_user.id) if q is None: raise HTTPException(status_code=404, detail="Quota not found") @@ -415,11 +357,7 @@ async def get_my_quota( async def get_my_preferences( current_user: User = Depends(get_current_user), ): - """Return the current user's PDF open mode preference (D-10). - - Both regular users and admins can read their own preferences. - Falls back to 'in_app' if the column is absent (migration not yet run). - """ + # Falls back to 'in_app' if the column is absent (migration not yet run) try: pdf_open_mode = current_user.pdf_open_mode except AttributeError: @@ -435,11 +373,7 @@ async def update_my_preferences( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Update the current user's PDF open mode preference (D-10). - - Both regular users and admins can update their own preferences. - Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05). - """ + # Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05) user = await session.get(User, current_user.id) if user is None: raise HTTPException(status_code=404, detail="User not found") diff --git a/backend/api/auth/totp.py b/backend/api/auth/totp.py index 4bc4e0e..da8cf3c 100644 --- a/backend/api/auth/totp.py +++ b/backend/api/auth/totp.py @@ -1,11 +1,3 @@ -""" -Auth API — TOTP endpoints. - -Handles: - GET /totp/setup — provision TOTP secret for current user - POST /totp/enable — enable TOTP (verify code, generate backup codes) - DELETE /totp — disable TOTP (clear secret, revoke other sessions) -""" from __future__ import annotations import hashlib @@ -34,12 +26,6 @@ async def totp_setup( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Provision a TOTP secret for the current user. - - If TOTP is already enabled, returns 400. - Returns { provisioning_uri, secret } — the provisioning_uri is suitable - for QR code generation. The secret is the base32-encoded TOTP secret. - """ if current_user.totp_enabled: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -59,14 +45,7 @@ async def enable_totp( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Enable TOTP for the current user. - - Rate-limited to 10 attempts/minute per IP (SEC-02 / T-02-25). - Verifies the submitted 6-digit code (with Redis replay prevention, AUTH-08). - On success: marks TOTP enabled, generates and returns 10 one-time backup codes. - The backup codes are ONLY returned here — they are stored as Argon2 hashes - in the DB and never returned again (T-02-19). - """ + # Backup codes are returned here ONLY — stored as Argon2 hashes, never returned again (T-02-19) redis_client = request.app.state.redis ok = await auth_service.verify_totp(session, current_user.id, body.code, redis_client) if not ok: @@ -75,12 +54,10 @@ async def enable_totp( detail="Incorrect or expired code", ) - # Mark TOTP as enabled user = await session.get(User, current_user.id) user.totp_enabled = True await session.flush() - # Generate and store 10 backup codes; return plaintext to user (one-time, T-02-19) plain_codes = auth_service.generate_backup_codes(10) await auth_service.store_backup_codes(session, current_user.id, plain_codes) @@ -119,16 +96,11 @@ async def disable_totp( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Disable TOTP for the current user. - - Clears totp_secret, sets totp_enabled=False, and deletes all backup codes. - """ _ip = get_client_ip(request) user = await session.get(User, current_user.id) user.totp_enabled = False user.totp_secret = None - # Delete all backup codes for this user (including unused ones) await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id)) # Revoke other sessions; keep current one alive via skip_token_hash (CR-03) diff --git a/backend/api/documents/content.py b/backend/api/documents/content.py index 6cd99f7..8f5f9a8 100644 --- a/backend/api/documents/content.py +++ b/backend/api/documents/content.py @@ -62,19 +62,6 @@ async def stream_document_content( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ): - """Stream document bytes directly from MinIO (DOC-02). - - T-04-05-01: uses get_regular_user — admin role → 403 (critical security invariant). - T-04-05-02: bytes fetched via get_object() ONLY — presigned_get_url() never called. - T-04-05-03: Range header validated via _parse_range(); invalid range → 416. - T-04-05-04: access gated on ownership OR active Share.recipient_id. - - Returns 200 (or 206 for Range requests) with: - Content-Type: doc.content_type - Content-Disposition: inline; filename="" - Accept-Ranges: bytes - Content-Length: - """ request.state.current_user = current_user try: uid = uuid.UUID(doc_id) @@ -97,9 +84,6 @@ async def stream_document_content( if share is None: raise HTTPException(status_code=404, detail="Document not found") - # Fetch bytes from the correct backend — get_storage_backend_for_document handles - # all backends (MinIO, Google Drive, OneDrive, Nextcloud, WebDAV) transparently - # (D-15, T-04-05-02). NEVER via presigned URL for cloud backends (D-14). try: import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents storage_backend = await _doc_pkg.get_storage_backend_for_document(doc, current_user, session) diff --git a/backend/api/documents/crud.py b/backend/api/documents/crud.py index c6d1963..5b989d5 100644 --- a/backend/api/documents/crud.py +++ b/backend/api/documents/crud.py @@ -99,12 +99,10 @@ async def list_documents( items.append(d) return {"items": items, "total": total, "page": page, "per_page": per_page} - # New path: direct ORM query with sort/filter/FTS from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top) stmt = select(Document).where(Document.user_id == current_user.id) - # Topic filter (join-based, same as list_metadata) if topic is not None: stmt = ( stmt.join(DocumentTopic, DocumentTopic.document_id == Document.id) @@ -112,7 +110,6 @@ async def list_documents( .where(Topic.name == topic) ) - # Folder filter if folder_id is not None: try: folder_uuid = uuid.UUID(folder_id) @@ -120,7 +117,6 @@ async def list_documents( raise HTTPException(status_code=404, detail="Folder not found") stmt = stmt.where(Document.folder_id == folder_uuid) - # Sort sort_col = Document.created_at # default: date if sort == "name": sort_col = Document.filename @@ -147,13 +143,11 @@ async def list_documents( result = await session.execute(stmt) docs_orm = result.scalars().all() - # is_shared subquery shared_result = await session.execute( select(Share.document_id).where(Share.owner_id == current_user.id) ) shared_ids = {row[0] for row in shared_result.fetchall()} - # Serialize all_items = [] for doc in docs_orm: from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415 @@ -199,7 +193,6 @@ async def get_document( is_recipient = False if doc.user_id != current_user.id: - # Allow recipients of an active share to view the document share_result = await session.execute( select(Share).where( Share.document_id == uid, @@ -250,7 +243,6 @@ async def patch_document( if doc is None or doc.user_id != current_user.id: raise HTTPException(404, "Document not found") - # Require at least one field to be set (model_fields_set tracks provided fields) if not body.model_fields_set: raise HTTPException(422, "At least one field (filename, folder_id) must be provided") @@ -311,7 +303,6 @@ async def delete_document( _doc_id = doc.id _ip = get_client_ip(request) - # Cloud routing: attempt provider delete unless remove_only is set if is_cloud and not remove_only: try: import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents diff --git a/backend/api/documents/upload.py b/backend/api/documents/upload.py index 5e9823a..99060fe 100644 --- a/backend/api/documents/upload.py +++ b/backend/api/documents/upload.py @@ -129,7 +129,6 @@ async def upload_document( """ request.state.current_user = current_user if target_backend == "minio": - # MinIO: generate a presigned URL for client-side PUT (existing flow reused) doc_id = uuid.uuid4() suffix = Path(file.filename or "file").suffix.lower() object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}" @@ -152,7 +151,6 @@ async def upload_document( ) return {"upload_url": upload_url, "document_id": str(doc_id)} - # Cloud backend path if target_backend not in _CLOUD_PROVIDERS: raise HTTPException( status_code=422, @@ -174,11 +172,9 @@ async def upload_document( detail=f"No active {target_backend} connection found. Please connect in Settings.", ) - # Decrypt per-user credentials master_key = settings.cloud_creds_key.encode() credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc) - # Read file bytes file_bytes = await file.read() filename = file.filename or "upload" content_type = file.content_type or "application/octet-stream" @@ -186,7 +182,6 @@ async def upload_document( doc_id = uuid.uuid4() - # Instantiate backend and upload if target_backend == "google_drive": from storage.google_drive_backend import GoogleDriveBackend # lazy import cloud_backend = GoogleDriveBackend(credentials) @@ -319,13 +314,11 @@ async def confirm_upload( row = result.fetchone() if row is None: - # Quota exceeded — fetch current quota state for the 413 body quota_result = await session.execute( text("SELECT used_bytes, limit_bytes FROM quotas WHERE user_id = :uid"), {"uid": doc.user_id.hex}, ) q = quota_result.fetchone() - # Delete the pending Document row and best-effort remove the MinIO object await session.delete(doc) try: await get_storage_backend().delete_object(doc.object_key)