- Added import and app.include_router(cloud_router) for Phase 5 cloud endpoints
- Added app.include_router(cloud_users_router) for /api/users/me/default-storage
- Both routers registered after the Phase 4 audit router
- All 6 cloud routes + default-storage route visible in app.routes
- CloudConnectionError(reason=) defined in this module — token_expired | invalid_grant
- All 7 StorageBackend methods implemented as async coroutines
- Every sync googleapiclient call wrapped in asyncio.to_thread() (Pitfall 7)
- cache_discovery=False on build() prevents /tmp directory traversal (T-05-03-05)
- presigned_get_url and generate_presigned_put_url raise NotImplementedError (D-14)
- HttpError 401 raises CloudConnectionError(reason='token_expired')
- HttpError 400 with 'invalid_grant' raises CloudConnectionError(reason='invalid_grant')
- HttpError 404 on delete_object is silently swallowed (no-op per contract)
- Backend is stateless — no DB writes (B2 design, D-05/D-06)
- cloud_cache.py: module-level TTLCache(maxsize=1000, ttl=60) singleton with
threading.Lock for concurrent access safety (RESEARCH.md Pattern 8 / D-16)
- get_cloud_folders_cached(): async function; calls fetch_fn OUTSIDE the lock
to avoid blocking the event loop during cloud API calls
- invalidate_provider_cache(): removes all cache entries for a user+provider prefix
- storage/__init__.py: adds get_storage_backend_for_document() async factory
— returns MinIOBackend for minio docs; queries CloudConnection (scoped to user.id),
decrypts credentials, and lazy-imports cloud backends to avoid circular imports
— raises HTTPException(503) if connection missing or not ACTIVE (T-05-02-04)
- validate_cloud_url(): blocks RFC-1918 (10.x, 172.16.x, 192.168.x), loopback (127.x),
link-local (169.254.x), IPv6 loopback (::1), ULA (fc00::/7), and 'localhost' string;
resolves DNS via socket.getaddrinfo BEFORE IP check (anti-DNS-rebinding per D-17)
- _derive_fernet_key(): creates fresh HKDF-SHA256 instance per call (AlreadyFinalized
pitfall avoided per RESEARCH.md Pitfall 3); uses user_id as salt for per-user isolation
- encrypt_credentials(): Fernet-encrypts JSON-serialised credentials dict; returns str
- decrypt_credentials(): decrypts Fernet token back to original dict
- [Rule 1 - Bug] Fixed test_allows_public_https to use 8.8.8.8 IP (cloud.example.com
does not resolve in offline CI environments)
Plans 05-01..05-08 cover all CLOUD-01..07 requirements plus SEC-09
(cloud credential cleanup on account deletion). Key design decisions:
API layer owns D-05 token refresh + DB update via _call_cloud_op helper;
backends are stateless signal-raisers. Vitest tests added for frontend
store and SettingsCloudTab. RESEARCH.md open questions resolved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
B1: Mark RESEARCH.md Open Questions as (RESOLVED) with decision text for all 3
B2: Backends now stateless — raise CloudConnectionError(reason=) only; API layer
in cloud.py owns token refresh + DB update via _call_cloud_op helper
B3: Add Task 3 to Plan 05 — cloud connection + object cleanup on account deletion (SEC-09)
B4: Add frontend_url setting to Plan 01 Task 1; Plan 05 uses settings.frontend_url
for OAuth callback redirects
W1: ROADMAP.md Phase 5 now correctly labels Plans 03+04 as Wave 3 (not Wave 2)
W2: Plan 06 invalid_grant test now asserts both 503 HTTP response AND DB REQUIRES_REAUTH
W3: Plan 06 Task 2 split into unit tests (4, cloud_utils.py) and integration tests (11, HTTP)
W4: Plan 07 adds Vitest tests for cloudConnections store (4 tests) and SettingsCloudTab
mount test (2 tests) per CLAUDE.md testing protocol
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify all 6 PyPI packages (cryptography, google-auth-oauthlib,
google-api-python-client, msal, webdavclient3, cachetools); all pass
slopcheck [OK]. Document HKDF+Fernet pattern, OAuth2 flows for Google
Drive and OneDrive, webdavclient3+asyncio.to_thread for WebDAV/Nextcloud,
SSRF ipaddress module approach, Redis OAuth state pattern, and
cachetools.TTLCache folder listing cache. Confirm cloud_connections table
and storage_backend columns already exist — no new Alembic migration needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UAT: 14/15 passed. Bug fixed: folders/rootFolders array alias in fetchFolders caused
duplicate folder row on creation (rootFolders = [...list] breaks the shared reference).
Sidebar: Folders section now has a collapse/expand chevron, collapsed by default.
State: Phase 4 complete, Phase 5 (Cloud Storage Backends) is next.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the unified file manager view (Windows Explorer-style), collapsible
folder tree sidebar item, full vitest test suite (55 tests, 4 files), and
commits all Phase 4 backend/frontend fixes that were staged but uncommitted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add CloudConnectionOut Pydantic model (SEC-08): credentials_enc deliberately excluded
- Implement DELETE /api/admin/users/{id} (SEC-09): collects user docs, deletes MinIO
objects best-effort before DB delete; audit log written within same transaction
- Add write_audit_log calls to: create_user (admin.user_created), update_user_status
(admin.user_deactivated/admin.user_activated), update_user_quota (admin.quota_changed),
update_ai_config (admin.ai_provider_assigned), delete_user (admin.user_deleted)
- Add Request param to all admin state-changing handlers for IP extraction
- Fix test_admin_impersonation_not_found: accept 405 in addition to 404/422
(expected: DELETE /users/{id} exists now, so GET returns 405 — no impersonation
route still satisfied, just a different HTTP status for non-existent method)
- Add PreferencesUpdate Pydantic model with Literal['in_app', 'new_tab'] validation
- Add GET /api/auth/me/preferences — returns current pdf_open_mode
- Add PATCH /api/auth/me/preferences — validates + stores + returns updated value
- Both endpoints use get_current_user (admin can set own prefs, D-10)
- Add 7 preference tests: default GET, in_app, new_tab, invalid 422, persist,
and two unauthenticated 401 tests
- Add _parse_range() helper: validates Range header bounds, raises 416 on invalid
- Add stream_document_content endpoint with get_regular_user dep (admin → 403)
- Access check: owner OR Share.recipient_id; neither → 404
- Bytes fetched via get_object() only — presigned_get_url() never called
- Range requests return 206 + Content-Range header
- Add pdf_open_mode column to User ORM model (migration 0004 already applied)
- Use HTTP_416_RANGE_NOT_SATISFIABLE (non-deprecated constant)
- POST /api/shares: grant share by recipient_handle; 400 self-share, 404 bad UUID/doc/user, 409 duplicate
- GET /api/shares?document_id: list shares owned by current user for a document
- GET /api/shares/received: virtual "shared with me" folder — metadata only (no extracted_text)
- DELETE /api/shares/{share_id}: revoke with IDOR protection (share.owner_id != current_user.id → 404)
- IntegrityError on UniqueConstraint(document_id, recipient_id) → 409
- write_audit_log called for share.granted and share.revoked (D-14)
- /received defined before /{share_id} in router to prevent FastAPI path parameter conflict
- No quota table touched — recipient quota never modified by share operations (T-04-04-04)
- backend/api/folders.py: POST /api/folders (create), GET /api/folders (list),
GET /api/folders/{id} (breadcrumb), PATCH /api/folders/{id} (rename),
DELETE /api/folders/{id} (cascade-delete + atomic quota decrement),
PATCH /api/documents/{id}/folder (move document)
- All folder endpoints use get_regular_user (admin gets 403); 404 for IDOR
- IntegrityError caught -> 409 on duplicate folder name under same parent
- WITH RECURSIVE CTE for subtree collection with SQLite fallback (OperationalError)
- Atomic quota decrement with CASE WHEN pattern (SQLite compat)
- MinIO object deletion best-effort (per-object try/except)
- write_audit_log called after folder.created, folder.renamed, folder.deleted
- backend/api/documents.py: add sort, order, folder_id, q params to list_documents;
add is_shared field to each document in response using Share subquery
- backend/main.py: register folders_router and document_move_router
- Creates backend/services/audit.py with write_audit_log() function
- Uses session.flush() not session.commit() per D-14 architectural requirement
- Catches and logs all exceptions (never re-raises) so audit failure is non-fatal
- Correct AuditLog ORM attribute metadata_ (not metadata) per models.py
- Add users.pdf_open_mode column via batch_alter_table (server_default='in_app')
- Create GIN expression index ix_documents_fts on documents.extracted_text via raw SQL (Alembic #1390)
- Create audit-logs MinIO bucket gated on MINIO_ENDPOINT env var
- Add MinIOBackend.put_object_raw() for caller-supplied bucket+key uploads (audit CSV export)