refactor(09-05): CODE-09 purge — Phase 8 backend sub-packages (WHAT comments removed)

- api/admin/users.py: removed module docstring + 7 WHAT function docstrings
- api/admin/quotas.py: removed module docstring + 2 WHAT function docstrings
- api/admin/ai.py: removed module docstring + 3 WHAT inline comments; security invariant docstrings preserved
- api/admin/shared.py: unchanged (all comments are WHY — constraint notes)
- api/documents/upload.py: removed 4 WHAT inline comments; T-03-05/T-03-06 WHY notes preserved
- api/documents/content.py: removed WHAT function docstring + WHAT inline comment
- api/documents/crud.py: removed 7 WHAT inline comments; D-16 + security constraint docstrings preserved
- api/auth/shared.py: removed module docstring + 1 WHAT inline comment
- api/auth/tokens.py: removed module docstring + 8 WHAT function docstrings/comments; family-revocation + SEC-02 WHY notes preserved
- api/auth/totp.py: removed module docstring + 2 WHAT function docstrings + 2 WHAT inline comments
- api/auth/password.py: removed module docstring + 2 WHAT function docstrings + 3 WHAT inline comments
- All NO-prefix anchor comments and security-invariant WHY comments preserved
- pytest: 413 passed, 1 failed (pre-existing ModuleNotFoundError unrelated to purge)
This commit is contained in:
curo1305
2026-06-12 16:59:46 +02:00
parent 9ddd599897
commit 6d1c02f703
10 changed files with 8 additions and 268 deletions
+1 -17
View File
@@ -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)
-18
View File
@@ -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")
-58
View File
@@ -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(
+2 -35
View File
@@ -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)
-9
View File
@@ -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)
+4 -70
View File
@@ -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")
+1 -29
View File
@@ -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)
-16
View File
@@ -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="<filename>"
Accept-Ranges: bytes
Content-Length: <size>
"""
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)
-9
View File
@@ -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
-7
View File
@@ -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)