chore: merge partial 09-05 — Phase 8 backend comment purge
This commit is contained in:
+1
-17
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -126,7 +117,7 @@ async def get_ai_config_models(
|
|||||||
|
|
||||||
api_key = config.api_key if config else ""
|
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":
|
if provider_id == "anthropic":
|
||||||
headers = {
|
headers = {
|
||||||
"x-api-key": api_key,
|
"x-api-key": api_key,
|
||||||
@@ -143,9 +134,6 @@ async def get_ai_config_models(
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
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 []
|
raw_list = data.get("data") or data.get("models") or []
|
||||||
model_ids: list[str] = sorted(
|
model_ids: list[str] = sorted(
|
||||||
{
|
{
|
||||||
@@ -286,7 +274,6 @@ async def update_system_ai_config(
|
|||||||
"""
|
"""
|
||||||
from config import settings as _settings # noqa: PLC0415
|
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)
|
stmt = select(SystemSettings).where(SystemSettings.provider_id == body.provider_id)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
row = result.scalar_one_or_none()
|
row = result.scalar_one_or_none()
|
||||||
@@ -303,10 +290,8 @@ async def update_system_ai_config(
|
|||||||
api_key_enc=None,
|
api_key_enc=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track which fields the caller explicitly set (for audit log — never api_key value)
|
|
||||||
fields_changed: list[str] = []
|
fields_changed: list[str] = []
|
||||||
|
|
||||||
# Apply provided fields
|
|
||||||
if body.api_key is not None:
|
if body.api_key is not None:
|
||||||
fields_changed.append("api_key")
|
fields_changed.append("api_key")
|
||||||
if body.api_key == "":
|
if body.api_key == "":
|
||||||
@@ -363,7 +348,6 @@ async def update_system_ai_config(
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Reload to pick up DB-generated updated_at after commit
|
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|
||||||
return _ai_config_to_dict(row)
|
return _ai_config_to_dict(row)
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -44,11 +37,6 @@ async def get_user_quota(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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)
|
quota = await session.get(Quota, user_id)
|
||||||
if quota is None:
|
if quota is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quota not found")
|
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),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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)
|
quota = await session.get(Quota, user_id)
|
||||||
if quota is None:
|
if quota is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quota not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quota not found")
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -79,11 +71,6 @@ async def list_users(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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(
|
result = await session.execute(
|
||||||
select(User).order_by(User.created_at.desc())
|
select(User).order_by(User.created_at.desc())
|
||||||
)
|
)
|
||||||
@@ -98,14 +85,6 @@ async def create_user(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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(
|
existing_email = await session.execute(
|
||||||
select(User).where(User.email == str(body.email))
|
select(User).where(User.email == str(body.email))
|
||||||
)
|
)
|
||||||
@@ -172,11 +151,6 @@ async def update_user_status(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
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),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> None:
|
) -> 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.
|
# 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.
|
# Fail fast — no DB reads for the target user until the admin is confirmed.
|
||||||
if not verify_password(body.admin_password, _admin.password_hash):
|
if not verify_password(body.admin_password, _admin.password_hash):
|
||||||
@@ -414,15 +365,6 @@ async def create_system_topic(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> dict:
|
) -> 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
|
from services import storage # noqa: PLC0415
|
||||||
|
|
||||||
topic = await storage.create_topic(
|
topic = await storage.create_topic(
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -43,34 +35,23 @@ async def change_password(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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):
|
if not auth_service.verify_password(body.current_password, current_user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Current password is incorrect",
|
detail="Current password is incorrect",
|
||||||
)
|
)
|
||||||
|
|
||||||
# HIBP breach check on new password (SEC-03)
|
|
||||||
if await auth_service.check_hibp(body.new_password):
|
if await auth_service.check_hibp(body.new_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="This password has appeared in a data breach. Choose a different password.",
|
detail="This password has appeared in a data breach. Choose a different password.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Password strength check
|
|
||||||
try:
|
try:
|
||||||
auth_service.validate_password_strength(body.new_password)
|
auth_service.validate_password_strength(body.new_password)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
|
|
||||||
# Update password
|
|
||||||
_ip = get_client_ip(request)
|
_ip = get_client_ip(request)
|
||||||
user = await session.get(User, current_user.id)
|
user = await session.get(User, current_user.id)
|
||||||
user.password_hash = auth_service.hash_password(body.new_password)
|
user.password_hash = auth_service.hash_password(body.new_password)
|
||||||
@@ -108,12 +89,7 @@ async def password_reset_request(
|
|||||||
body: PasswordResetRequest,
|
body: PasswordResetRequest,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Request a password reset email.
|
# Always returns 202 regardless of whether email exists — anti-enumeration (T-02-22)
|
||||||
|
|
||||||
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).
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select as _select # noqa: PLC0415 (already imported above)
|
from sqlalchemy import select as _select # noqa: PLC0415 (already imported above)
|
||||||
|
|
||||||
result = await session.execute(_select(User).where(User.email == str(body.email)))
|
result = await session.execute(_select(User).where(User.email == str(body.email)))
|
||||||
@@ -142,12 +118,7 @@ async def password_reset_confirm(
|
|||||||
body: PasswordResetConfirmRequest,
|
body: PasswordResetConfirmRequest,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Confirm a password reset using the token from the email link.
|
# Does NOT issue new tokens — user must re-authenticate through /login (AUTH-05, T-02-21)
|
||||||
|
|
||||||
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).
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
user_id_str = auth_service.decode_password_reset_token(body.token)
|
user_id_str = auth_service.decode_password_reset_token(body.token)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -156,20 +127,17 @@ async def password_reset_confirm(
|
|||||||
detail="Invalid or expired reset link",
|
detail="Invalid or expired reset link",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Password strength validation
|
|
||||||
try:
|
try:
|
||||||
auth_service.validate_password_strength(body.new_password)
|
auth_service.validate_password_strength(body.new_password)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(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):
|
if await auth_service.check_hibp(body.new_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="This password has appeared in a data breach. Choose a different password.",
|
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))
|
user = await session.get(User, uuid.UUID(user_id_str))
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -177,7 +145,6 @@ async def password_reset_confirm(
|
|||||||
detail="Invalid or expired reset link",
|
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)
|
user.password_hash = auth_service.hash_password(body.new_password)
|
||||||
await auth_service.revoke_all_refresh_tokens(session, user.id)
|
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)
|
# Revoke any pre-reset access tokens still within their TTL window (T-7.2-01)
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
@@ -18,7 +10,6 @@ from config import settings
|
|||||||
from deps.utils import get_client_ip
|
from deps.utils import get_client_ip
|
||||||
|
|
||||||
# IP-level rate limiter (SEC-02 — 10 req/min on register/login/refresh)
|
# 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)
|
limiter = Limiter(key_func=get_client_ip)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -51,27 +37,17 @@ async def register(
|
|||||||
body: RegisterRequest,
|
body: RegisterRequest,
|
||||||
session: AsyncSession = Depends(get_db),
|
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:
|
try:
|
||||||
auth_service.validate_password_strength(body.password)
|
auth_service.validate_password_strength(body.password)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
|
|
||||||
# HIBP breach check
|
|
||||||
if await auth_service.check_hibp(body.password):
|
if await auth_service.check_hibp(body.password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="This password has appeared in a data breach. Choose a different password.",
|
detail="This password has appeared in a data breach. Choose a different password.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Duplicate email/handle check
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(User).where(
|
select(User).where(
|
||||||
(User.email == str(body.email)) | (User.handle == body.handle)
|
(User.email == str(body.email)) | (User.handle == body.handle)
|
||||||
@@ -83,7 +59,6 @@ async def register(
|
|||||||
detail="Email or handle already in use",
|
detail="Email or handle already in use",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create user and quota
|
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
new_user = User(
|
new_user = User(
|
||||||
id=user_id,
|
id=user_id,
|
||||||
@@ -132,23 +107,11 @@ async def login(
|
|||||||
response: Response,
|
response: Response,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Authenticate a user and issue tokens.
|
# Per-account rate limiting (SEC-02): Redis counter keyed by email, checked before any DB lookup
|
||||||
|
|
||||||
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)
|
|
||||||
redis_client = request.app.state.redis
|
redis_client = request.app.state.redis
|
||||||
rate_key = f"login_attempts:{body.email}"
|
rate_key = f"login_attempts:{body.email}"
|
||||||
count = await redis_client.incr(rate_key)
|
count = await redis_client.incr(rate_key)
|
||||||
if count == 1:
|
if count == 1:
|
||||||
# Set TTL only on first increment (15-minute window)
|
|
||||||
await redis_client.expire(rate_key, 900)
|
await redis_client.expire(rate_key, 900)
|
||||||
if count > 10:
|
if count > 10:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -156,11 +119,9 @@ async def login(
|
|||||||
detail="Too many login attempts. Try again in 15 minutes.",
|
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)))
|
result = await session.execute(select(User).where(User.email == str(body.email)))
|
||||||
user: Optional[User] = result.scalar_one_or_none()
|
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)
|
_ip = get_client_ip(request)
|
||||||
|
|
||||||
# Verify password (anti-enumeration: same error regardless of whether user exists)
|
# Verify password (anti-enumeration: same error regardless of whether user exists)
|
||||||
@@ -180,7 +141,6 @@ async def login(
|
|||||||
detail="Incorrect email or password",
|
detail="Incorrect email or password",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Active check
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -191,10 +151,8 @@ async def login(
|
|||||||
if user.password_must_change:
|
if user.password_must_change:
|
||||||
return {"requires_password_change": True, "user_id": str(user.id)}
|
return {"requires_password_change": True, "user_id": str(user.id)}
|
||||||
|
|
||||||
# TOTP second-factor dispatch
|
|
||||||
if user.totp_enabled:
|
if user.totp_enabled:
|
||||||
if body.totp_code is None and body.backup_code is None:
|
if body.totp_code is None and body.backup_code is None:
|
||||||
# Challenge: prompt for second factor
|
|
||||||
return {"requires_totp": True}
|
return {"requires_totp": True}
|
||||||
|
|
||||||
if body.totp_code is not None:
|
if body.totp_code is not None:
|
||||||
@@ -223,7 +181,6 @@ async def login(
|
|||||||
ip_address=_ip,
|
ip_address=_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Issue tokens
|
|
||||||
access_token = auth_service.create_access_token(
|
access_token = auth_service.create_access_token(
|
||||||
str(user.id),
|
str(user.id),
|
||||||
user.role,
|
user.role,
|
||||||
@@ -266,12 +223,7 @@ async def refresh_token(
|
|||||||
response: Response,
|
response: Response,
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Rotate the refresh token.
|
# Token reuse (revoked token presented) revokes entire family — family revocation security contract
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
raw_token = request.cookies.get("refresh_token")
|
raw_token = request.cookies.get("refresh_token")
|
||||||
if not raw_token:
|
if not raw_token:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -292,7 +244,6 @@ async def refresh_token(
|
|||||||
detail="Invalid or expired refresh token",
|
detail="Invalid or expired refresh token",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Look up user for response body
|
|
||||||
user = await session.get(User, uuid.UUID(user_id_str))
|
user = await session.get(User, uuid.UUID(user_id_str))
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -300,7 +251,6 @@ async def refresh_token(
|
|||||||
detail="User not found or deactivated",
|
detail="User not found or deactivated",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set new refresh cookie
|
|
||||||
_set_refresh_cookie(response, new_raw)
|
_set_refresh_cookie(response, new_raw)
|
||||||
|
|
||||||
access_token = auth_service.create_access_token(
|
access_token = auth_service.create_access_token(
|
||||||
@@ -325,7 +275,6 @@ async def refresh_token(
|
|||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(request: Request, response: Response, session: AsyncSession = Depends(get_db)):
|
async def logout(request: Request, response: Response, session: AsyncSession = Depends(get_db)):
|
||||||
"""Revoke current refresh token and clear the cookie."""
|
|
||||||
import hashlib as _hashlib
|
import hashlib as _hashlib
|
||||||
|
|
||||||
_ip = get_client_ip(request)
|
_ip = get_client_ip(request)
|
||||||
@@ -365,7 +314,6 @@ async def logout_all(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Sign out of all devices: revoke all refresh tokens for current user."""
|
|
||||||
_ip = get_client_ip(request)
|
_ip = get_client_ip(request)
|
||||||
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
|
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
|
||||||
# D-13: sign-out-all event
|
# D-13: sign-out-all event
|
||||||
@@ -387,7 +335,6 @@ async def logout_all(
|
|||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def get_me(current_user: User = Depends(get_current_user)):
|
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)
|
return _user_dict(current_user)
|
||||||
|
|
||||||
|
|
||||||
@@ -398,11 +345,6 @@ async def get_my_quota(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db),
|
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)
|
q = await session.get(Quota, current_user.id)
|
||||||
if q is None:
|
if q is None:
|
||||||
raise HTTPException(status_code=404, detail="Quota not found")
|
raise HTTPException(status_code=404, detail="Quota not found")
|
||||||
@@ -415,11 +357,7 @@ async def get_my_quota(
|
|||||||
async def get_my_preferences(
|
async def get_my_preferences(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return the current user's PDF open mode preference (D-10).
|
# Falls back to 'in_app' if the column is absent (migration not yet run)
|
||||||
|
|
||||||
Both regular users and admins can read their own preferences.
|
|
||||||
Falls back to 'in_app' if the column is absent (migration not yet run).
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
pdf_open_mode = current_user.pdf_open_mode
|
pdf_open_mode = current_user.pdf_open_mode
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
@@ -435,11 +373,7 @@ async def update_my_preferences(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Update the current user's PDF open mode preference (D-10).
|
# Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05)
|
||||||
|
|
||||||
Both regular users and admins can update their own preferences.
|
|
||||||
Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05).
|
|
||||||
"""
|
|
||||||
user = await session.get(User, current_user.id)
|
user = await session.get(User, current_user.id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -34,12 +26,6 @@ async def totp_setup(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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:
|
if current_user.totp_enabled:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -59,14 +45,7 @@ async def enable_totp(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Enable TOTP for the current user.
|
# Backup codes are returned here ONLY — stored as Argon2 hashes, never returned again (T-02-19)
|
||||||
|
|
||||||
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).
|
|
||||||
"""
|
|
||||||
redis_client = request.app.state.redis
|
redis_client = request.app.state.redis
|
||||||
ok = await auth_service.verify_totp(session, current_user.id, body.code, redis_client)
|
ok = await auth_service.verify_totp(session, current_user.id, body.code, redis_client)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -75,12 +54,10 @@ async def enable_totp(
|
|||||||
detail="Incorrect or expired code",
|
detail="Incorrect or expired code",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark TOTP as enabled
|
|
||||||
user = await session.get(User, current_user.id)
|
user = await session.get(User, current_user.id)
|
||||||
user.totp_enabled = True
|
user.totp_enabled = True
|
||||||
await session.flush()
|
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)
|
plain_codes = auth_service.generate_backup_codes(10)
|
||||||
await auth_service.store_backup_codes(session, current_user.id, plain_codes)
|
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),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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)
|
_ip = get_client_ip(request)
|
||||||
user = await session.get(User, current_user.id)
|
user = await session.get(User, current_user.id)
|
||||||
user.totp_enabled = False
|
user.totp_enabled = False
|
||||||
user.totp_secret = None
|
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))
|
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)
|
# Revoke other sessions; keep current one alive via skip_token_hash (CR-03)
|
||||||
|
|||||||
@@ -62,19 +62,6 @@ async def stream_document_content(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_regular_user),
|
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
|
request.state.current_user = current_user
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(doc_id)
|
uid = uuid.UUID(doc_id)
|
||||||
@@ -97,9 +84,6 @@ async def stream_document_content(
|
|||||||
if share is None:
|
if share is None:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
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:
|
try:
|
||||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
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)
|
storage_backend = await _doc_pkg.get_storage_backend_for_document(doc, current_user, session)
|
||||||
|
|||||||
@@ -99,12 +99,10 @@ async def list_documents(
|
|||||||
items.append(d)
|
items.append(d)
|
||||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
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)
|
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
|
||||||
|
|
||||||
stmt = select(Document).where(Document.user_id == current_user.id)
|
stmt = select(Document).where(Document.user_id == current_user.id)
|
||||||
|
|
||||||
# Topic filter (join-based, same as list_metadata)
|
|
||||||
if topic is not None:
|
if topic is not None:
|
||||||
stmt = (
|
stmt = (
|
||||||
stmt.join(DocumentTopic, DocumentTopic.document_id == Document.id)
|
stmt.join(DocumentTopic, DocumentTopic.document_id == Document.id)
|
||||||
@@ -112,7 +110,6 @@ async def list_documents(
|
|||||||
.where(Topic.name == topic)
|
.where(Topic.name == topic)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Folder filter
|
|
||||||
if folder_id is not None:
|
if folder_id is not None:
|
||||||
try:
|
try:
|
||||||
folder_uuid = uuid.UUID(folder_id)
|
folder_uuid = uuid.UUID(folder_id)
|
||||||
@@ -120,7 +117,6 @@ async def list_documents(
|
|||||||
raise HTTPException(status_code=404, detail="Folder not found")
|
raise HTTPException(status_code=404, detail="Folder not found")
|
||||||
stmt = stmt.where(Document.folder_id == folder_uuid)
|
stmt = stmt.where(Document.folder_id == folder_uuid)
|
||||||
|
|
||||||
# Sort
|
|
||||||
sort_col = Document.created_at # default: date
|
sort_col = Document.created_at # default: date
|
||||||
if sort == "name":
|
if sort == "name":
|
||||||
sort_col = Document.filename
|
sort_col = Document.filename
|
||||||
@@ -147,13 +143,11 @@ async def list_documents(
|
|||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
docs_orm = result.scalars().all()
|
docs_orm = result.scalars().all()
|
||||||
|
|
||||||
# is_shared subquery
|
|
||||||
shared_result = await session.execute(
|
shared_result = await session.execute(
|
||||||
select(Share.document_id).where(Share.owner_id == current_user.id)
|
select(Share.document_id).where(Share.owner_id == current_user.id)
|
||||||
)
|
)
|
||||||
shared_ids = {row[0] for row in shared_result.fetchall()}
|
shared_ids = {row[0] for row in shared_result.fetchall()}
|
||||||
|
|
||||||
# Serialize
|
|
||||||
all_items = []
|
all_items = []
|
||||||
for doc in docs_orm:
|
for doc in docs_orm:
|
||||||
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
|
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
|
||||||
@@ -199,7 +193,6 @@ async def get_document(
|
|||||||
|
|
||||||
is_recipient = False
|
is_recipient = False
|
||||||
if doc.user_id != current_user.id:
|
if doc.user_id != current_user.id:
|
||||||
# Allow recipients of an active share to view the document
|
|
||||||
share_result = await session.execute(
|
share_result = await session.execute(
|
||||||
select(Share).where(
|
select(Share).where(
|
||||||
Share.document_id == uid,
|
Share.document_id == uid,
|
||||||
@@ -250,7 +243,6 @@ async def patch_document(
|
|||||||
if doc is None or doc.user_id != current_user.id:
|
if doc is None or doc.user_id != current_user.id:
|
||||||
raise HTTPException(404, "Document not found")
|
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:
|
if not body.model_fields_set:
|
||||||
raise HTTPException(422, "At least one field (filename, folder_id) must be provided")
|
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
|
_doc_id = doc.id
|
||||||
_ip = get_client_ip(request)
|
_ip = get_client_ip(request)
|
||||||
|
|
||||||
# Cloud routing: attempt provider delete unless remove_only is set
|
|
||||||
if is_cloud and not remove_only:
|
if is_cloud and not remove_only:
|
||||||
try:
|
try:
|
||||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ async def upload_document(
|
|||||||
"""
|
"""
|
||||||
request.state.current_user = current_user
|
request.state.current_user = current_user
|
||||||
if target_backend == "minio":
|
if target_backend == "minio":
|
||||||
# MinIO: generate a presigned URL for client-side PUT (existing flow reused)
|
|
||||||
doc_id = uuid.uuid4()
|
doc_id = uuid.uuid4()
|
||||||
suffix = Path(file.filename or "file").suffix.lower()
|
suffix = Path(file.filename or "file").suffix.lower()
|
||||||
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
|
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)}
|
return {"upload_url": upload_url, "document_id": str(doc_id)}
|
||||||
|
|
||||||
# Cloud backend path
|
|
||||||
if target_backend not in _CLOUD_PROVIDERS:
|
if target_backend not in _CLOUD_PROVIDERS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
@@ -174,11 +172,9 @@ async def upload_document(
|
|||||||
detail=f"No active {target_backend} connection found. Please connect in Settings.",
|
detail=f"No active {target_backend} connection found. Please connect in Settings.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Decrypt per-user credentials
|
|
||||||
master_key = settings.cloud_creds_key.encode()
|
master_key = settings.cloud_creds_key.encode()
|
||||||
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
|
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
|
||||||
|
|
||||||
# Read file bytes
|
|
||||||
file_bytes = await file.read()
|
file_bytes = await file.read()
|
||||||
filename = file.filename or "upload"
|
filename = file.filename or "upload"
|
||||||
content_type = file.content_type or "application/octet-stream"
|
content_type = file.content_type or "application/octet-stream"
|
||||||
@@ -186,7 +182,6 @@ async def upload_document(
|
|||||||
|
|
||||||
doc_id = uuid.uuid4()
|
doc_id = uuid.uuid4()
|
||||||
|
|
||||||
# Instantiate backend and upload
|
|
||||||
if target_backend == "google_drive":
|
if target_backend == "google_drive":
|
||||||
from storage.google_drive_backend import GoogleDriveBackend # lazy import
|
from storage.google_drive_backend import GoogleDriveBackend # lazy import
|
||||||
cloud_backend = GoogleDriveBackend(credentials)
|
cloud_backend = GoogleDriveBackend(credentials)
|
||||||
@@ -319,13 +314,11 @@ async def confirm_upload(
|
|||||||
row = result.fetchone()
|
row = result.fetchone()
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
# Quota exceeded — fetch current quota state for the 413 body
|
|
||||||
quota_result = await session.execute(
|
quota_result = await session.execute(
|
||||||
text("SELECT used_bytes, limit_bytes FROM quotas WHERE user_id = :uid"),
|
text("SELECT used_bytes, limit_bytes FROM quotas WHERE user_id = :uid"),
|
||||||
{"uid": doc.user_id.hex},
|
{"uid": doc.user_id.hex},
|
||||||
)
|
)
|
||||||
q = quota_result.fetchone()
|
q = quota_result.fetchone()
|
||||||
# Delete the pending Document row and best-effort remove the MinIO object
|
|
||||||
await session.delete(doc)
|
await session.delete(doc)
|
||||||
try:
|
try:
|
||||||
await get_storage_backend().delete_object(doc.object_key)
|
await get_storage_backend().delete_object(doc.object_key)
|
||||||
|
|||||||
Reference in New Issue
Block a user