Compare commits
17
Commits
4f6ee06f51
...
83cdf28231
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83cdf28231 | ||
|
|
ac95c1243f | ||
|
|
2263abb2eb | ||
|
|
0df2942e4c | ||
|
|
339f5a0c82 | ||
|
|
bac5dcfe3d | ||
|
|
5972a62041 | ||
|
|
76785b4d96 | ||
|
|
42ab542e25 | ||
|
|
f9e5a31945 | ||
|
|
e3c681f99d | ||
|
|
6d238a4ff3 | ||
|
|
11228306e4 | ||
|
|
3307282570 | ||
|
|
ce67b9f98a | ||
|
|
210670d033 | ||
|
|
e97ca164d7 |
+4
-4
@@ -17,7 +17,7 @@ POSTGRES_PASSWORD=changeme_super
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=changeme_minio_root
|
||||
MINIO_ENDPOINT=minio:9000
|
||||
# App-level access key — minimal permissions on docuvault bucket only
|
||||
# App-level access key. docker-compose.yml provisions this user and a bucket-scoped policy.
|
||||
MINIO_ACCESS_KEY=docuvault_app
|
||||
MINIO_SECRET_KEY=changeme_minio_app
|
||||
MINIO_BUCKET=docuvault
|
||||
@@ -51,9 +51,9 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@docuvault.local
|
||||
|
||||
# ── CORS (Phase 2 — D-09) ────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed origins. Default: http://localhost:5173
|
||||
# Example for production: https://app.docuvault.example.com
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
# JSON list of allowed origins. Default: ["http://localhost:5173"]
|
||||
# Example for production: ["https://app.docuvault.example.com"]
|
||||
CORS_ORIGINS=["http://localhost:5173"]
|
||||
|
||||
# ── Cloud Storage Backends (Phase 5) ─────────────────────────────────────────
|
||||
# Master key for HKDF per-user cloud credential encryption.
|
||||
|
||||
@@ -65,7 +65,7 @@ Every user's documents — and the credentials they use to store them — are in
|
||||
|
||||
## Context
|
||||
|
||||
- **Current state**: v0.2 in progress — Phase 9 complete (2026-06-13). Admin panel rearchitected as standalone /admin/* route subtree with AdminLayout, 5 dedicated views, corrected Vue Router 4 guard, admin login redirect, and new GET /api/admin/overview backend endpoint. Phase 10 (UX & Interaction) up next.
|
||||
- **Current state**: v0.2 in progress — Phase 10 complete (2026-06-16). UX & Interaction layer ships: empty states, loading skeletons, keyboard shortcuts (/, U, N, Escape), OS drag-drop upload, toast notifications, BreadcrumbBar, AppIcon registry, drag-to-move, Teleport dropdowns. All 6 UAT gaps closed (plan 10-13): sidebar shimmer, search-at-root visibility, admin sidebar isolation, keyboard dispatch via matched.find(), Escape modifier, OS drop capture phase. 219 tests pass. Phase 11 (Visual Design, Responsive Layout & Cleanup) up next.
|
||||
- **Tech stack**: FastAPI 0.136+ (Python 3.12), SQLAlchemy 2.0 async, Alembic, MinIO SDK; Vue 3 (Options API), Pinia, Vue Router 4, Vite, Tailwind CSS.
|
||||
- **Code quality**: v0.1 was built feature-first under time pressure. Both backend and frontend contain duplication, inconsistent patterns, and components that grew beyond their original scope. v0.2 addresses this systematically.
|
||||
- **Admin panel**: Now a standalone /admin/* route subtree with AdminLayout as the route component, AdminSidebar with 5 nav links, and 5 dedicated view components. Old AdminView.vue and tab components deleted. Admin users are redirected to /admin on login; non-admin users are blocked from /admin/* by a correct to.matched.some() guard.
|
||||
@@ -107,6 +107,8 @@ Every user's documents — and the credentials they use to store them — are in
|
||||
|
||||
This document evolves at phase transitions and milestone boundaries.
|
||||
|
||||
Last updated: 2026-06-16
|
||||
|
||||
**After each phase transition** (via `/gsd-transition`):
|
||||
1. Requirements invalidated? → Move to Out of Scope with reason
|
||||
2. Requirements validated? → Move to Validated with phase reference
|
||||
|
||||
+12
-12
@@ -136,15 +136,15 @@ Every line of code written or modified in v0.2 must be:
|
||||
| UX-13 | Phase 10 | Pending |
|
||||
| UX-14 | Phase 10 | Pending |
|
||||
| CODE-05 | Phase 10 | Pending |
|
||||
| VISUAL-01 | Phase 11 | Pending |
|
||||
| VISUAL-02 | Phase 11 | Pending |
|
||||
| VISUAL-03 | Phase 11 | Pending |
|
||||
| VISUAL-04 | Phase 11 | Pending |
|
||||
| RESP-01 | Phase 11 | Pending |
|
||||
| RESP-02 | Phase 11 | Pending |
|
||||
| RESP-03 | Phase 11 | Pending |
|
||||
| RESP-04 | Phase 11 | Pending |
|
||||
| RESP-05 | Phase 11 | Pending |
|
||||
| CODE-07 | Phase 11 | Pending |
|
||||
| PERF-02 | Phase 11 | Pending |
|
||||
| PERF-03 | Phase 11 | Pending |
|
||||
| VISUAL-01 | Phase 11 | Planned: 11-05 |
|
||||
| VISUAL-02 | Phase 11 | Planned: 11-04 |
|
||||
| VISUAL-03 | Phase 11 | Planned: 11-05 |
|
||||
| VISUAL-04 | Phase 11 | Planned: 11-05 |
|
||||
| RESP-01 | Phase 11 | Planned: 11-03 |
|
||||
| RESP-02 | Phase 11 | Planned: 11-03 |
|
||||
| RESP-03 | Phase 11 | Planned: 11-03 |
|
||||
| RESP-04 | Phase 11 | Planned: 11-04 |
|
||||
| RESP-05 | Phase 11 | Planned: 11-03 |
|
||||
| CODE-07 | Phase 11 | Planned: 11-06 |
|
||||
| PERF-02 | Phase 11 | Planned: 11-01, 11-06 |
|
||||
| PERF-03 | Phase 11 | Planned: 11-02 |
|
||||
|
||||
@@ -716,7 +716,7 @@ _Started: 2026-06-07_
|
||||
|-------|----------------|--------|-----------|
|
||||
| 8. Stack Upgrade & Backend Decomposition | 4/8 | In Progress| |
|
||||
| 9. Admin Panel Rearchitecture | 5/5 | Complete | 2026-06-13 |
|
||||
| 10. UX & Interaction | 12/12 | Complete | 2026-06-16 |
|
||||
| 10. UX & Interaction | 13/13 | Complete | 2026-06-16 |
|
||||
| 11. Visual Design, Responsive Layout & Cleanup | 0/TBD | Not started | — |
|
||||
|
||||
---
|
||||
|
||||
+20
-20
@@ -3,30 +3,30 @@ gsd_state_version: 1.0
|
||||
milestone: v0.2
|
||||
milestone_name: Phases
|
||||
current_phase: 10
|
||||
status: completed
|
||||
last_updated: "2026-06-16T08:18:26.700Z"
|
||||
last_activity: 2026-06-16 -- Phase 10 marked complete
|
||||
status: executing
|
||||
last_updated: "2026-06-16T17:05:11.592Z"
|
||||
last_activity: 2026-06-16 -- Phase 10 execution started
|
||||
progress:
|
||||
total_phases: 17
|
||||
completed_phases: 16
|
||||
total_plans: 90
|
||||
completed_plans: 90
|
||||
percent: 94
|
||||
total_phases: 4
|
||||
completed_phases: 2
|
||||
total_plans: 32
|
||||
completed_plans: 25
|
||||
percent: 50
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
**Project:** DocuVault
|
||||
**Status:** Phase 10 complete
|
||||
**Status:** Executing Phase 10
|
||||
**Current Phase:** 10
|
||||
**Last Updated:** 2026-06-13
|
||||
**Last Updated:** 2026-06-16
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 10 — COMPLETE
|
||||
Plan: 1 of 12
|
||||
Status: Phase 10 complete
|
||||
Last activity: 2026-06-16 -- Phase 10 marked complete
|
||||
Phase: 10 (ux-interaction) — EXECUTING
|
||||
Plan: 1 of 13
|
||||
Status: Executing Phase 10
|
||||
Last activity: 2026-06-16 -- Phase 10 execution started
|
||||
|
||||
## Phase Status
|
||||
|
||||
@@ -34,17 +34,17 @@ Last activity: 2026-06-16 -- Phase 10 marked complete
|
||||
|-------|-------------|--------|
|
||||
| 8. Stack Upgrade & Backend Decomposition | PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08 | **Complete (8/8 plans)** |
|
||||
| 9. Admin Panel Rearchitecture | ADMIN-08..12, CODE-06, CODE-09 | **Complete (5/5 plans)** |
|
||||
| 10. UX & Interaction | UX-01..14, CODE-05 | Not started |
|
||||
| 11. Visual Design, Responsive Layout & Cleanup | VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 | Not started |
|
||||
| 10. UX & Interaction | UX-01..14, CODE-05 | **Complete (12/12 plans)** |
|
||||
| 11. Visual Design, Responsive Layout & Cleanup | VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 | **Planned (0/6 plans)** |
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Phases complete | 2 / 4 |
|
||||
| Phases complete | 3 / 4 |
|
||||
| Requirements mapped | 40 / 40 |
|
||||
| Plans written | 83 |
|
||||
| Plans complete | 83 |
|
||||
| Plans written | 96 |
|
||||
| Plans complete | 90 |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -85,6 +85,6 @@ _Updated at each phase transition._
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Last session | 2026-06-13 — Phase 9 complete; UAT 9/9 passed |
|
||||
| Next action | /gsd:discuss-phase 10 then /gsd:plan-phase 10 |
|
||||
| Next action | /gsd:execute-phase 11 plan 11-01 |
|
||||
| Pending decisions | None |
|
||||
| Resume file | None |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
phase: 10
|
||||
plan: 13
|
||||
subsystem: frontend-ux
|
||||
tags: [gap-closure, uat, shimmer, keyboard, admin-layout, drag-drop, search]
|
||||
dependency_graph:
|
||||
requires: [10-01, 10-05, 10-06, 10-07, 10-08, 10-12]
|
||||
provides: [uat-gap-closure-all-6, phase-10-sign-off]
|
||||
affects: [TreeItem.vue, StorageBrowser.vue, App.vue, SearchBar.vue, OsDragOverlay.vue]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "router.currentRoute.value.matched.find(r => r.instances?.default) for direct component instance access"
|
||||
- "window.addEventListener capture=true for drag-drop above bubble-phase folder handlers"
|
||||
- "@keydown.escape.prevent.stop to suppress type=search native clear+blur"
|
||||
key_files:
|
||||
created:
|
||||
- frontend/src/components/ui/__tests__/TreeItem.test.js
|
||||
- frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js
|
||||
modified:
|
||||
- frontend/src/components/ui/TreeItem.vue
|
||||
- frontend/src/components/storage/StorageBrowser.vue
|
||||
- frontend/src/App.vue
|
||||
- frontend/src/components/documents/SearchBar.vue
|
||||
- frontend/src/components/layout/OsDragOverlay.vue
|
||||
- frontend/src/__tests__/keyboard.test.js
|
||||
decisions:
|
||||
- "Use matched.find(r => r.instances?.default) to access FileManagerView instance directly instead of routeViewRef (which resolves to RouterView proxy)"
|
||||
- "Shimmer test uses refresh() flow (expanded=true + reload) since toggleExpand sets expanded only after load completes"
|
||||
- "StorageBrowser showSearch: OR condition (local OR cloud) instead of AND with breadcrumb length"
|
||||
metrics:
|
||||
duration: ~15 min
|
||||
completed: 2026-06-16
|
||||
tasks_completed: 4
|
||||
files_changed: 8
|
||||
---
|
||||
|
||||
# Phase 10 Plan 13: UAT Gap Closure — All 6 Root Causes Summary
|
||||
|
||||
Closed all 6 UAT gaps that blocked Phase 10 sign-off. Five production files modified surgically, three test files added, 219 tests passing.
|
||||
|
||||
## What Was Built
|
||||
|
||||
Targeted fixes for 6 root-cause gaps from UAT (`10-UAT.md`):
|
||||
|
||||
- **Gap 1 — Sidebar shimmer**: `TreeItem.vue` loading branch replaced with 3 animate-pulse shimmer rows (icon + text placeholder pattern from AppSidebar.vue). `Loading…` text eliminated.
|
||||
- **Gap 2 — Search at root**: `StorageBrowser.vue` `showSearch` computed changed from `mode==='local' && breadcrumb.length > 0` to `mode==='local' || mode==='cloud'`. Search and sort controls now visible at the root of both local and cloud browsers.
|
||||
- **Gap 3 — Admin sidebar bleed**: `App.vue` gained a `v-else-if` branch for admin routes that renders only `<router-view />` with no AppSidebar or main wrapper.
|
||||
- **Gap 4 — Keyboard shortcuts broken**: `App.vue` `routeViewRef` (which resolves to RouterView proxy) replaced with `getFileManagerInstance()` that uses `router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default`. All keyboard dispatch calls (`/`, Escape, U, N) and OS drop handler updated.
|
||||
- **Gap 5 — Escape clears search but loses focus**: `SearchBar.vue` escape handler changed from `@keydown.escape` to `@keydown.escape.prevent.stop`. `.prevent` stops browser's native clear+blur on `type="search"` inputs, `.stop` prevents bubbling to App.vue's global handler.
|
||||
- **Gap 6 — OS drag-drop not uploading**: `OsDragOverlay.vue` `drop` listener changed to capture phase (`addEventListener('drop', this.onDrop, true)`). Both `addEventListener` and `removeEventListener` carry the `true` third argument so cleanup works correctly.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Commit | Description |
|
||||
|------|--------|-------------|
|
||||
| 1 | 76785b4 | Shimmer rows (TreeItem.vue) + showSearch fix (StorageBrowser.vue) |
|
||||
| 2 | 5972a62 | Admin sidebar bleed + keyboard instance resolution (App.vue) |
|
||||
| 3 | bac5dcf | Escape modifier (SearchBar.vue) + capture-phase drop (OsDragOverlay.vue) |
|
||||
| 4 | 339f5a0 | Regression tests for all 6 gaps |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-adjusted Issues
|
||||
|
||||
**1. [Rule 1 - Bug] TreeItem shimmer test used refresh() flow instead of direct toggle**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** `toggleExpand()` sets `expanded=true` only AFTER `load()` completes. While `loading=true`, `expanded` is still `false`, so `v-if="expanded"` hides the shimmer block. No state where `expanded=true && loading=true` can be reached via the initial expand path.
|
||||
- **Fix:** Test uses `refresh()` call (which loads while already expanded) to reach the `expanded=true && loading=true` state.
|
||||
- **Files modified:** `frontend/src/components/ui/__tests__/TreeItem.test.js`
|
||||
- **Commit:** 339f5a0
|
||||
|
||||
**2. [Worktree path] Early edits went to main repo instead of worktree**
|
||||
- **Found during:** Task 1-2 (first commit attempt)
|
||||
- **Issue:** First two Edit calls used the main repo path (`/Users/nik/Documents/Progamming/document_scanner/...`) instead of the worktree path (`/Users/nik/Documents/Progamming/document_scanner/.claude/worktrees/agent-a2c859712240996ff/...`). The changes committed to the main repo's `main` branch.
|
||||
- **Fix:** Reverted approach: re-applied all changes to the worktree files using the correct absolute paths. The main repo has two extra commits (f9e5a31, 42ab542) that duplicate the task 1-2 changes — those will be resolved at merge/orchestrator level.
|
||||
- **Commits affected:** 76785b4, 5972a62 are the correct worktree commits.
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
Test Files 30 passed (30)
|
||||
Tests 219 passed (219)
|
||||
Duration ~2.7s
|
||||
```
|
||||
|
||||
8 new tests added:
|
||||
- `TreeItem.test.js`: 3 tests (shimmer visible, no Loading text, Empty branch unchanged)
|
||||
- `StorageBrowser.showSearch.test.js`: 4 tests (local root, local non-root, cloud root, shared=false)
|
||||
- `keyboard.test.js`: 1 new test (Gap 4 instance resolution via router-view)
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all gaps are wired to real component behavior.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — all changes are display-only template modifications and event handler configuration. No new network endpoints, auth paths, or schema changes introduced.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Files created/modified:
|
||||
- [x] `frontend/src/components/ui/TreeItem.vue` — animate-pulse present, Loading text absent
|
||||
- [x] `frontend/src/components/storage/StorageBrowser.vue` — showSearch uses || cloud
|
||||
- [x] `frontend/src/App.vue` — routeViewRef removed, requiresAdmin branch added
|
||||
- [x] `frontend/src/components/documents/SearchBar.vue` — .prevent.stop on escape
|
||||
- [x] `frontend/src/components/layout/OsDragOverlay.vue` — true capture arg on drop
|
||||
- [x] `frontend/src/__tests__/keyboard.test.js` — Gap 4 test appended
|
||||
- [x] `frontend/src/components/ui/__tests__/TreeItem.test.js` — created
|
||||
- [x] `frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js` — created
|
||||
|
||||
Commits verified:
|
||||
- [x] 76785b4 — Task 1
|
||||
- [x] 5972a62 — Task 2
|
||||
- [x] bac5dcf — Task 3
|
||||
- [x] 339f5a0 — Task 4
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
status: resolved
|
||||
phase: 10-ux-interaction
|
||||
source: 10-01-SUMMARY.md, 10-02-SUMMARY.md, 10-03-SUMMARY.md, 10-04-SUMMARY.md, 10-06-SUMMARY.md, 10-07-SUMMARY.md, 10-08-SUMMARY.md, 10-09-SUMMARY.md, 10-10-SUMMARY.md, 10-11-SUMMARY.md, 10-12-SUMMARY.md
|
||||
started: 2026-06-16T00:00:00Z
|
||||
updated: 2026-06-16T19:31:00Z
|
||||
resolved_by: 10-13-PLAN.md
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. File Manager Loading Skeleton
|
||||
expected: Open the file manager. While documents are loading, the content area shows 5 animated shimmer/pulse rows instead of any "Loading…" text. Once loaded, the shimmer rows disappear and real content (or an empty state) renders.
|
||||
result: issue
|
||||
reported: "Cloud folder view: no skeleton visible on load (loading=false on first render). Cloud files unclickable with no feedback."
|
||||
severity: major
|
||||
fix_applied: "loading=ref(true) in CloudFolderView; onFileOpen shows info toast. Commit ce67b9f. Local storage loads too fast to verify manually — skeleton confirmed present in template."
|
||||
|
||||
### 2. Empty State — No Documents
|
||||
expected: In a folder with no documents, the content area shows a styled empty state with an icon (folder or document), a headline like "No documents yet" or similar, and descriptive subtext. Not just a blank white area.
|
||||
result: pass
|
||||
|
||||
### 3. Empty State — No Search Results
|
||||
expected: Type a search query that returns no matches. The content area shows an empty state with a search icon, a "No results" headline, and a "Clear search" link/button that resets the query.
|
||||
result: pass
|
||||
note: Search bar only visible inside a folder (breadcrumb.length > 0) — by design, root shows folders only. Empty state confirmed working inside folder.
|
||||
|
||||
### 4. Sidebar Loading Skeletons
|
||||
expected: On first load, the sidebar's Folders, Cloud, and Topics sections show animated shimmer placeholder rows while their data loads. No plain spinner or "Loading" text.
|
||||
result: issue
|
||||
reported: "Local storage too fast to see any loading. Nextcloud sidebar section does nothing until folder loads — no skeleton or feedback visible during load."
|
||||
severity: major
|
||||
|
||||
### 5. Sidebar Empty States
|
||||
expected: With no folders created, no cloud connections, and no topics, each sidebar section shows a small (compact) empty state: a tiny icon with a brief message like "Create a folder in the file manager", "Connect in Settings", or "No topics yet".
|
||||
result: issue
|
||||
reported: "Empty states confirmed. But search bar and sorting controls are not visible at the root of the cloud and local file browser."
|
||||
severity: major
|
||||
note: Search-at-root absence was previously noted in test 3 as \"by design\", but user is explicitly flagging it as missing expected functionality.
|
||||
|
||||
### 6. Sidebar — No Inline "New Folder" Button
|
||||
expected: The sidebar's Folders section header does NOT have a "New" or "New folder" button next to it. Folder creation happens only via the file manager toolbar.
|
||||
result: pass
|
||||
|
||||
### 7. Breadcrumb Bar in File Manager
|
||||
expected: The file manager shows a breadcrumb bar above the content. At the root it shows "Home". After navigating into a folder it shows "Home > FolderName". Clicking "Home" navigates back to root.
|
||||
result: pass
|
||||
|
||||
### 8. Breadcrumb Bar in Admin Views
|
||||
expected: Admin views (Users, Quotas, AI Config, Audit Log) and Settings show a breadcrumb bar with static segments like "Users", "Settings > Account", etc. No "Home" root button in these views.
|
||||
result: issue
|
||||
reported: "Admin views still show the normal user sidebar."
|
||||
severity: major
|
||||
|
||||
### 9. Toast on Document Delete
|
||||
expected: Delete a document. A toast notification appears in the bottom-right corner with a success message (e.g., "Document deleted"). It auto-dismisses after a few seconds.
|
||||
result: pass
|
||||
|
||||
### 10. Toast on File Upload
|
||||
expected: Upload one or more files. After upload completes, a toast appears summarising the result (e.g., "2 files uploaded" or a per-file message). It appears without a page refresh.
|
||||
result: pass
|
||||
note: User also reported drag-and-drop didn't work — covered in tests 15–17.
|
||||
|
||||
### 11. Keyboard Shortcut — / Focuses Search
|
||||
expected: While viewing the file manager with focus NOT in a text field, press "/". The search bar receives focus (cursor appears inside it). Pressing "/" while already in a text input does NOT trigger this.
|
||||
result: issue
|
||||
reported: "Search bar not visible at directory root (see gap #2). When inside a folder where search IS visible, pressing '/' does nothing."
|
||||
severity: major
|
||||
|
||||
### 12. Keyboard Shortcut — U Opens Upload
|
||||
expected: While viewing the file manager with focus not in a text field, press "U". The file-picker dialog opens (browser native file chooser), same as clicking the upload button.
|
||||
result: issue
|
||||
reported: "Pressing U does not open the file picker."
|
||||
severity: major
|
||||
|
||||
### 13. Keyboard Shortcut — N Starts New Folder
|
||||
expected: While viewing the file manager with focus not in a text field, press "N". The new-folder inline input appears in the content area, same as clicking the "New folder" toolbar button.
|
||||
result: issue
|
||||
reported: "Pressing N does not trigger new folder input."
|
||||
severity: major
|
||||
|
||||
### 14. Keyboard Shortcut — Escape Clears Search
|
||||
expected: With a search query active in the file manager, press "Escape". The search field clears and the full document list returns.
|
||||
result: issue
|
||||
reported: "Field clears on Escape, but search no longer works afterwards — cannot type a new query."
|
||||
severity: major
|
||||
|
||||
### 15. OS Drag Overlay
|
||||
expected: From the OS (Finder/Explorer), drag a file and hover it over the browser window. A full-screen semi-transparent overlay appears saying something like "Drop files to upload". Releasing the file starts the upload.
|
||||
result: issue
|
||||
reported: "Overlay appears correctly, but dropping the file does not start the upload."
|
||||
severity: major
|
||||
|
||||
### 16. Drag Document to Folder
|
||||
expected: In the file manager, drag a document row onto a folder row. The folder row highlights while the document hovers over it. Releasing drops the document into the folder (it moves; the folder item count updates).
|
||||
result: pass
|
||||
|
||||
### 17. Click-After-Drag Guard
|
||||
expected: After dragging a document (without dropping it onto a folder — just drag and release), the document does NOT open or navigate. The drag gesture does not accidentally trigger a "file open" action.
|
||||
result: pass
|
||||
|
||||
### 18. Admin View Skeletons
|
||||
expected: Open the Admin > Audit Log or Admin > Users page while data loads. The table body shows skeleton rows (animated shimmer cells) instead of a spinner or "Loading…" text.
|
||||
result: pass
|
||||
note: Skeleton visible but very briefly — data loads fast locally. Skeleton confirmed present.
|
||||
|
||||
### 19. Admin Audit Log Empty State
|
||||
expected: With no audit log entries (or with filters that match nothing), the audit log table shows an empty state with a "Clear filters" button. Not just an empty table with no rows.
|
||||
result: pass
|
||||
|
||||
## Summary
|
||||
|
||||
total: 19
|
||||
passed: 10
|
||||
issues: 9
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "Sidebar Cloud section shows animated shimmer rows while Nextcloud data loads"
|
||||
status: failed
|
||||
reason: "User reported: Local storage too fast to see any loading. Nextcloud sidebar section does nothing until folder loads — no skeleton or feedback visible during load."
|
||||
severity: major
|
||||
test: 4
|
||||
root_cause: "TreeItem.vue lines 48-52 render <div class='text-xs text-gray-400 py-1'>Loading…</div> instead of animate-pulse shimmer rows. The loading state ref is tracked correctly but the visual treatment does not match the shimmer pattern used elsewhere in AppSidebar."
|
||||
artifacts:
|
||||
- path: "frontend/src/components/ui/TreeItem.vue"
|
||||
issue: "v-if='loading' branch renders plain text instead of animated skeleton rows (lines 48-52)"
|
||||
missing:
|
||||
- "Replace plain Loading… div with 3 shimmer rows using animate-pulse pattern matching AppSidebar lines 60-64"
|
||||
debug_session: ""
|
||||
|
||||
- truth: "Admin views show admin-specific layout (no user sidebar) with breadcrumb bar"
|
||||
status: failed
|
||||
reason: "User reported: Admin views still show the normal user sidebar."
|
||||
severity: major
|
||||
test: 8
|
||||
root_cause: "App.vue renders <AppSidebar> in a v-else branch with no admin exemption. When /admin/* routes render, Vue Router places AdminLayout.vue into <router-view> but AppSidebar is outside it — unconditionally rendered for all non-auth routes. Both sidebars appear simultaneously."
|
||||
artifacts:
|
||||
- path: "frontend/src/App.vue"
|
||||
issue: "v-else branch (lines 3-8) renders <AppSidebar> without checking route.meta.requiresAdmin"
|
||||
missing:
|
||||
- "Add third branch in App.vue: when route.matched.some(r => r.meta.requiresAdmin), render only <router-view> with no AppSidebar"
|
||||
debug_session: ""
|
||||
|
||||
- truth: "Pressing '/' while in the file manager (with search bar visible) focuses the search input"
|
||||
status: failed
|
||||
reason: "User reported: pressing '/' does nothing when search bar is visible inside a folder."
|
||||
severity: major
|
||||
test: 11
|
||||
root_cause: "Shared root cause with tests 12 and 13: ref='routeViewRef' on <router-view> in App.vue resolves to the RouterView component proxy, not the FileManagerView instance. RouterView.setup() never calls expose(), so routeViewRef.value has no focusSearch/triggerUpload/startNewFolder properties. All calls silently no-op via optional chaining ?."
|
||||
artifacts:
|
||||
- path: "frontend/src/App.vue"
|
||||
issue: "ref='routeViewRef' on <router-view> (line 6); shortcut handlers use routeViewRef.value?.focusSearch?.() etc. (lines 37, 43, 46) which are unreachable"
|
||||
- path: "frontend/src/views/FileManagerView.vue"
|
||||
issue: "defineExpose({ focusSearch, triggerUpload, startNewFolder }) is correct (lines 190-196) but unreachable via routeViewRef"
|
||||
missing:
|
||||
- "Replace routeViewRef approach with router.currentRoute.value.matched[0].instances.default to reach actual FileManagerView instance, or use a Pinia store / event bus for keyboard action dispatch"
|
||||
debug_session: ""
|
||||
|
||||
- truth: "Pressing 'U' while in the file manager opens the file-picker dialog"
|
||||
status: failed
|
||||
reason: "User reported: pressing U does not open the file picker."
|
||||
severity: major
|
||||
test: 12
|
||||
root_cause: "Same root cause as test 11: routeViewRef resolves to RouterView proxy, not FileManagerView. triggerUpload?.() is a no-op."
|
||||
artifacts:
|
||||
- path: "frontend/src/App.vue"
|
||||
issue: "routeViewRef.value?.triggerUpload?.() (line 43) silently no-ops"
|
||||
missing:
|
||||
- "Fixed by the same routeViewRef fix as test 11"
|
||||
debug_session: ""
|
||||
|
||||
- truth: "Pressing 'N' while in the file manager triggers the new-folder inline input"
|
||||
status: failed
|
||||
reason: "User reported: pressing N does not trigger new folder input."
|
||||
severity: major
|
||||
test: 13
|
||||
root_cause: "Same root cause as test 11: routeViewRef resolves to RouterView proxy, not FileManagerView. startNewFolder?.() is a no-op."
|
||||
artifacts:
|
||||
- path: "frontend/src/App.vue"
|
||||
issue: "routeViewRef.value?.startNewFolder?.() (line 46) silently no-ops"
|
||||
missing:
|
||||
- "Fixed by the same routeViewRef fix as test 11"
|
||||
debug_session: ""
|
||||
|
||||
- truth: "After pressing Escape to clear search, the search field remains functional for new queries"
|
||||
status: failed
|
||||
reason: "User reported: field clears on Escape but search no longer works afterwards — cannot type a new query."
|
||||
severity: major
|
||||
test: 14
|
||||
root_cause: "SearchBar.vue uses type='search' on the input (line 6) and handles @keydown.escape without .prevent. Browsers treat Escape on type='search' as native clear+blur — the field loses focus and the user cannot type without clicking first. Event also bubbles to App.vue global handler (no .stop) causing a redundant clearSearch call."
|
||||
artifacts:
|
||||
- path: "frontend/src/components/documents/SearchBar.vue"
|
||||
issue: "@keydown.escape handler (line 11) lacks .prevent and .stop — browser native blur fires after Vue handler"
|
||||
- path: "frontend/src/App.vue"
|
||||
issue: "Global Escape handler (line 39) may fire redundantly after input blurs"
|
||||
missing:
|
||||
- "Change @keydown.escape to @keydown.escape.prevent.stop in SearchBar.vue to suppress native blur and prevent bubbling"
|
||||
debug_session: ""
|
||||
|
||||
- truth: "Dropping a file from OS onto the drag overlay starts the upload"
|
||||
status: failed
|
||||
reason: "User reported: overlay appears but dropping the file does not start the upload."
|
||||
severity: major
|
||||
test: 15
|
||||
root_cause: "OsDragOverlay registers window 'drop' listener in bubble phase. StorageBrowser registers @drop.prevent on every folder row (line 79), which consumes the native drop event before it bubbles to window. OsDragOverlay has pointer-events-none so drops land on underlying DOM elements (folder rows) which intercept them first. The window listener never fires."
|
||||
artifacts:
|
||||
- path: "frontend/src/components/layout/OsDragOverlay.vue"
|
||||
issue: "window.addEventListener('drop', this.onDrop) registered in bubble phase (line 56) — consumed by folder rows first"
|
||||
- path: "frontend/src/components/storage/StorageBrowser.vue"
|
||||
issue: "@drop.prevent on folder rows (line 79) intercepts OS drops; onDropDocOnFolder guard (line 364) exits early for OS drags (draggingFile is null)"
|
||||
missing:
|
||||
- "Register OsDragOverlay window listener in capture phase: window.addEventListener('drop', this.onDrop, true) so it runs before element-level handlers"
|
||||
debug_session: ""
|
||||
|
||||
- truth: "Search bar and sorting controls are visible at the root level of the file manager and cloud file browser (currently hidden behind breadcrumb.length > 0 guard)"
|
||||
status: failed
|
||||
reason: "User reported: search bar and sorting controls not visible at the root of the cloud and local file browser."
|
||||
severity: major
|
||||
test: 5
|
||||
root_cause: "StorageBrowser.vue line 287: showSearch computed is props.mode === 'local' && props.breadcrumb.length > 0. Both conditions must be true — breadcrumb is [] at root so showSearch is false there; cloud mode also always false because mode guard requires 'local'. SearchBar (line 12) and SortControls (line 14) both share v-if='showSearch' so both disappear."
|
||||
artifacts:
|
||||
- path: "frontend/src/components/storage/StorageBrowser.vue"
|
||||
issue: "showSearch computed (line 287) has breadcrumb.length > 0 and mode === 'local' guards; both incorrect"
|
||||
missing:
|
||||
- "Change showSearch to computed(() => props.mode === 'local' || props.mode === 'cloud') — remove breadcrumb depth guard entirely"
|
||||
debug_session: ""
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
phase: 10
|
||||
slug: ux-interaction
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
status: complete
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: 2026-06-14
|
||||
audited: 2026-06-16
|
||||
---
|
||||
|
||||
# Phase 10 — Validation Strategy
|
||||
@@ -38,22 +39,22 @@ created: 2026-06-14
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| AppIcon foundation | W0 | 0 | CODE-05 | — | Unknown names warn; render nothing | unit | `npm run test -- --run AppIcon` | ❌ | pending |
|
||||
| EmptyState foundation | W0 | 0 | UX-01 | — | Props render; CTA slot optional | unit | `npm run test -- --run EmptyState` | ❌ | pending |
|
||||
| BreadcrumbBar foundation | W0 | 0 | UX-12 | — | Last segment non-clickable; navigate emitted | unit | `npm run test -- --run BreadcrumbBar` | ❌ | pending |
|
||||
| Toast store + container | W0 | 0 | UX-10 | — | auto-dismiss 4s; dismiss on click | unit | `npm run test -- --run toast` | ❌ | pending |
|
||||
| StorageBrowser skeleton | W1 | 1 | UX-02 | — | skeleton renders when loading=true | unit | `npm run test -- --run StorageBrowser` | ❌ | pending |
|
||||
| AppSidebar skeleton + empty | W1 | 1 | UX-03 | — | skeleton rows replace Loading text | unit | `npm run test -- --run AppSidebar` | ❌ | pending |
|
||||
| Admin table skeletons | W1 | 1 | UX-04 | — | skeleton rows in users + audit | unit | `npm run test -- --run Admin` | ❌ | pending |
|
||||
| EmptyState wiring | W1 | 1 | UX-01 | — | EmptyState shown in all 7+ contexts | unit | `npm run test -- --run EmptyState` | ❌ | pending |
|
||||
| Toast call sites | W1 | 1 | UX-10 | — | show() called on upload/delete/move | unit | `npm run test -- --run toast` | ❌ | pending |
|
||||
| BreadcrumbBar wiring | W1 | 1 | UX-12 | — | admin/settings views pass static segments | unit | `npm run test -- --run BreadcrumbBar` | ❌ | pending |
|
||||
| UX-14 sidebar removal | W1 | 1 | UX-14 | — | "New" button absent from AppSidebar | unit | `npm run test -- --run AppSidebar` | ❌ | pending |
|
||||
| Keyboard shortcuts | W2 | 2 | UX-05..08 | — | guard fires; no input bleed | unit | `npm run test -- --run keyboard` | ❌ | pending |
|
||||
| OS drag overlay | W2 | 2 | UX-09 | — | overlay on Files dragenter; hide on leave | unit | `npm run test -- --run OsDragOverlay` | ❌ | pending |
|
||||
| Drag-to-move toast | W3 | 3 | UX-11 | — | ring-2 ring-inset ring-amber-300 on hover | unit | `npm run test -- --run StorageBrowser` | ❌ | pending |
|
||||
| Dropdown clipping fixes | W3 | 3 | UX-13 | — | picker renders via Teleport at correct pos | unit | `npm run test -- --run dropdown` | ❌ | pending |
|
||||
| SVG migration | W3 | 3 | CODE-05 | — | all 29 files use AppIcon; no inline svg | unit | `npm run test -- --run AppIcon` | ❌ | pending |
|
||||
| AppIcon foundation | W0 | 0 | CODE-05 | — | Unknown names warn; render nothing | unit | `npm run test -- --run AppIcon` | ✅ | COVERED |
|
||||
| EmptyState foundation | W0 | 0 | UX-01 | — | Props render; CTA slot optional | unit | `npm run test -- --run EmptyState` | ✅ | COVERED |
|
||||
| BreadcrumbBar foundation | W0 | 0 | UX-12 | — | Last segment non-clickable; navigate emitted | unit | `npm run test -- --run BreadcrumbBar` | ✅ | COVERED |
|
||||
| Toast store + container | W0 | 0 | UX-10 | — | auto-dismiss 4s; dismiss on click | unit | `npm run test -- --run toast` | ✅ | COVERED |
|
||||
| StorageBrowser skeleton | W1 | 1 | UX-02 | — | skeleton renders when loading=true | unit | `npm run test -- --run StorageBrowser` | ✅ | COVERED |
|
||||
| AppSidebar skeleton + empty | W1 | 1 | UX-03 | — | skeleton rows replace Loading text | unit | `npm run test -- --run AppSidebar` | ✅ | COVERED |
|
||||
| Admin table skeletons | W1 | 1 | UX-04 | — | skeleton rows in users + audit | unit | `npm run test -- --run Admin` | ✅ | COVERED |
|
||||
| EmptyState wiring | W1 | 1 | UX-01 | — | EmptyState shown in all 7+ contexts | unit | `npm run test -- --run EmptyState` | ✅ | COVERED |
|
||||
| Toast call sites | W1 | 1 | UX-10 | — | show() called on upload/delete/move | unit | `npm run test -- --run toast` | ✅ | COVERED |
|
||||
| BreadcrumbBar wiring | W1 | 1 | UX-12 | — | admin/settings views pass static segments | unit | `npm run test -- --run BreadcrumbBar` | ✅ | COVERED |
|
||||
| UX-14 sidebar removal | W1 | 1 | UX-14 | — | "New" button absent from AppSidebar | unit | `npm run test -- --run AppSidebar` | ✅ | COVERED |
|
||||
| Keyboard shortcuts | W2 | 2 | UX-05..08 | — | guard fires; no input bleed | unit | `npm run test -- --run keyboard` | ✅ | COVERED |
|
||||
| OS drag overlay | W2 | 2 | UX-09 | — | overlay on Files dragenter; hide on leave | unit | `npm run test -- --run OsDragOverlay` | ✅ | COVERED |
|
||||
| Drag-to-move toast | W3 | 3 | UX-11 | — | ring-2 ring-inset ring-amber-300 on hover | unit | `npm run test -- --run StorageBrowser` | ✅ | COVERED |
|
||||
| Dropdown clipping fixes | W3 | 3 | UX-13 | — | picker renders via Teleport at correct pos | unit | `npm run test -- --run dropdown` | ✅ | COVERED |
|
||||
| SVG migration | W3 | 3 | CODE-05 | — | all 29 files use AppIcon; no inline svg | unit | `npm run test -- --run AppIcon` | ✅ | COVERED |
|
||||
|
||||
---
|
||||
|
||||
@@ -74,11 +75,11 @@ created: 2026-06-14
|
||||
|
||||
## Wave 0 Test Stubs (REQUIRED — create before implementation)
|
||||
|
||||
- [ ] `frontend/src/components/ui/AppIcon.test.js` — covers CODE-05 (name→path, unknown name warn)
|
||||
- [ ] `frontend/src/components/ui/EmptyState.test.js` — covers UX-01 (props, CTA slot)
|
||||
- [ ] `frontend/src/components/ui/BreadcrumbBar.test.js` — covers UX-12 (last segment, navigate emit)
|
||||
- [ ] `frontend/src/stores/toast.test.js` — covers UX-10 (show, auto-dismiss, dismiss on click)
|
||||
- [ ] `frontend/src/components/ui/ToastContainer.test.js` — covers UX-10 visual rendering
|
||||
- [x] `frontend/src/components/ui/__tests__/AppIcon.test.js` — covers CODE-05 (name→path, unknown name warn)
|
||||
- [x] `frontend/src/components/ui/__tests__/EmptyState.test.js` — covers UX-01 (props, CTA slot)
|
||||
- [x] `frontend/src/components/ui/__tests__/BreadcrumbBar.test.js` — covers UX-12 (last segment, navigate emit)
|
||||
- [x] `frontend/src/stores/__tests__/toast.test.js` — covers UX-10 (show, auto-dismiss, dismiss on click)
|
||||
- [x] `frontend/src/components/ui/__tests__/ToastContainer.test.js` — covers UX-10 visual rendering
|
||||
|
||||
---
|
||||
|
||||
@@ -91,3 +92,20 @@ These require human observation:
|
||||
- OS drag-and-drop actual file upload end-to-end
|
||||
- Keyboard `N` in cloud folder view (must no-op silently)
|
||||
- Human checkpoint UAT: all 15 requirements exercised by a real user
|
||||
|
||||
---
|
||||
|
||||
## Validation Audit 2026-06-16
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Tasks audited | 16 |
|
||||
| Gaps found | 0 |
|
||||
| COVERED | 16 |
|
||||
| PARTIAL | 0 |
|
||||
| MISSING | 0 |
|
||||
| Escalated to manual-only | 0 |
|
||||
|
||||
**Test suite result:** 211 tests pass across 28 files (0 failures).
|
||||
|
||||
All Phase 10 test files were present and green at audit time. VALIDATION.md promoted from `draft` to `complete`; `nyquist_compliant` set to `true`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 10-ux-interaction
|
||||
verified: 2026-06-16T10:15:00Z
|
||||
verified: 2026-06-16T19:31:00Z
|
||||
status: passed
|
||||
score: 15/15 must-haves verified
|
||||
overrides_applied: 0
|
||||
@@ -11,8 +11,16 @@ re_verification:
|
||||
- "Pressing Escape closes any open modal (ShareModal, FolderDeleteModal, DocumentPreviewModal)"
|
||||
- "Share revoke and folder rename actions produce toast notifications"
|
||||
- "UX-13 StorageBrowser folder picker dropdown test stubs promoted to real assertions"
|
||||
gap_closure_plan_10_13:
|
||||
- "Sidebar TreeItem.vue shimmer rows replacing Loading text (Gap 1)"
|
||||
- "StorageBrowser.vue showSearch true at root for local AND cloud modes (Gap 2)"
|
||||
- "App.vue admin v-else-if branch — no AppSidebar on /admin/* routes (Gap 3)"
|
||||
- "App.vue getFileManagerInstance() via matched.find() replaces routeViewRef proxy (Gap 4)"
|
||||
- "SearchBar.vue @keydown.escape.prevent.stop suppresses native blur (Gap 5)"
|
||||
- "OsDragOverlay.vue drop listener in capture phase (Gap 6)"
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
tests_after_gap_closure: 219
|
||||
---
|
||||
|
||||
# Phase 10: UX & Interaction Verification Report
|
||||
@@ -69,11 +77,11 @@ re_verification:
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| App.vue keydown | routeViewRef.value?.method?() | optional chaining on routeViewRef | WIRED | ref="routeViewRef" on router-view; onKeydown handler with 4 branches |
|
||||
| App.vue keydown | getFileManagerInstance()?.method?() | router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default | WIRED | routeViewRef removed; getFileManagerInstance() helper resolves to actual FileManagerView (not RouterView proxy); 4 dispatch branches in onKeydown |
|
||||
| StorageBrowser.vue | DropZone.vue | dropZoneRef.value?.triggerInput?.() | WIRED | const dropZoneRef; ref="dropZoneRef" on DropZone; DropZone defineExpose({ triggerInput }) |
|
||||
| StorageBrowser.vue | SearchBar.vue | searchBarRef.value?.focus?.() | WIRED | const searchBarRef; ref="searchBarRef" on SearchBar; SearchBar defineExpose({ focus }) |
|
||||
| FileManagerView.vue | StorageBrowser.vue | browserRef.value?.method?.() | WIRED | defineExpose delegates all 5 methods to browserRef via optional chaining |
|
||||
| App.vue | OsDragOverlay.vue | @files-dropped → onOsFilesDropped | WIRED | OsDragOverlay mounted; onOsFilesDropped calls routeViewRef.value?.handleOsDrop?.(files) |
|
||||
| App.vue | OsDragOverlay.vue | @files-dropped → onOsFilesDropped | WIRED | OsDragOverlay mounted; onOsFilesDropped calls getFileManagerInstance()?.handleOsDrop?.(files); drop listener in capture phase (true arg) |
|
||||
| FileManagerView.vue | onFilesSelected | handleOsDrop | WIRED | handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }) in defineExpose |
|
||||
| StorageBrowser.vue | BreadcrumbBar.vue | import + :segments="breadcrumb" | WIRED | import BreadcrumbBar from ../ui/BreadcrumbBar.vue; 1 BreadcrumbBar element |
|
||||
| FileManagerView.vue | useToastStore | show() in doMove/doDeleteDoc/onFilesSelected/handleFolderRename | WIRED | useToastStore used in 4 functions; 'Document moved', 'Document deleted', upload summary, 'Folder renamed' toasts |
|
||||
@@ -136,3 +144,20 @@ Previous state: 208 passed, 3 todo, 0 failed. The 3 promoted UX-13 stubs account
|
||||
|
||||
_Verified: 2026-06-16T10:15:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
|
||||
---
|
||||
|
||||
## Gap Closure Verification (Plan 10-13)
|
||||
|
||||
**Re-verified: 2026-06-16T19:31:00Z** — after UAT gap closure (10-UAT.md had 9 issues, 6 root causes)
|
||||
|
||||
| Gap | Fix | Verified |
|
||||
|-----|-----|---------|
|
||||
| 1 — Sidebar shimmer | `TreeItem.vue`: `animate-pulse` ×2 in v-if="loading" branch; "Loading" text: 0 occurrences | ✓ |
|
||||
| 2 — Search at root | `StorageBrowser.vue`: `showSearch = computed(() => props.mode === 'local' \|\| props.mode === 'cloud')` | ✓ |
|
||||
| 3 — Admin sidebar bleed | `App.vue`: `v-else-if="route.matched.some(r => r.meta.requiresAdmin)"` with no AppSidebar | ✓ |
|
||||
| 4 — Keyboard dispatch | `App.vue`: `getFileManagerInstance()` via `matched.find(r => r.instances?.default)?.instances?.default`; `routeViewRef` fully removed | ✓ |
|
||||
| 5 — Escape blur | `SearchBar.vue`: `@keydown.escape.prevent.stop` | ✓ |
|
||||
| 6 — OS drag capture | `OsDragOverlay.vue`: `addEventListener('drop', this.onDrop, true)` + matching `removeEventListener` | ✓ |
|
||||
|
||||
**Test suite after gap closure: 219 passed, 0 failed (30 files)** (was 211 before plan 10-13)
|
||||
|
||||
@@ -158,13 +158,14 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b
|
||||
| `ADMIN_PASSWORD` | Bootstrap admin password (must pass strength check) |
|
||||
| `POSTGRES_PASSWORD` | PostgreSQL superuser password |
|
||||
| `MINIO_ROOT_PASSWORD` | MinIO root password |
|
||||
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | App-level MinIO credentials; Docker Compose provisions this user and bucket policy |
|
||||
| `REDIS_PASSWORD` | Redis `requirepass` password |
|
||||
|
||||
### Optional (sensible defaults for local dev)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CORS_ORIGINS` | `http://localhost:5173` | Comma-separated allowed origins |
|
||||
| `CORS_ORIGINS` | `["http://localhost:5173"]` | JSON list of allowed origins |
|
||||
| `SMTP_HOST` | *(unset)* | Leave empty to log password-reset links to stdout |
|
||||
| `LOG_JSON` | `false` | Set `true` in production for structured JSON logs |
|
||||
| `DEFAULT_AI_PROVIDER` | `ollama` | Used on first boot seed; overridable per-user in admin panel |
|
||||
|
||||
@@ -11,13 +11,9 @@ from deps.auth import get_current_admin
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from api.admin.shared import _user_to_dict
|
||||
|
||||
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
class QuotaUpdate(BaseModel):
|
||||
limit_bytes: int
|
||||
|
||||
@@ -28,9 +24,6 @@ class QuotaUpdate(BaseModel):
|
||||
raise ValueError("limit_bytes must be greater than 0")
|
||||
return v
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users/{user_id}/quota")
|
||||
async def get_user_quota(
|
||||
user_id: uuid.UUID,
|
||||
|
||||
+16
-45
@@ -21,12 +21,8 @@ from api.admin.shared import _user_to_dict
|
||||
|
||||
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
_DEFAULT_QUOTA_BYTES = 104857600
|
||||
|
||||
_DEFAULT_QUOTA_BYTES = 104857600 # 100 MB free-tier default (D-06)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
handle: str
|
||||
@@ -51,20 +47,21 @@ class UserAiConfigUpdate(BaseModel):
|
||||
|
||||
|
||||
class SystemTopicCreate(BaseModel):
|
||||
"""Request model for admin system topic creation (D-09)."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
color: str = "#6366f1"
|
||||
|
||||
|
||||
class UserDeleteConfirm(BaseModel):
|
||||
"""Admin password confirmation required before hard-deleting a user (ADMIN-02, T-05-11-01)."""
|
||||
|
||||
admin_password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
async def _get_user_or_404(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
@@ -111,7 +108,7 @@ async def create_user(
|
||||
role=body.role,
|
||||
is_active=True,
|
||||
totp_enabled=False,
|
||||
password_must_change=True, # ADMIN-01: force password change on first login
|
||||
password_must_change=True,
|
||||
)
|
||||
session.add(new_user)
|
||||
|
||||
@@ -121,8 +118,7 @@ async def create_user(
|
||||
used_bytes=0,
|
||||
)
|
||||
session.add(quota)
|
||||
await session.flush() # persist User + Quota before audit_log FK references them
|
||||
# D-13: admin user created event
|
||||
await session.flush()
|
||||
_ip_addr = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -151,11 +147,8 @@ async def update_user_status(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
# Guard: cannot deactivate the only remaining active admin (T-02-29)
|
||||
if not body.is_active and user.role == "admin":
|
||||
count_result = await session.execute(
|
||||
select(func.count(User.id)).where(
|
||||
@@ -174,9 +167,7 @@ async def update_user_status(
|
||||
user.is_active = body.is_active
|
||||
|
||||
if not body.is_active:
|
||||
# Revoke all refresh tokens on deactivation
|
||||
await revoke_all_refresh_tokens(session, user.id)
|
||||
# Revoke any pre-deactivation access tokens still within their TTL (T-7.2-01)
|
||||
await request.app.state.redis.set(
|
||||
f"user_nbf:{user.id}",
|
||||
int(time.time()),
|
||||
@@ -185,7 +176,6 @@ async def update_user_status(
|
||||
|
||||
session.add(user)
|
||||
|
||||
# D-13: user deactivated/activated event
|
||||
_event = "admin.user_deactivated" if not body.is_active else "admin.user_activated"
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -211,9 +201,7 @@ async def initiate_password_reset(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
from services.auth import create_password_reset_token # noqa: PLC0415
|
||||
from config import settings as _settings # noqa: PLC0415
|
||||
@@ -236,16 +224,13 @@ async def update_ai_config(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
_ip_addr = get_client_ip(request)
|
||||
user.ai_provider = body.ai_provider
|
||||
user.ai_model = body.ai_model
|
||||
session.add(user)
|
||||
|
||||
# D-13: AI provider assigned event
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.ai_provider_assigned",
|
||||
@@ -273,19 +258,14 @@ async def delete_user(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
# 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):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid admin password",
|
||||
)
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
# T-04-07-04: Cannot delete admin accounts
|
||||
if user.role == "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -294,15 +274,11 @@ async def delete_user(
|
||||
|
||||
_ip_addr = get_client_ip(request)
|
||||
|
||||
# SEC-09 (cloud): purge cloud-stored documents and credentials BEFORE DB delete.
|
||||
# Must run before MinIO cleanup so that credentials are still available to build
|
||||
# the cloud backend instances for delete_object calls.
|
||||
cloud_conns_result = await session.execute(
|
||||
select(CloudConnection).where(CloudConnection.user_id == user_id)
|
||||
)
|
||||
cloud_conns = cloud_conns_result.scalars().all()
|
||||
for conn in cloud_conns:
|
||||
# Delete cloud objects stored in this provider for this user
|
||||
cloud_docs_result = await session.execute(
|
||||
select(Document).where(
|
||||
Document.user_id == user_id,
|
||||
@@ -314,12 +290,10 @@ async def delete_user(
|
||||
backend = await get_storage_backend_for_document(doc, user, session)
|
||||
await backend.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # Best-effort cloud object cleanup; deletion proceeds regardless
|
||||
# Purge the credentials row (FK cascade would also remove it, but explicit
|
||||
# deletion here guarantees credentials_enc is gone before commit — SEC-09)
|
||||
pass
|
||||
await session.delete(conn)
|
||||
if cloud_conns:
|
||||
await session.flush() # Flush connection deletes before user delete
|
||||
await session.flush()
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="cloud.credentials_purged",
|
||||
@@ -330,7 +304,6 @@ async def delete_user(
|
||||
metadata_={"providers": [c.provider for c in cloud_conns]},
|
||||
)
|
||||
|
||||
# SEC-09 (minio): collect all user documents and delete MinIO objects BEFORE DB delete
|
||||
docs_result = await session.execute(
|
||||
select(Document).where(Document.user_id == user_id)
|
||||
)
|
||||
@@ -341,9 +314,8 @@ async def delete_user(
|
||||
try:
|
||||
await storage.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # Best-effort MinIO cleanup; DB deletion proceeds regardless
|
||||
pass
|
||||
|
||||
# D-13: audit log BEFORE deleting the user row (user FK still valid at flush time)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.user_deleted",
|
||||
@@ -354,7 +326,6 @@ async def delete_user(
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
# Delete user record (CASCADE removes quota, documents, refresh_tokens, etc.)
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
|
||||
+120
-218
@@ -1,23 +1,4 @@
|
||||
"""
|
||||
Admin audit log API endpoints for DocuVault.
|
||||
|
||||
All handlers require get_current_admin (ADMIN-06, SEC-07) — regular users
|
||||
receive 403 Forbidden.
|
||||
|
||||
Implements:
|
||||
GET /api/admin/audit-log — paginated, filtered audit log viewer
|
||||
GET /api/admin/audit-log/export — CSV streaming export with same filters
|
||||
GET /api/admin/audit-log/daily-exports — list available Celery daily export files
|
||||
GET /api/admin/audit-log/daily-exports/{date} — stream a specific daily export CSV
|
||||
|
||||
Security invariants:
|
||||
- All endpoints use Depends(get_current_admin) — verified by grep
|
||||
- _audit_to_dict() is a pure whitelist: no filename, extracted_text,
|
||||
password_hash, or credentials_enc can appear in responses (ADMIN-06, D-15)
|
||||
- CSV export uses the same _audit_to_dict_with_handles() helper as the JSON viewer
|
||||
- Date path parameter validated against YYYY-MM-DD regex before MinIO key
|
||||
construction — prevents path traversal (T-06.2-04-01, Pitfall 6)
|
||||
"""
|
||||
"""Admin audit log API endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -44,17 +25,22 @@ from storage.minio_backend import MinIOBackend
|
||||
router = APIRouter(prefix="/api/admin", tags=["audit"])
|
||||
|
||||
_VALID_EVENT_PREFIXES = frozenset({"auth", "document", "folder", "share", "admin", "cloud"})
|
||||
_CSV_FIELDS = [
|
||||
"id",
|
||||
"event_type",
|
||||
"user_id",
|
||||
"actor_id",
|
||||
"user_handle",
|
||||
"actor_handle",
|
||||
"user_email",
|
||||
"resource_id",
|
||||
"ip_address",
|
||||
"metadata_",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
|
||||
# ── Safe response helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _audit_to_dict(entry: AuditLog) -> dict:
|
||||
"""Safe audit log serializer — never includes filename, extracted_text, or
|
||||
document content (ADMIN-06, D-15).
|
||||
|
||||
Whitelist: id, event_type, user_id, actor_id, resource_id, ip_address,
|
||||
metadata_, created_at. No other keys are possible.
|
||||
"""
|
||||
def _audit_base_fields(entry: AuditLog) -> dict:
|
||||
return {
|
||||
"id": entry.id,
|
||||
"event_type": entry.event_type,
|
||||
@@ -67,38 +53,49 @@ def _audit_to_dict(entry: AuditLog) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _audit_to_dict(entry: AuditLog) -> dict:
|
||||
"""Whitelisted audit serializer shared with the daily export task."""
|
||||
return _audit_base_fields(entry)
|
||||
|
||||
|
||||
def _audit_to_dict_with_handles(
|
||||
entry: AuditLog,
|
||||
user_handle: Optional[str],
|
||||
actor_handle: Optional[str],
|
||||
user_email: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Extended audit log serializer that includes user_handle, actor_handle, and user_email.
|
||||
|
||||
Returns the same fields as _audit_to_dict() plus:
|
||||
- user_handle: str | None (the handle of the user who owns the entry)
|
||||
- actor_handle: str | None (the handle of the actor who performed the event)
|
||||
- user_email: str | None (the email of the user who owns the entry)
|
||||
|
||||
Used by both the JSON viewer and CSV export endpoints (Pitfall 7 — both
|
||||
endpoints must use the enriched function).
|
||||
"""
|
||||
return {
|
||||
"id": entry.id,
|
||||
"event_type": entry.event_type,
|
||||
"user_id": str(entry.user_id) if entry.user_id else None,
|
||||
"actor_id": str(entry.actor_id) if entry.actor_id else None,
|
||||
data = _audit_base_fields(entry)
|
||||
data.update({
|
||||
"user_handle": user_handle or None,
|
||||
"actor_handle": actor_handle or None,
|
||||
"user_email": user_email or None,
|
||||
"resource_id": str(entry.resource_id) if entry.resource_id else None,
|
||||
"ip_address": str(entry.ip_address) if entry.ip_address else None,
|
||||
"metadata_": entry.metadata_,
|
||||
"created_at": entry.created_at.isoformat(),
|
||||
}
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
# ── Query builder helpers ─────────────────────────────────────────────────────
|
||||
def _validate_event_type(event_type: Optional[str]) -> None:
|
||||
if event_type is not None and event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
|
||||
|
||||
def _apply_audit_filters(
|
||||
query,
|
||||
start: Optional[datetime],
|
||||
end: Optional[datetime],
|
||||
user_uuid: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
):
|
||||
_validate_event_type(event_type)
|
||||
if start is not None:
|
||||
query = query.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
query = query.where(AuditLog.created_at <= end)
|
||||
if user_uuid is not None:
|
||||
query = query.where(AuditLog.user_id == user_uuid)
|
||||
if event_type is not None:
|
||||
query = query.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
return query
|
||||
|
||||
|
||||
def _build_filtered_query(
|
||||
start: Optional[datetime],
|
||||
@@ -106,27 +103,13 @@ def _build_filtered_query(
|
||||
user_id: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
):
|
||||
"""Return a SQLAlchemy Select for AuditLog with the given filters applied.
|
||||
|
||||
Shared by count queries in both the paginated viewer and the CSV export
|
||||
endpoints to ensure consistent filter semantics.
|
||||
|
||||
NOTE: This function selects AuditLog only (no JOIN). It is used for COUNT
|
||||
queries to avoid the subquery ambiguity that arises with multi-column JOINs
|
||||
(Pitfall 4). Data queries use _build_filtered_query_with_handles() instead.
|
||||
"""
|
||||
q = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||
if start is not None:
|
||||
q = q.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
q = q.where(AuditLog.created_at <= end)
|
||||
if user_id is not None:
|
||||
q = q.where(AuditLog.user_id == user_id)
|
||||
if event_type is not None:
|
||||
if event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
return q
|
||||
return _apply_audit_filters(
|
||||
select(AuditLog).order_by(AuditLog.created_at.desc()),
|
||||
start,
|
||||
end,
|
||||
user_id,
|
||||
event_type,
|
||||
)
|
||||
|
||||
|
||||
def _build_filtered_query_with_handles(
|
||||
@@ -135,15 +118,6 @@ def _build_filtered_query_with_handles(
|
||||
user_uuid: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
):
|
||||
"""Return a multi-column Select that joins User twice for handle enrichment.
|
||||
|
||||
Yields (AuditLog, user_handle: str|None, actor_handle: str|None) tuples.
|
||||
Uses SQLAlchemy aliased() to join User twice without collision:
|
||||
- UserSubject: resolves user_id FK → handle
|
||||
- UserActor: resolves actor_id FK → handle
|
||||
|
||||
outerjoin() ensures entries with NULL user_id or actor_id are still returned.
|
||||
"""
|
||||
UserSubject = aliased(User)
|
||||
UserActor = aliased(User)
|
||||
|
||||
@@ -158,37 +132,68 @@ def _build_filtered_query_with_handles(
|
||||
.outerjoin(UserActor, UserActor.id == AuditLog.actor_id)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
)
|
||||
if start is not None:
|
||||
q = q.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
q = q.where(AuditLog.created_at <= end)
|
||||
if user_uuid is not None:
|
||||
q = q.where(AuditLog.user_id == user_uuid)
|
||||
if event_type is not None:
|
||||
if event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
return q
|
||||
return _apply_audit_filters(q, start, end, user_uuid, event_type)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
# IMPORTANT: daily-export routes are registered BEFORE /audit-log and
|
||||
# /audit-log/export so FastAPI matches the more specific paths first.
|
||||
async def _resolve_user_uuid(session: AsyncSession, user_handle: Optional[str]) -> uuid.UUID | None:
|
||||
if not user_handle:
|
||||
return None
|
||||
|
||||
result = await session.execute(select(User.id).where(User.handle == user_handle))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _count_audit_log(
|
||||
session: AsyncSession,
|
||||
start: Optional[datetime],
|
||||
end: Optional[datetime],
|
||||
user_uuid: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
) -> int:
|
||||
count_q = _apply_audit_filters(
|
||||
select(func.count(AuditLog.id)).where(True),
|
||||
start,
|
||||
end,
|
||||
user_uuid,
|
||||
event_type,
|
||||
)
|
||||
result = await session.execute(count_q)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
def _audit_rows_to_dicts(rows) -> list[dict]:
|
||||
return [_audit_to_dict_with_handles(row[0], row[1], row[2], row[3]) for row in rows]
|
||||
|
||||
|
||||
def _csv_response(csv_text: str, filename: str = "audit-export.csv") -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
iter([csv_text]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
def _empty_csv_response() -> StreamingResponse:
|
||||
output = io.StringIO()
|
||||
csv.DictWriter(output, fieldnames=_CSV_FIELDS).writeheader()
|
||||
return _csv_response(output.getvalue())
|
||||
|
||||
|
||||
def _audit_csv_response(rows) -> StreamingResponse:
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=_CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
for record in _audit_rows_to_dicts(rows):
|
||||
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
|
||||
writer.writerow(record)
|
||||
return _csv_response(output.getvalue())
|
||||
|
||||
|
||||
@router.get("/audit-log/daily-exports")
|
||||
async def list_daily_exports(
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""List available Celery daily audit export files from MinIO (D-15).
|
||||
|
||||
Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] }
|
||||
Items are sorted descending by date.
|
||||
|
||||
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
|
||||
Event loop safety: list_objects() is synchronous; wrapped in asyncio.to_thread
|
||||
to avoid blocking the event loop (T-06.2-04-05).
|
||||
"""
|
||||
"""List available Celery daily audit export files from MinIO."""
|
||||
backend = get_storage_backend()
|
||||
if not isinstance(backend, MinIOBackend):
|
||||
return {"items": []}
|
||||
@@ -215,15 +220,7 @@ async def download_daily_export(
|
||||
date: str,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
"""Stream a specific Celery daily audit export file from MinIO (D-16).
|
||||
|
||||
The date path parameter is validated against YYYY-MM-DD regex before
|
||||
MinIO key construction to prevent path traversal (T-06.2-04-01, Pitfall 6).
|
||||
|
||||
Returns: StreamingResponse with Content-Type: text/csv.
|
||||
|
||||
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
|
||||
"""
|
||||
"""Stream a specific Celery daily audit export file from MinIO."""
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
|
||||
raise HTTPException(status_code=404, detail="Invalid date format")
|
||||
|
||||
@@ -263,56 +260,18 @@ async def list_audit_log(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Return paginated, filtered audit log entries (ADMIN-06).
|
||||
"""Return paginated, filtered audit log entries."""
|
||||
user_uuid = await _resolve_user_uuid(session, user_handle)
|
||||
if user_handle and user_uuid is None:
|
||||
return {"items": [], "total": 0, "page": page, "per_page": per_page}
|
||||
|
||||
Response: { items: [...], total: int, page: int, per_page: int }
|
||||
Each item includes user_handle and actor_handle alongside UUID fields (D-11).
|
||||
Entries never contain filename, extracted_text, or document content (D-15).
|
||||
|
||||
user_handle filter: accepts a plain string handle and resolves to UUID
|
||||
internally. Returns empty results (not 422) for unknown handles (D-12).
|
||||
"""
|
||||
# Handle-to-UUID resolution (D-12, Pattern 4)
|
||||
user_uuid: Optional[uuid.UUID] = None
|
||||
if user_handle:
|
||||
handle_result = await session.execute(
|
||||
select(User.id).where(User.handle == user_handle)
|
||||
)
|
||||
uid = handle_result.scalar_one_or_none()
|
||||
if uid is None:
|
||||
# No user with that handle — return empty results (D-12)
|
||||
return {"items": [], "total": 0, "page": page, "per_page": per_page}
|
||||
user_uuid = uid
|
||||
|
||||
# Count query: use the plain _build_filtered_query (no JOIN) to avoid
|
||||
# COUNT ambiguity on multi-column subqueries (Pitfall 4)
|
||||
count_q = select(func.count(AuditLog.id)).where(True)
|
||||
if start is not None:
|
||||
count_q = count_q.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
count_q = count_q.where(AuditLog.created_at <= end)
|
||||
if user_uuid is not None:
|
||||
count_q = count_q.where(AuditLog.user_id == user_uuid)
|
||||
if event_type is not None:
|
||||
if event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
count_q = count_q.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
count_result = await session.execute(count_q)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
# Data query: use enriched JOIN for handle fields
|
||||
total = await _count_audit_log(session, start, end, user_uuid, event_type)
|
||||
data_q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
|
||||
data_q = data_q.limit(per_page).offset((page - 1) * per_page)
|
||||
result = await session.execute(data_q)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
|
||||
items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val))
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"items": _audit_rows_to_dicts(result.all()),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
@@ -329,68 +288,11 @@ async def export_audit_log(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
"""Stream a CSV export of filtered audit log entries (ADMIN-06).
|
||||
"""Stream a CSV export of filtered audit log entries."""
|
||||
user_uuid = await _resolve_user_uuid(session, user_handle)
|
||||
if user_handle and user_uuid is None:
|
||||
return _empty_csv_response()
|
||||
|
||||
Uses the same _audit_to_dict_with_handles() whitelist as the JSON viewer —
|
||||
includes user_handle and actor_handle; no filename, extracted_text, or
|
||||
document content appears in the export (D-15, T-04-06-02, Pitfall 7).
|
||||
|
||||
Returns StreamingResponse with Content-Disposition: attachment; filename=audit-export.csv.
|
||||
|
||||
user_handle filter: same handle-to-UUID resolution as the viewer (D-12).
|
||||
"""
|
||||
# Handle-to-UUID resolution (D-12) — same logic as list_audit_log
|
||||
user_uuid: Optional[uuid.UUID] = None
|
||||
if user_handle:
|
||||
handle_result = await session.execute(
|
||||
select(User.id).where(User.handle == user_handle)
|
||||
)
|
||||
uid = handle_result.scalar_one_or_none()
|
||||
if uid is None:
|
||||
# Unknown handle — return empty CSV
|
||||
empty_output = io.StringIO()
|
||||
fields = [
|
||||
"id", "event_type", "user_id", "actor_id", "user_handle", "actor_handle",
|
||||
"user_email", "resource_id", "ip_address", "metadata_", "created_at",
|
||||
]
|
||||
writer = csv.DictWriter(empty_output, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
return StreamingResponse(
|
||||
iter([empty_output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
|
||||
)
|
||||
user_uuid = uid
|
||||
|
||||
# Data query with handle enrichment (Pitfall 7 — export must use enriched function)
|
||||
q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
|
||||
result = await session.execute(q)
|
||||
rows = result.all()
|
||||
|
||||
fields = [
|
||||
"id",
|
||||
"event_type",
|
||||
"user_id",
|
||||
"actor_id",
|
||||
"user_handle",
|
||||
"actor_handle",
|
||||
"user_email",
|
||||
"resource_id",
|
||||
"ip_address",
|
||||
"metadata_",
|
||||
"created_at",
|
||||
]
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
|
||||
record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val)
|
||||
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
|
||||
writer.writerow(record)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
|
||||
)
|
||||
return _audit_csv_response(result.all())
|
||||
|
||||
+278
-593
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,12 @@ Security:
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Share, User
|
||||
from db.models import User
|
||||
from api.documents.shared import get_accessible_document
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from services.rate_limiting import account_limiter
|
||||
@@ -31,15 +29,12 @@ from storage.exceptions import CloudConnectionError
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Range header parsing helper ───────────────────────────────────────────────
|
||||
|
||||
def _parse_range(range_header: str, file_size: int) -> tuple:
|
||||
"""Parse a 'bytes=X-Y' Range header and return (start, end).
|
||||
|
||||
Returns (start, end) where both are inclusive byte offsets.
|
||||
Raises HTTP 416 on any invalid or out-of-bounds range.
|
||||
|
||||
T-04-05-03: validates start <= end, start >= 0, end < file_size.
|
||||
"""
|
||||
try:
|
||||
h = range_header.replace("bytes=", "").split("-")
|
||||
@@ -52,8 +47,6 @@ def _parse_range(range_header: str, file_size: int) -> tuple:
|
||||
return start, end
|
||||
|
||||
|
||||
# ── GET /api/documents/{doc_id}/content ──────────────────────────────────────
|
||||
|
||||
@router.get("/{doc_id}/content")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def stream_document_content(
|
||||
@@ -63,26 +56,7 @@ async def stream_document_content(
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Access control: owner OR share recipient (T-04-05-04)
|
||||
if doc.user_id != current_user.id:
|
||||
result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == doc.id,
|
||||
Share.recipient_id == current_user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
doc, _is_recipient = await get_accessible_document(session, doc_id, current_user.id)
|
||||
|
||||
try:
|
||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
||||
|
||||
+39
-153
@@ -1,22 +1,4 @@
|
||||
"""Document CRUD endpoints — list, get, patch, delete, and re-classify.
|
||||
|
||||
Endpoints:
|
||||
GET "" — list documents with sort, folder filter, and FTS (list_documents)
|
||||
GET /{doc_id} — get document metadata (get_document)
|
||||
PATCH /{doc_id} — update filename and/or folder_id (patch_document)
|
||||
DELETE /{doc_id} — delete document, decrement quota atomically (delete_document)
|
||||
POST /{doc_id}/classify — re-queue Celery classification (classify_document, D-08)
|
||||
|
||||
Sub-router carries NO prefix — prefix="/api/documents" lives in __init__.py (D-04).
|
||||
|
||||
Security:
|
||||
T-03-11: ownership assertion on every resource endpoint — cross-user access returns 404.
|
||||
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
|
||||
T-05-09-02: response uses storage.get_metadata() whitelist — no credentials_enc, no password_hash.
|
||||
T-06.2-03-01: cloud documents skip MinIO quota decrement.
|
||||
T-06.2-03-02: cloud delete failure returns {success: false, cloud_delete_failed: true} (HTTP 200).
|
||||
T-07-10: classify endpoint — IDOR returns 404 per ownership assertion.
|
||||
"""
|
||||
"""Document CRUD endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -41,14 +23,31 @@ from services.rate_limiting import account_limiter
|
||||
from storage import get_storage_backend_for_document as _get_storage_backend_for_document
|
||||
from tasks.document_tasks import extract_and_classify
|
||||
|
||||
from api.documents.shared import DocumentPatch, _CLOUD_PROVIDERS
|
||||
from api.documents.shared import DocumentPatch, get_accessible_document, get_owned_document
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── GET /api/documents ────────────────────────────────────────────────────────
|
||||
# Route registered on parent router in __init__.py (FastAPI 0.100+ disallows
|
||||
# include_router when both the include prefix and route path are empty strings).
|
||||
async def _shared_document_ids(session: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
result = await session.execute(select(Share.document_id).where(Share.owner_id == user_id))
|
||||
return {row[0] for row in result.fetchall()}
|
||||
|
||||
|
||||
async def _decorate_shared_flags(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
items: list[dict],
|
||||
) -> list[dict]:
|
||||
shared_ids = await _shared_document_ids(session, user_id)
|
||||
for item in items:
|
||||
try:
|
||||
doc_id = uuid.UUID(item.get("id", ""))
|
||||
except (TypeError, ValueError):
|
||||
item["is_shared"] = False
|
||||
else:
|
||||
item["is_shared"] = doc_id in shared_ids
|
||||
return items
|
||||
|
||||
|
||||
@account_limiter.limit("100/minute")
|
||||
async def list_documents(
|
||||
@@ -63,40 +62,17 @@ async def list_documents(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""List documents with optional sort, folder filter, and full-text search.
|
||||
|
||||
D-16: requires authenticated regular user (get_regular_user rejects admins).
|
||||
Returns only documents belonging to the current user.
|
||||
|
||||
FOLD-05: sort by name|date|size; order asc|desc; folder_id filter;
|
||||
q full-text search via plainto_tsquery (PostgreSQL only — silently skipped
|
||||
on SQLite when function is unavailable). FTS scope is always scoped to
|
||||
current_user.id (T-04-03-02).
|
||||
|
||||
Backward-compat: when sort/order/folder_id/q are not provided, behaviour
|
||||
is identical to the pre-Phase-4 implementation.
|
||||
"""
|
||||
"""List documents with optional sort, folder filter, and full-text search."""
|
||||
request.state.current_user = current_user
|
||||
# If no new params used, fall through to the legacy storage.list_metadata path
|
||||
# to preserve full backward compatibility with topic filtering.
|
||||
if folder_id is None and q is None and sort == "date" and order == "desc":
|
||||
docs = await storage.list_metadata(session, user_id=current_user.id, topic=topic)
|
||||
total = len(docs)
|
||||
start = (page - 1) * per_page
|
||||
# Add is_shared field (Phase 4 addition)
|
||||
shared_result = await session.execute(
|
||||
select(Share.document_id).where(Share.owner_id == current_user.id)
|
||||
items = await _decorate_shared_flags(
|
||||
session,
|
||||
current_user.id,
|
||||
docs[start : start + per_page],
|
||||
)
|
||||
shared_ids = {row[0] for row in shared_result.fetchall()}
|
||||
items = []
|
||||
for d in docs[start : start + per_page]:
|
||||
doc_id_str = d.get("id", "")
|
||||
try:
|
||||
doc_uuid = uuid.UUID(doc_id_str)
|
||||
except (ValueError, AttributeError):
|
||||
doc_uuid = None
|
||||
d["is_shared"] = doc_uuid in shared_ids if doc_uuid else False
|
||||
items.append(d)
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
|
||||
@@ -126,8 +102,6 @@ async def list_documents(
|
||||
order_fn = sort_col.asc if order == "asc" else sort_col.desc
|
||||
stmt = stmt.order_by(order_fn())
|
||||
|
||||
# Full-text search — plainto_tsquery on extracted_text (PostgreSQL only)
|
||||
# Falls back to unfiltered if the DB dialect doesn't support @@ (e.g. SQLite in test env)
|
||||
fts_requested = q is not None and len(q) >= 2
|
||||
if fts_requested:
|
||||
fts_stmt = stmt.where(
|
||||
@@ -143,14 +117,11 @@ async def list_documents(
|
||||
result = await session.execute(stmt)
|
||||
docs_orm = result.scalars().all()
|
||||
|
||||
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()}
|
||||
|
||||
all_items = []
|
||||
shared_ids = await _shared_document_ids(session, current_user.id)
|
||||
for doc in docs_orm:
|
||||
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
|
||||
|
||||
topic_names = await _load_topic_names(session, doc.id)
|
||||
d = _doc_to_dict(doc, topic_names)
|
||||
d["is_shared"] = doc.id in shared_ids
|
||||
@@ -166,8 +137,6 @@ async def list_documents(
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/documents/{doc_id} ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/{doc_id}")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def get_document(
|
||||
@@ -176,44 +145,18 @@ async def get_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Return document metadata by ID.
|
||||
|
||||
D-16: requires authenticated regular user. Asserts ownership — cross-user
|
||||
access returns 404 (not 403) to avoid information leakage (T-03-11).
|
||||
"""
|
||||
"""Return document metadata by ID."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
is_recipient = False
|
||||
if doc.user_id != current_user.id:
|
||||
share_result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == uid,
|
||||
Share.recipient_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if share_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, "Document not found")
|
||||
is_recipient = True
|
||||
_, is_recipient = await get_accessible_document(session, doc_id, current_user.id)
|
||||
|
||||
meta = await storage.get_metadata(session, doc_id)
|
||||
if meta is None:
|
||||
raise HTTPException(404, "Document not found")
|
||||
# T-04-04-03: recipients get metadata only — extracted_text excluded (consistent with /shares/received)
|
||||
if is_recipient:
|
||||
meta.pop("extracted_text", None)
|
||||
return meta
|
||||
|
||||
|
||||
# ── PATCH /api/documents/{doc_id} ────────────────────────────────────────────
|
||||
|
||||
@router.patch("/{doc_id}")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def patch_document(
|
||||
@@ -223,25 +166,9 @@ async def patch_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Update document metadata (filename and/or folder_id).
|
||||
|
||||
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
|
||||
T-05-09-01: ownership check — non-owner gets 404 to avoid leaking document IDs (D-16).
|
||||
T-05-09-02: response uses storage.get_metadata() which excludes credentials_enc and
|
||||
password_hash via the _doc_to_dict whitelist.
|
||||
|
||||
At least one field must be provided — empty body returns 422.
|
||||
folder_id=null moves the document to the root (no folder).
|
||||
"""
|
||||
"""Update document metadata."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
doc = await get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
if not body.model_fields_set:
|
||||
raise HTTPException(422, "At least one field (filename, folder_id) must be provided")
|
||||
@@ -250,7 +177,6 @@ async def patch_document(
|
||||
doc.filename = body.filename
|
||||
|
||||
if "folder_id" in body.model_fields_set:
|
||||
# folder_id=null → move to root (no folder); folder_id=<uuid> → move to folder
|
||||
if body.folder_id is not None:
|
||||
target = await session.get(Folder, body.folder_id)
|
||||
if target is None or target.user_id != current_user.id:
|
||||
@@ -265,8 +191,6 @@ async def patch_document(
|
||||
return meta
|
||||
|
||||
|
||||
# ── DELETE /api/documents/{doc_id} ───────────────────────────────────────────
|
||||
|
||||
@router.delete("/{doc_id}")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def delete_document(
|
||||
@@ -276,27 +200,9 @@ async def delete_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Delete a document and decrement quota atomically.
|
||||
|
||||
For cloud-stored documents:
|
||||
- Default path: attempt cloud provider delete first; on failure return
|
||||
{success: false, cloud_delete_failed: true} (HTTP 200) so the frontend
|
||||
can offer a "Remove from app" fallback (T-06.2-03-02).
|
||||
- remove_only=true: skip cloud delete, remove DB row only, skip quota decrement.
|
||||
- Cloud docs always use skip_quota=True (never charged MinIO quota, T-06.2-03-01).
|
||||
|
||||
D-16: requires authenticated regular user. Asserts ownership — cross-user
|
||||
delete returns 404 (not 403) to avoid information leakage (T-03-11).
|
||||
"""
|
||||
"""Delete a document and decrement quota when appropriate."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
doc = await get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
is_cloud = doc.storage_backend != "minio"
|
||||
_doc_size = doc.size_bytes
|
||||
@@ -305,7 +211,8 @@ async def delete_document(
|
||||
|
||||
if is_cloud and not remove_only:
|
||||
try:
|
||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
||||
import api.documents as _doc_pkg
|
||||
|
||||
_gsb = _doc_pkg.get_storage_backend_for_document
|
||||
cloud_backend = await _gsb(doc, current_user, session)
|
||||
await cloud_backend.delete_object(doc.object_key)
|
||||
@@ -320,13 +227,10 @@ async def delete_document(
|
||||
},
|
||||
)
|
||||
|
||||
# auto_commit=False defers the commit so the audit log write below happens
|
||||
# in the same transaction — avoids the split-transaction gap (WR-08).
|
||||
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
# D-13: document deleted event — written in the same transaction as the delete (WR-08).
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.deleted",
|
||||
@@ -341,8 +245,6 @@ async def delete_document(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ── POST /api/documents/{doc_id}/classify ────────────────────────────────────
|
||||
|
||||
@router.post("/{doc_id}/classify")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def classify_document(
|
||||
@@ -351,25 +253,9 @@ async def classify_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Re-queue a document for classification via Celery (D-11).
|
||||
|
||||
Sets doc.status='processing', commits, dispatches extract_and_classify.delay(),
|
||||
and returns {'document_id': str, 'status': 'processing'}.
|
||||
|
||||
D-16: requires authenticated regular user. Asserts ownership — cross-user
|
||||
classify returns 404 (not 403) to avoid information leakage (T-03-11).
|
||||
T-07-10: ownership enforced here; IDOR returns 404 per STATE.md policy.
|
||||
Placed in crud.py per D-08: same ownership-check pattern as get/patch/delete.
|
||||
"""
|
||||
"""Re-queue a document for classification via Celery."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
doc = await get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
doc.status = "processing"
|
||||
await session.commit()
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
"""Shared constants and Pydantic request models for the documents API package.
|
||||
|
||||
CODE-08: Single definition of _CLOUD_PROVIDERS, UploadUrlRequest, and DocumentPatch.
|
||||
These are imported by upload.py and crud.py — never duplicated.
|
||||
|
||||
T-05-06-01: _CLOUD_PROVIDERS is an allowlist frozenset; target_backend validated
|
||||
against it (never against user-supplied strings).
|
||||
T-05-09-01: DocumentPatch fields declared explicitly — mass assignment prevented.
|
||||
T-05-09-02: filename_no_path_separators validator preserved verbatim (path traversal
|
||||
defense at the API boundary — D-11 analysis: stays in Pydantic model).
|
||||
"""
|
||||
"""Shared constants, request models, and access helpers for document routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Share
|
||||
|
||||
# Valid cloud backend slugs (T-05-06-01: validated against allowlist, not user-supplied string)
|
||||
_CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"})
|
||||
|
||||
|
||||
@@ -26,14 +20,6 @@ class UploadUrlRequest(BaseModel):
|
||||
|
||||
|
||||
class DocumentPatch(BaseModel):
|
||||
"""Pydantic model for PATCH /api/documents/{doc_id}.
|
||||
|
||||
Optional fields — model_fields_set distinguishes "not provided" from "set to null".
|
||||
At least one field must be present in model_fields_set (enforced in the handler).
|
||||
|
||||
T-05-09-01: explicit field declaration prevents mass assignment.
|
||||
T-05-09-02: only filename and folder_id are accepted — no other fields can be set.
|
||||
"""
|
||||
filename: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
folder_id: Optional[uuid.UUID] = None
|
||||
|
||||
@@ -43,3 +29,50 @@ class DocumentPatch(BaseModel):
|
||||
if v is not None and ("/" in v or "\\" in v):
|
||||
raise ValueError("filename must not contain path separators")
|
||||
return v
|
||||
|
||||
|
||||
def _document_not_found() -> HTTPException:
|
||||
return HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
|
||||
def parse_document_uuid(doc_id: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise _document_not_found()
|
||||
|
||||
|
||||
async def get_owned_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = parse_document_uuid(doc_id)
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != user_id:
|
||||
raise _document_not_found()
|
||||
return doc
|
||||
|
||||
|
||||
async def get_accessible_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> tuple[Document, bool]:
|
||||
uid = parse_document_uuid(doc_id)
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None:
|
||||
raise _document_not_found()
|
||||
|
||||
if doc.user_id == user_id:
|
||||
return doc, False
|
||||
|
||||
result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == doc.id,
|
||||
Share.recipient_id == user_id,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise _document_not_found()
|
||||
return doc, True
|
||||
|
||||
+99
-179
@@ -1,32 +1,11 @@
|
||||
"""Document upload endpoints — presigned URL flow and direct cloud upload.
|
||||
|
||||
Endpoints:
|
||||
POST /upload-url — create pending Document row, return presigned PUT URL (D-05 step 1)
|
||||
POST /upload — direct multipart upload supporting cloud backends (D-10, D-14, D-15)
|
||||
POST /{doc_id}/confirm — stat MinIO for authoritative size, enforce quota atomically (D-05 step 3)
|
||||
|
||||
Sub-router carries NO prefix — prefix="/api/documents" lives in __init__.py (D-04).
|
||||
|
||||
Security:
|
||||
T-03-04: object_key computed server-side using str(current_user.id) — never user-supplied.
|
||||
T-03-05: size from backend.stat_object() — never from client.
|
||||
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
|
||||
T-03-11: ownership assertion on confirm — cross-user access returns 404.
|
||||
T-03-15: object_key prefix always the authenticated user's id.
|
||||
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist.
|
||||
T-05-06-02: CloudConnectionError detail never includes provider error detail.
|
||||
"""
|
||||
"""Document upload endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import structlog as _structlog
|
||||
|
||||
_log = _structlog.get_logger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from sqlalchemy import text
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
@@ -36,24 +15,91 @@ from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from services.rate_limiting import account_limiter
|
||||
from storage import get_storage_backend, get_storage_backend_for_document
|
||||
from storage import get_storage_backend
|
||||
from storage.cloud_backend_factory import build_cloud_backend
|
||||
from storage.cloud_utils import decrypt_credentials
|
||||
from storage.exceptions import CloudConnectionError
|
||||
from tasks.document_tasks import extract_and_classify
|
||||
|
||||
try:
|
||||
from minio.error import S3Error
|
||||
except ImportError:
|
||||
S3Error = Exception # type: ignore[assignment,misc]
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── POST /api/documents/upload-url ───────────────────────────────────────────
|
||||
def _new_minio_document(user_id: uuid.UUID, filename: str, content_type: str) -> Document:
|
||||
doc_id = uuid.uuid4()
|
||||
suffix = Path(filename).suffix.lower()
|
||||
return Document(
|
||||
id=doc_id,
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
size_bytes=0,
|
||||
storage_backend="minio",
|
||||
status="pending",
|
||||
object_key=f"{user_id}/{doc_id}/{uuid.uuid4()}{suffix}",
|
||||
)
|
||||
|
||||
|
||||
async def _create_presigned_upload(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
) -> dict:
|
||||
doc = _new_minio_document(user_id, filename, content_type)
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
upload_url = await get_storage_backend().generate_presigned_put_url(
|
||||
doc.object_key, expires_minutes=15
|
||||
)
|
||||
return {"upload_url": upload_url, "document_id": str(doc.id)}
|
||||
|
||||
|
||||
async def _get_active_cloud_connection(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
provider: str,
|
||||
) -> CloudConnection:
|
||||
result = await session.execute(
|
||||
select(CloudConnection).where(
|
||||
CloudConnection.user_id == user_id,
|
||||
CloudConnection.provider == provider,
|
||||
CloudConnection.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
conn = result.scalar_one_or_none()
|
||||
if conn is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active {provider} connection found. Please connect in Settings.",
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def _decrypt_cloud_credentials(conn: CloudConnection, user_id: uuid.UUID) -> dict:
|
||||
return decrypt_credentials(settings.cloud_creds_key.encode(), str(user_id), conn.credentials_enc)
|
||||
|
||||
|
||||
async def _record_upload(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
current_user: User,
|
||||
doc: Document,
|
||||
size_bytes: int,
|
||||
storage_backend: str,
|
||||
) -> None:
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.uploaded",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=doc.id,
|
||||
ip_address=get_client_ip(request) if request else None,
|
||||
metadata_={"size_bytes": size_bytes, "storage_backend": storage_backend},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload-url")
|
||||
@account_limiter.limit("100/minute")
|
||||
@@ -63,41 +109,15 @@ async def request_upload_url(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Create a pending Document row and return a presigned PUT URL.
|
||||
|
||||
D-05 step 1: FastAPI creates a Document row (status='pending'), generates a
|
||||
15-minute presigned PUT URL, returns {upload_url, document_id}.
|
||||
Quota is NOT reserved at this step — quota enforcement happens at /confirm.
|
||||
|
||||
T-03-04: object_key is computed server-side using str(current_user.id); filename
|
||||
stored in DB only (CLAUDE.md MinIO key schema).
|
||||
T-03-15: object_key prefix is always the authenticated user's id — never user-supplied.
|
||||
"""
|
||||
"""Create a pending Document row and return a presigned PUT URL."""
|
||||
request.state.current_user = current_user
|
||||
doc_id = uuid.uuid4()
|
||||
suffix = Path(body.filename).suffix.lower()
|
||||
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=current_user.id,
|
||||
filename=body.filename,
|
||||
content_type=body.content_type,
|
||||
size_bytes=0,
|
||||
storage_backend="minio",
|
||||
status="pending",
|
||||
object_key=object_key,
|
||||
return await _create_presigned_upload(
|
||||
session,
|
||||
current_user.id,
|
||||
body.filename,
|
||||
body.content_type,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
upload_url = await get_storage_backend().generate_presigned_put_url(
|
||||
object_key, expires_minutes=15
|
||||
)
|
||||
return {"upload_url": upload_url, "document_id": str(doc_id)}
|
||||
|
||||
|
||||
# ── POST /api/documents/upload ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/upload")
|
||||
@account_limiter.limit("100/minute")
|
||||
@@ -109,47 +129,15 @@ async def upload_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Direct multipart upload endpoint supporting cloud backends (D-10, D-14, D-15).
|
||||
|
||||
If target_backend == "minio": generates a presigned PUT URL (unchanged MinIO flow).
|
||||
If target_backend in ("google_drive", "onedrive", "nextcloud", "webdav"):
|
||||
1. Reads file bytes from UploadFile
|
||||
2. Loads CloudConnection for current_user.id + target_backend; 404 if not found/not ACTIVE
|
||||
3. Decrypts credentials and instantiates the correct backend class
|
||||
4. Calls cloud_backend.put_object() to upload directly to the provider
|
||||
5. Creates Document with storage_backend=target_backend
|
||||
6. Returns {document_id, storage_backend} — no upload_url (cloud upload is synchronous)
|
||||
|
||||
Cloud uploads do NOT use the atomic quota UPDATE — cloud files are not counted
|
||||
against MinIO quota (D-11: separate backends; cloud storage quota is provider-side).
|
||||
|
||||
Security:
|
||||
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist → 422 on invalid value
|
||||
T-05-06-02: CloudConnectionError detail message never includes provider error detail
|
||||
"""
|
||||
"""Direct multipart upload endpoint supporting cloud backends."""
|
||||
request.state.current_user = current_user
|
||||
if target_backend == "minio":
|
||||
doc_id = uuid.uuid4()
|
||||
suffix = Path(file.filename or "file").suffix.lower()
|
||||
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=current_user.id,
|
||||
filename=file.filename or "upload",
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=0,
|
||||
storage_backend="minio",
|
||||
status="pending",
|
||||
object_key=object_key,
|
||||
return await _create_presigned_upload(
|
||||
session,
|
||||
current_user.id,
|
||||
file.filename or "upload",
|
||||
file.content_type or "application/octet-stream",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
upload_url = await get_storage_backend().generate_presigned_put_url(
|
||||
object_key, expires_minutes=15
|
||||
)
|
||||
return {"upload_url": upload_url, "document_id": str(doc_id)}
|
||||
|
||||
if target_backend not in _CLOUD_PROVIDERS:
|
||||
raise HTTPException(
|
||||
@@ -157,23 +145,8 @@ async def upload_document(
|
||||
detail=f"Invalid target_backend '{target_backend}'. Valid values: minio, {', '.join(sorted(_CLOUD_PROVIDERS))}",
|
||||
)
|
||||
|
||||
# Load active CloudConnection for current user + provider (T-05-06-01: user-scoped query)
|
||||
result = await session.execute(
|
||||
select(CloudConnection).where(
|
||||
CloudConnection.user_id == current_user.id,
|
||||
CloudConnection.provider == target_backend,
|
||||
CloudConnection.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
conn = result.scalar_one_or_none()
|
||||
if conn is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active {target_backend} connection found. Please connect in Settings.",
|
||||
)
|
||||
|
||||
master_key = settings.cloud_creds_key.encode()
|
||||
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
|
||||
conn = await _get_active_cloud_connection(session, current_user.id, target_backend)
|
||||
credentials = _decrypt_cloud_credentials(conn, current_user.id)
|
||||
|
||||
file_bytes = await file.read()
|
||||
filename = file.filename or "upload"
|
||||
@@ -182,26 +155,7 @@ async def upload_document(
|
||||
|
||||
doc_id = uuid.uuid4()
|
||||
|
||||
if target_backend == "google_drive":
|
||||
from storage.google_drive_backend import GoogleDriveBackend # lazy import
|
||||
cloud_backend = GoogleDriveBackend(credentials)
|
||||
elif target_backend == "onedrive":
|
||||
from storage.onedrive_backend import OneDriveBackend # lazy import
|
||||
cloud_backend = OneDriveBackend(credentials)
|
||||
elif target_backend == "nextcloud":
|
||||
from storage.nextcloud_backend import NextcloudBackend # lazy import
|
||||
cloud_backend = NextcloudBackend(
|
||||
credentials["server_url"],
|
||||
credentials["username"],
|
||||
credentials["password"],
|
||||
)
|
||||
elif target_backend == "webdav":
|
||||
from storage.webdav_backend import WebDAVBackend # lazy import
|
||||
cloud_backend = WebDAVBackend(
|
||||
credentials["server_url"],
|
||||
credentials["username"],
|
||||
credentials["password"],
|
||||
)
|
||||
cloud_backend = build_cloud_backend(target_backend, credentials)
|
||||
|
||||
try:
|
||||
object_key = await cloud_backend.put_object(
|
||||
@@ -219,9 +173,9 @@ async def upload_document(
|
||||
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
|
||||
) from exc
|
||||
|
||||
# Bust folder listing cache so the next GET /folders reflects the new file
|
||||
if cloud_folder_path:
|
||||
from services.cloud_cache import invalidate_provider_cache # lazy import
|
||||
|
||||
invalidate_provider_cache(str(current_user.id), target_backend)
|
||||
|
||||
doc = Document(
|
||||
@@ -236,16 +190,7 @@ async def upload_document(
|
||||
)
|
||||
session.add(doc)
|
||||
|
||||
_ip = get_client_ip(request) if request else None
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.uploaded",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=doc.id,
|
||||
ip_address=_ip,
|
||||
metadata_={"size_bytes": len(file_bytes), "storage_backend": target_backend},
|
||||
)
|
||||
await _record_upload(session, request, current_user, doc, len(file_bytes), target_backend)
|
||||
await session.commit()
|
||||
|
||||
extract_and_classify.delay(str(doc.id))
|
||||
@@ -253,8 +198,6 @@ async def upload_document(
|
||||
return {"document_id": str(doc.id), "storage_backend": target_backend}
|
||||
|
||||
|
||||
# ── POST /api/documents/{doc_id}/confirm ─────────────────────────────────────
|
||||
|
||||
@router.post("/{doc_id}/confirm")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def confirm_upload(
|
||||
@@ -263,18 +206,7 @@ async def confirm_upload(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Confirm a presigned PUT upload: stat MinIO for size, enforce quota atomically.
|
||||
|
||||
D-05 step 3: FastAPI reads authoritative file size from MinIO stat_object (never
|
||||
from client), runs atomic quota UPDATE, sets status='uploaded', enqueues Celery task.
|
||||
|
||||
Quota exceeded: HTTP 413 with {"used_bytes": N, "limit_bytes": M, "rejected_bytes": K}
|
||||
Upload not found: HTTP 422 (presigned URL may have expired)
|
||||
|
||||
T-03-05: size always comes from backend.stat_object(doc.object_key) — never client.
|
||||
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
|
||||
T-03-11: ownership assertion — cross-user access returns 404 (D-16).
|
||||
"""
|
||||
"""Confirm a presigned PUT upload and enforce quota."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
@@ -285,7 +217,6 @@ async def confirm_upload(
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Get authoritative file size from MinIO (T-03-05 — never trust client-supplied size)
|
||||
try:
|
||||
size = await get_storage_backend().stat_object(doc.object_key)
|
||||
except Exception as exc:
|
||||
@@ -300,7 +231,6 @@ async def confirm_upload(
|
||||
doc.size_bytes = size
|
||||
await session.flush()
|
||||
|
||||
# Atomic quota enforcement — user_id is always set post-migration (Plan 03-03+)
|
||||
result = await session.execute(
|
||||
text(
|
||||
"UPDATE quotas "
|
||||
@@ -323,7 +253,7 @@ async def confirm_upload(
|
||||
try:
|
||||
await get_storage_backend().delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # MinIO cleanup is best-effort; object TTL will eventually expire
|
||||
pass
|
||||
await session.commit()
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
@@ -337,17 +267,7 @@ async def confirm_upload(
|
||||
used_bytes = row.used_bytes
|
||||
|
||||
doc.status = "uploaded"
|
||||
# D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02)
|
||||
_ip = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.uploaded",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=doc.id,
|
||||
ip_address=_ip,
|
||||
metadata_={"size_bytes": size, "storage_backend": "minio"},
|
||||
)
|
||||
await _record_upload(session, request, current_user, doc, size, "minio")
|
||||
await session.commit()
|
||||
extract_and_classify.delay(str(doc.id))
|
||||
|
||||
|
||||
+90
-199
@@ -1,21 +1,4 @@
|
||||
"""
|
||||
Folder API endpoints for DocuVault — Phase 4, Plan 03.
|
||||
|
||||
Implements FOLD-01 through FOLD-05:
|
||||
POST /api/folders — create folder (FOLD-01)
|
||||
GET /api/folders — list top-level folders (FOLD-02)
|
||||
GET /api/folders/{id} — get folder + breadcrumb (FOLD-02)
|
||||
PATCH /api/folders/{id} — rename folder (FOLD-03)
|
||||
DELETE /api/folders/{id} — delete folder (cascade) (FOLD-03)
|
||||
PATCH /api/documents/{id}/folder — move document to folder (FOLD-04)
|
||||
|
||||
Security invariants (all enforced):
|
||||
T-04-03-01: get_regular_user on all endpoints (admin gets 403)
|
||||
T-04-03-04: All folder IDOR paths return 404 not 403
|
||||
T-04-03-05: PATCH /api/documents/{id}/folder validates both doc and target folder ownership
|
||||
T-04-03-06: IntegrityError (duplicate folder name) → 409 Conflict
|
||||
T-04-03-03: Atomic quota decrement with CASE WHEN pattern (SQLite compat)
|
||||
"""
|
||||
"""Folder and document-folder organization endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -23,11 +6,11 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, text, func
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Folder, Quota, Share, User
|
||||
from db.models import Document, Folder, User
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
@@ -37,8 +20,6 @@ from storage import get_storage_backend
|
||||
router = APIRouter(prefix="/api/folders", tags=["folders"])
|
||||
|
||||
|
||||
# ── Request / response models ─────────────────────────────────────────────────
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: Optional[str] = None
|
||||
@@ -52,9 +33,6 @@ class DocumentMove(BaseModel):
|
||||
folder_id: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
# ── Helper: folder serialization ──────────────────────────────────────────────
|
||||
|
||||
def _folder_to_dict(folder: Folder) -> dict:
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
@@ -65,8 +43,6 @@ def _folder_to_dict(folder: Folder) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── Helper: document serialization ────────────────────────────────────────────
|
||||
|
||||
def _doc_to_dict(doc: Document) -> dict:
|
||||
return {
|
||||
"id": str(doc.id),
|
||||
@@ -81,7 +57,67 @@ def _doc_to_dict(doc: Document) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── POST /api/folders ─────────────────────────────────────────────────────────
|
||||
def _parse_uuid(value: str, not_found_detail: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=not_found_detail)
|
||||
|
||||
|
||||
async def _get_owned_folder(
|
||||
session: AsyncSession,
|
||||
folder_id: str | uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
detail: str = "Folder not found",
|
||||
) -> Folder:
|
||||
uid = folder_id if isinstance(folder_id, uuid.UUID) else _parse_uuid(folder_id, detail)
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
return folder
|
||||
|
||||
|
||||
async def _get_owned_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = _parse_uuid(doc_id, "Document not found")
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return doc
|
||||
|
||||
|
||||
async def _ensure_unique_folder_name(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
parent_id: Optional[uuid.UUID],
|
||||
exclude_id: Optional[uuid.UUID] = None,
|
||||
) -> None:
|
||||
stmt = select(Folder).where(
|
||||
Folder.user_id == user_id,
|
||||
Folder.name == name,
|
||||
Folder.parent_id == parent_id,
|
||||
)
|
||||
if exclude_id is not None:
|
||||
stmt = stmt.where(Folder.id != exclude_id)
|
||||
|
||||
dup = await session.execute(stmt)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
|
||||
def _duplicate_folder_error() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(
|
||||
@@ -90,35 +126,14 @@ async def create_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Create a new folder for the current user.
|
||||
|
||||
FOLD-01: parent_id (if given) must belong to current_user — 404 otherwise.
|
||||
Duplicate name under same parent returns 409 (T-04-03-06).
|
||||
"""
|
||||
parent_uuid: Optional[uuid.UUID] = None
|
||||
if body.parent_id is not None:
|
||||
try:
|
||||
parent_uuid = uuid.UUID(body.parent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await session.get(Folder, parent_uuid)
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await _get_owned_folder(
|
||||
session, body.parent_id, current_user.id, "Parent folder not found"
|
||||
)
|
||||
parent_uuid = parent.id
|
||||
|
||||
# Explicit duplicate check — UniqueConstraint won't fire when parent_id IS NULL
|
||||
# because SQL treats NULL as distinct from NULL in unique indexes.
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == parent_uuid,
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
await _ensure_unique_folder_name(session, current_user.id, body.name, parent_uuid)
|
||||
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
@@ -130,10 +145,7 @@ async def create_folder(
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
raise _duplicate_folder_error()
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -148,34 +160,20 @@ async def create_folder(
|
||||
return _folder_to_dict(folder)
|
||||
|
||||
|
||||
# ── GET /api/folders ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_folders(
|
||||
parent_id: Optional[str] = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""List the current user's folders at a given level.
|
||||
|
||||
FOLD-02: when parent_id is omitted, returns root folders (parent_id IS NULL).
|
||||
When parent_id is supplied, returns that folder's direct children (asserts ownership).
|
||||
Each folder includes has_children so the frontend can hide expand arrows on leaf nodes.
|
||||
"""
|
||||
parent_uuid: Optional[uuid.UUID] = None
|
||||
if parent_id is not None:
|
||||
try:
|
||||
parent_uuid = uuid.UUID(parent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent_folder = await session.get(Folder, parent_uuid)
|
||||
if parent_folder is None or parent_folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent_folder = await _get_owned_folder(
|
||||
session, parent_id, current_user.id, "Parent folder not found"
|
||||
)
|
||||
parent_uuid = parent_folder.id
|
||||
|
||||
if parent_uuid is None:
|
||||
where_clause = Folder.parent_id.is_(None)
|
||||
else:
|
||||
where_clause = Folder.parent_id == parent_uuid
|
||||
where_clause = Folder.parent_id.is_(None) if parent_uuid is None else Folder.parent_id == parent_uuid
|
||||
|
||||
result = await session.execute(
|
||||
select(Folder)
|
||||
@@ -184,8 +182,6 @@ async def list_folders(
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
|
||||
# One extra query to know which of these folders have sub-folders.
|
||||
# Allows the frontend to hide expand arrows on leaf nodes without extra round-trips.
|
||||
folder_ids = [f.id for f in folders]
|
||||
folders_with_children: set = set()
|
||||
if folder_ids:
|
||||
@@ -205,55 +201,34 @@ async def list_folders(
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/folders/{folder_id} ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{folder_id}")
|
||||
async def get_folder(
|
||||
folder_id: str,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Get folder metadata + breadcrumb array from root to this folder.
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
|
||||
FOLD-02 / FOLD-05: breadcrumb is built via iterative parent-walk in Python
|
||||
(not WITH RECURSIVE) so it is compatible with both PostgreSQL and SQLite tests.
|
||||
|
||||
Response: {id, name, parent_id, user_id, created_at, breadcrumb: [{id, name}, ...]}
|
||||
The breadcrumb array is ordered root-first (root is breadcrumb[0]).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
# Build breadcrumb by walking up the parent chain iteratively.
|
||||
# Ownership check on each ancestor ensures no cross-user traversal.
|
||||
crumbs = [{"id": str(folder.id), "name": folder.name}]
|
||||
current = folder
|
||||
visited: set = {current.id}
|
||||
while current.parent_id is not None:
|
||||
if current.parent_id in visited:
|
||||
break # cycle guard (should not occur with proper constraints)
|
||||
break
|
||||
parent = await session.get(Folder, current.parent_id)
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
break # stop traversal if parent is inaccessible
|
||||
break
|
||||
visited.add(parent.id)
|
||||
crumbs.append({"id": str(parent.id), "name": parent.name})
|
||||
current = parent
|
||||
|
||||
crumbs.reverse() # root-first order
|
||||
crumbs.reverse()
|
||||
|
||||
response = _folder_to_dict(folder)
|
||||
response["breadcrumb"] = crumbs
|
||||
return response
|
||||
|
||||
|
||||
# ── PATCH /api/folders/{folder_id} ───────────────────────────────────────────
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
@@ -262,46 +237,20 @@ async def rename_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Rename a folder.
|
||||
|
||||
FOLD-03: asserts ownership → 404 if not owner.
|
||||
Duplicate name under same parent returns 409 (T-04-03-06).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
old_name = folder.name
|
||||
# Explicit duplicate check — same NULL parent_id issue as create_folder.
|
||||
|
||||
if body.name != folder.name:
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == folder.parent_id,
|
||||
Folder.id != folder.id,
|
||||
)
|
||||
await _ensure_unique_folder_name(
|
||||
session, current_user.id, body.name, folder.parent_id, folder.id
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
folder.name = body.name
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
raise _duplicate_folder_error()
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -316,8 +265,6 @@ async def rename_folder(
|
||||
return _folder_to_dict(folder)
|
||||
|
||||
|
||||
# ── DELETE /api/folders/{folder_id} ──────────────────────────────────────────
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
@@ -325,30 +272,9 @@ async def delete_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Delete a folder and all of its contents (cascade).
|
||||
|
||||
FOLD-03 + D-03:
|
||||
- Collects all documents in the folder subtree using WITH RECURSIVE CTE
|
||||
(wraps in try/except OperationalError for SQLite test compat; fallback
|
||||
uses direct children only).
|
||||
- Sums size_bytes, performs atomic quota decrement (CASE WHEN pattern for
|
||||
SQLite compat — T-04-03-03).
|
||||
- Deletes MinIO objects best-effort (per-object try/except — PATTERNS.md Pattern 2).
|
||||
- Deletes all document rows and the folder row via ORM.
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
folder_name = folder.name
|
||||
|
||||
# Collect all folder IDs in the subtree via WITH RECURSIVE CTE.
|
||||
# Falls back to direct-children-only on SQLite (OperationalError on recursive CTE).
|
||||
subtree_folder_ids: list[str] = []
|
||||
try:
|
||||
cte_result = await session.execute(
|
||||
@@ -361,17 +287,13 @@ async def delete_folder(
|
||||
" WHERE f.user_id = :uid"
|
||||
") SELECT id FROM subtree"
|
||||
),
|
||||
# Use .hex (no dashes) — SQLite stores UUID as 32-char hex; PostgreSQL accepts both.
|
||||
{"root_id": folder.id.hex, "uid": current_user.id.hex},
|
||||
)
|
||||
subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()]
|
||||
except OperationalError:
|
||||
# SQLite fallback: only direct children of this folder
|
||||
subtree_folder_ids = [str(folder.id)]
|
||||
|
||||
# Collect all documents in the subtree folder IDs
|
||||
if subtree_folder_ids:
|
||||
# Build UUID list for IN query
|
||||
subtree_uuids = []
|
||||
for fid in subtree_folder_ids:
|
||||
try:
|
||||
@@ -394,7 +316,6 @@ async def delete_folder(
|
||||
|
||||
total_bytes = sum(d.size_bytes for d in docs)
|
||||
|
||||
# Atomic quota decrement (CASE WHEN for SQLite compat — never goes below 0)
|
||||
if total_bytes > 0:
|
||||
await session.execute(
|
||||
text(
|
||||
@@ -406,20 +327,16 @@ async def delete_folder(
|
||||
{"delta": total_bytes, "uid": current_user.id.hex},
|
||||
)
|
||||
|
||||
# Delete MinIO objects best-effort (per-object, never abort on failure)
|
||||
storage_backend = get_storage_backend()
|
||||
for doc in docs:
|
||||
try:
|
||||
await storage_backend.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # best-effort; stale MinIO objects will be garbage-collected
|
||||
pass
|
||||
|
||||
# Delete document rows
|
||||
for doc in docs:
|
||||
await session.delete(doc)
|
||||
|
||||
# Delete the folder (cascade will handle sub-folders in PostgreSQL;
|
||||
# in SQLite test env we already collected and deleted all documents)
|
||||
await session.delete(folder)
|
||||
await session.commit()
|
||||
|
||||
@@ -428,17 +345,12 @@ async def delete_folder(
|
||||
event_type="folder.deleted",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=uid,
|
||||
resource_id=folder.id,
|
||||
ip_address=get_client_ip(request),
|
||||
metadata_={"name": folder_name, "doc_count": len(docs)},
|
||||
)
|
||||
|
||||
|
||||
# ── PATCH /api/documents/{doc_id}/folder ─────────────────────────────────────
|
||||
# This endpoint lives in the folders router (not documents router) because it
|
||||
# is logically a folder organisation operation. The URL prefix /api/documents
|
||||
# is achieved via an explicit path on this APIRouter. FastAPI supports this.
|
||||
|
||||
document_move_router = APIRouter(prefix="/api/documents", tags=["folders"])
|
||||
|
||||
|
||||
@@ -450,33 +362,12 @@ async def move_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Move a document to a different folder (or to root if folder_id is null).
|
||||
|
||||
FOLD-04:
|
||||
- Asserts document ownership → 404 if not owner.
|
||||
- If folder_id given: asserts target folder ownership → 404 if not owner
|
||||
(T-04-03-05: cross-user folder assignment blocked).
|
||||
- Updates doc.folder_id and commits.
|
||||
- Returns 200 with updated document dict.
|
||||
"""
|
||||
try:
|
||||
doc_uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
doc = await session.get(Document, doc_uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
doc = await _get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
target_folder_uuid: Optional[uuid.UUID] = None
|
||||
if body.folder_id is not None:
|
||||
try:
|
||||
target_folder_uuid = uuid.UUID(body.folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
target_folder = await session.get(Folder, target_folder_uuid)
|
||||
if target_folder is None or target_folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
target_folder = await _get_owned_folder(session, body.folder_id, current_user.id)
|
||||
target_folder_uuid = target_folder.id
|
||||
|
||||
doc.folder_id = target_folder_uuid
|
||||
await session.commit()
|
||||
|
||||
+68
-137
@@ -1,23 +1,7 @@
|
||||
"""
|
||||
Sharing API for DocuVault — Phase 4, Plan 04-04.
|
||||
|
||||
Implements SHARE-01 through SHARE-05:
|
||||
POST /api/shares — grant share by recipient handle
|
||||
GET /api/shares — list shares owned by current user for a document
|
||||
GET /api/shares/received — virtual "Shared with me" folder (metadata only)
|
||||
DELETE /api/shares/{share_id} — revoke share with IDOR protection
|
||||
|
||||
Security invariants:
|
||||
T-04-04-02: DELETE asserts share.owner_id == current_user.id → 404 on mismatch
|
||||
T-04-04-03: GET /received returns metadata only — extracted_text is never included
|
||||
T-04-04-04: No quota table is touched anywhere in this module
|
||||
T-04-04-05: UniqueConstraint(document_id, recipient_id) → IntegrityError → 409
|
||||
"""
|
||||
"""Document sharing endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import select
|
||||
@@ -33,9 +17,6 @@ from services.audit import write_audit_log
|
||||
router = APIRouter(prefix="/api/shares", tags=["shares"])
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ShareCreate(BaseModel):
|
||||
document_id: str
|
||||
recipient_handle: str
|
||||
@@ -60,11 +41,47 @@ class SharePermissionPatch(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
def _parse_uuid(value: str, detail: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
|
||||
|
||||
async def _get_owned_document(
|
||||
session: AsyncSession,
|
||||
document_id: str,
|
||||
owner_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = _parse_uuid(document_id, "Document not found")
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != owner_id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return doc
|
||||
|
||||
# ── POST /api/shares ──────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_owned_share(
|
||||
session: AsyncSession,
|
||||
share_id: str,
|
||||
owner_id: uuid.UUID,
|
||||
) -> Share:
|
||||
sid = _parse_uuid(share_id, "Share not found")
|
||||
share = await session.get(Share, sid)
|
||||
if share is None or share.owner_id != owner_id:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
return share
|
||||
|
||||
|
||||
def _share_to_dict(share: Share, recipient: User) -> dict:
|
||||
return {
|
||||
"id": str(share.id),
|
||||
"document_id": str(share.document_id),
|
||||
"owner_id": str(share.owner_id),
|
||||
"recipient_id": str(share.recipient_id),
|
||||
"recipient_handle": recipient.handle,
|
||||
"permission": share.permission,
|
||||
"created_at": share.created_at.isoformat() if share.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@@ -74,22 +91,7 @@ async def grant_share(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> dict:
|
||||
"""Grant document share to a user identified by their handle (SHARE-01, D-04).
|
||||
|
||||
T-04-04-06: Only document owner can grant; 404 prevents ID enumeration.
|
||||
T-04-04-01: get_regular_user ensures admins cannot invoke this endpoint.
|
||||
T-04-04-05: Duplicate share → IntegrityError → 409 (no unbounded inserts).
|
||||
"""
|
||||
# Parse document_id as UUID (T-03-11 pattern)
|
||||
try:
|
||||
uid = uuid.UUID(body.document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Ownership assertion — 404 prevents ID enumeration
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
doc = await _get_owned_document(session, body.document_id, current_user.id)
|
||||
|
||||
# Recipient lookup by exact handle (D-04)
|
||||
result = await session.execute(
|
||||
@@ -105,7 +107,7 @@ async def grant_share(
|
||||
|
||||
# Create the share row
|
||||
share = Share(
|
||||
document_id=uid,
|
||||
document_id=doc.id,
|
||||
owner_id=current_user.id,
|
||||
recipient_id=recipient.id,
|
||||
permission=body.permission,
|
||||
@@ -126,25 +128,14 @@ async def grant_share(
|
||||
event_type="share.granted",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=uid,
|
||||
resource_id=doc.id,
|
||||
ip_address=get_client_ip(request),
|
||||
metadata_={"recipient_id": str(recipient.id)},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"id": str(share.id),
|
||||
"document_id": str(share.document_id),
|
||||
"owner_id": str(share.owner_id),
|
||||
"recipient_id": str(share.recipient_id),
|
||||
"recipient_handle": recipient.handle,
|
||||
"permission": share.permission,
|
||||
"created_at": share.created_at.isoformat() if share.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/shares ───────────────────────────────────────────────────────────
|
||||
return _share_to_dict(share, recipient)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -153,46 +144,28 @@ async def list_shares(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> dict:
|
||||
"""List shares owned by current user for a specific document (SHARE-01, D-05).
|
||||
doc = await _get_owned_document(session, document_id, current_user.id)
|
||||
|
||||
Only the document owner can list shares — 404 on mismatch or bad UUID.
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Join Share with User to get recipient handles
|
||||
stmt = (
|
||||
select(Share, User)
|
||||
.join(User, User.id == Share.recipient_id)
|
||||
.where(Share.document_id == uid)
|
||||
.where(Share.document_id == doc.id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for share, recipient in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": str(share.id),
|
||||
"recipient_id": str(share.recipient_id),
|
||||
"recipient_handle": recipient.handle,
|
||||
"permission": share.permission,
|
||||
"created_at": share.created_at.isoformat()
|
||||
if share.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(share.id),
|
||||
"recipient_id": str(share.recipient_id),
|
||||
"recipient_handle": recipient.handle,
|
||||
"permission": share.permission,
|
||||
"created_at": share.created_at.isoformat() if share.created_at else None,
|
||||
}
|
||||
for share, recipient in result.all()
|
||||
]
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
# ── GET /api/shares/received ──────────────────────────────────────────────────
|
||||
# CRITICAL: This endpoint MUST be defined BEFORE DELETE /api/shares/{share_id}.
|
||||
# Defining it after would cause FastAPI to route GET /api/shares/received as
|
||||
# DELETE with share_id="received" (path parameter conflict).
|
||||
@@ -203,12 +176,6 @@ async def list_shared_with_me(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> dict:
|
||||
"""Return documents shared WITH the current user (virtual "Shared with me" folder — D-06).
|
||||
|
||||
T-04-04-03: Only metadata is returned — extracted_text is never included.
|
||||
T-04-04-04: No quota is modified.
|
||||
Response: {items: [{id, filename, content_type, size_bytes, created_at, owner_handle}]}
|
||||
"""
|
||||
stmt = (
|
||||
select(Document, User)
|
||||
.join(Share, Share.document_id == Document.id)
|
||||
@@ -219,26 +186,21 @@ async def list_shared_with_me(
|
||||
result = await session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for doc, owner in rows:
|
||||
# T-04-04-03: extracted_text is intentionally excluded here
|
||||
items.append(
|
||||
{
|
||||
"id": str(doc.id),
|
||||
"filename": doc.filename,
|
||||
"content_type": doc.content_type,
|
||||
"size_bytes": doc.size_bytes,
|
||||
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
||||
"owner_handle": owner.handle,
|
||||
}
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(doc.id),
|
||||
"filename": doc.filename,
|
||||
"content_type": doc.content_type,
|
||||
"size_bytes": doc.size_bytes,
|
||||
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
||||
"owner_handle": owner.handle,
|
||||
}
|
||||
for doc, owner in rows
|
||||
]
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
# ── PATCH /api/shares/{share_id} ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.patch("/{share_id}", status_code=200)
|
||||
async def update_share_permission(
|
||||
share_id: str,
|
||||
@@ -247,20 +209,7 @@ async def update_share_permission(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> dict:
|
||||
"""Update the permission on an existing share (SHARE-03, D-09).
|
||||
|
||||
T-06.2-02-01 IDOR protection: 404 on owner mismatch — mirrors revoke_share exactly.
|
||||
T-06.2-02-02: SharePermissionPatch validator prevents arbitrary string passthrough.
|
||||
"""
|
||||
try:
|
||||
sid = uuid.UUID(share_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
share = await session.get(Share, sid)
|
||||
if share is None or share.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
share = await _get_owned_share(session, share_id, current_user.id)
|
||||
share.permission = body.permission
|
||||
|
||||
await write_audit_log(
|
||||
@@ -277,9 +226,6 @@ async def update_share_permission(
|
||||
return {"id": str(share.id), "permission": share.permission}
|
||||
|
||||
|
||||
# ── DELETE /api/shares/{share_id} ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def revoke_share(
|
||||
share_id: str,
|
||||
@@ -287,27 +233,12 @@ async def revoke_share(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> None:
|
||||
"""Revoke a share. Only the share owner may revoke (SHARE-04, D-07).
|
||||
|
||||
T-04-04-02 IDOR protection: asserts share.owner_id == current_user.id.
|
||||
Returns 404 (not 403) on mismatch to prevent share ID enumeration.
|
||||
"""
|
||||
try:
|
||||
sid = uuid.UUID(share_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
share = await session.get(Share, sid)
|
||||
# CRITICAL IDOR check: 404 on mismatch (not 403) — prevents ID enumeration
|
||||
if share is None or share.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
share = await _get_owned_share(session, share_id, current_user.id)
|
||||
document_id = share.document_id
|
||||
recipient_id = share.recipient_id
|
||||
|
||||
await session.delete(share)
|
||||
|
||||
# Audit log before commit (D-14 — within the same transaction)
|
||||
await write_audit_log(
|
||||
session=session,
|
||||
event_type="share.revoked",
|
||||
|
||||
+41
-166
@@ -1,27 +1,7 @@
|
||||
"""
|
||||
AI provider configuration service for DocuVault.
|
||||
|
||||
Provides HKDF/Fernet encryption helpers, a provider config loader that reads
|
||||
from the system_settings DB table, and a startup seed function that populates
|
||||
the default provider row from env vars on first boot.
|
||||
|
||||
Security design (D-05, T-07-02):
|
||||
HKDF domain separation — the info bytes b"ai-provider-settings" differ from
|
||||
b"cloud-credentials" used by storage/cloud_utils.py. Both use the same master
|
||||
key (settings.cloud_creds_key) but produce DIFFERENT derived Fernet keys, so
|
||||
a leaked cloud credential cannot decrypt an AI API key and vice versa.
|
||||
|
||||
AlreadyFinalized warning (RESEARCH.md Pitfall 2 / .continue-here.md anti-pattern):
|
||||
The cryptography library raises AlreadyFinalized if .derive() is called twice
|
||||
on the same HKDF instance. _derive_ai_settings_key() creates a FRESH HKDF(...)
|
||||
object on every call — never cache or reuse the HKDF object between calls.
|
||||
|
||||
Pattern reference: storage/cloud_utils.py:_derive_fernet_key().
|
||||
"""
|
||||
"""AI provider configuration and API-key encryption helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
@@ -36,167 +16,78 @@ from config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
AI_SETTINGS_KEY_INFO = b"ai-provider-settings"
|
||||
|
||||
# ── HKDF key derivation ───────────────────────────────────────────────────────
|
||||
|
||||
def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet:
|
||||
"""Derive a per-provider Fernet encryption key using HKDF-SHA256.
|
||||
|
||||
Security notes:
|
||||
- A FRESH HKDF instance is created on every call. The cryptography library
|
||||
raises AlreadyFinalized if .derive() is called twice on the same instance.
|
||||
Never cache or reuse the HKDF object (RESEARCH.md Pitfall 2).
|
||||
- salt = provider_id.encode("utf-8") provides per-provider derivation
|
||||
(deterministic: same provider → same key for encrypt/decrypt consistency).
|
||||
- info = b"ai-provider-settings" provides domain separation from
|
||||
b"cloud-credentials" — same master key, different derived keys.
|
||||
A leaked cloud credential cannot decrypt an AI API key (T-07-02 mitigated).
|
||||
|
||||
Args:
|
||||
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
||||
provider_id: The provider slug, e.g. "openai", "anthropic" (used as HKDF salt).
|
||||
|
||||
Returns:
|
||||
A Fernet instance ready for encrypt/decrypt operations.
|
||||
"""
|
||||
# Create a FRESH HKDF instance — never cache (AlreadyFinalized guard)
|
||||
"""Derive a per-provider Fernet key with domain-separated HKDF-SHA256."""
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=provider_id.encode("utf-8"),
|
||||
info=b"ai-provider-settings", # domain-separated from b"cloud-credentials"
|
||||
info=AI_SETTINGS_KEY_INFO,
|
||||
)
|
||||
raw_key: bytes = hkdf.derive(master_key)
|
||||
fernet_key = base64.urlsafe_b64encode(raw_key)
|
||||
return Fernet(fernet_key)
|
||||
|
||||
|
||||
# ── Encryption helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str:
|
||||
"""Encrypt a plaintext API key string to a Fernet token.
|
||||
|
||||
The returned string is safe to store in system_settings.api_key_enc.
|
||||
No JSON wrapping — the raw API key string is encrypted directly.
|
||||
|
||||
Args:
|
||||
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
||||
provider_id: The provider slug (used as HKDF salt for key derivation).
|
||||
api_key: The plaintext API key, e.g. "sk-proj-...".
|
||||
|
||||
Returns:
|
||||
A URL-safe base64 Fernet token (str).
|
||||
"""
|
||||
"""Encrypt a plaintext API key for storage in system_settings.api_key_enc."""
|
||||
f = _derive_ai_settings_key(master_key, provider_id)
|
||||
return f.encrypt(api_key.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> str:
|
||||
"""Decrypt a Fernet token back to the original plaintext API key.
|
||||
|
||||
Args:
|
||||
master_key: The CLOUD_CREDS_KEY env var as bytes.
|
||||
provider_id: The provider slug (used as HKDF salt for key derivation).
|
||||
api_key_enc: The Fernet token string from the database.
|
||||
|
||||
Returns:
|
||||
The original plaintext API key string.
|
||||
"""
|
||||
"""Decrypt a stored API-key token."""
|
||||
f = _derive_ai_settings_key(master_key, provider_id)
|
||||
return f.decrypt(api_key_enc.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
# ── Provider config loader ────────────────────────────────────────────────────
|
||||
def _config_from_settings_row(row) -> ProviderConfig:
|
||||
api_key = ""
|
||||
if row.api_key_enc:
|
||||
master_key = settings.cloud_creds_key.encode("utf-8")
|
||||
try:
|
||||
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ai_config: failed to decrypt api_key_enc",
|
||||
provider_id=row.provider_id,
|
||||
)
|
||||
|
||||
return ProviderConfig(
|
||||
provider_id=row.provider_id,
|
||||
api_key=api_key,
|
||||
base_url=row.base_url,
|
||||
model=row.model_name,
|
||||
context_chars=row.context_chars,
|
||||
)
|
||||
|
||||
|
||||
async def _load_settings_row(session: AsyncSession, *criteria):
|
||||
from db.models import SystemSettings # local import avoids circular deps
|
||||
|
||||
stmt = select(SystemSettings)
|
||||
if criteria:
|
||||
stmt = stmt.where(*criteria)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig]:
|
||||
"""Load the active AI provider config from the system_settings table.
|
||||
|
||||
Returns a ProviderConfig built from the row where is_active=True,
|
||||
decrypting api_key_enc when present. Returns None when no active row exists.
|
||||
|
||||
Args:
|
||||
session: An open AsyncSession.
|
||||
|
||||
Returns:
|
||||
A ProviderConfig if an active row exists; None if the table is empty or
|
||||
no row is marked active.
|
||||
"""
|
||||
from db.models import SystemSettings # local import to avoid circular deps
|
||||
|
||||
stmt = select(SystemSettings).where(SystemSettings.is_active.is_(True))
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await _load_settings_row(session, SystemSettings.is_active.is_(True))
|
||||
return _config_from_settings_row(row) if row else None
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Decrypt API key if present
|
||||
api_key = ""
|
||||
if row.api_key_enc:
|
||||
master_key = settings.cloud_creds_key.encode("utf-8")
|
||||
try:
|
||||
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ai_config.load_provider_config: failed to decrypt api_key_enc",
|
||||
provider_id=row.provider_id,
|
||||
)
|
||||
|
||||
return ProviderConfig(
|
||||
provider_id=row.provider_id,
|
||||
api_key=api_key,
|
||||
base_url=row.base_url,
|
||||
model=row.model_name,
|
||||
context_chars=row.context_chars,
|
||||
)
|
||||
|
||||
|
||||
# ── Provider config loader by ID ─────────────────────────────────────────────
|
||||
|
||||
async def load_provider_config_by_id(session: AsyncSession, provider_id: str) -> Optional[ProviderConfig]:
|
||||
"""Load an AI provider config from system_settings by provider_id.
|
||||
|
||||
Unlike load_provider_config(), this function does NOT require is_active=True.
|
||||
Used by the admin test-connection endpoint so admins can test inactive rows.
|
||||
|
||||
Args:
|
||||
session: An open AsyncSession.
|
||||
provider_id: The provider slug to load (e.g. "openai", "lmstudio").
|
||||
|
||||
Returns:
|
||||
A ProviderConfig if a row with the given provider_id exists; None otherwise.
|
||||
"""
|
||||
from db.models import SystemSettings # local import to avoid circular deps
|
||||
|
||||
stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
row = await _load_settings_row(session, SystemSettings.provider_id == provider_id)
|
||||
return _config_from_settings_row(row) if row else None
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Decrypt API key if present
|
||||
api_key = ""
|
||||
if row.api_key_enc:
|
||||
master_key = settings.cloud_creds_key.encode("utf-8")
|
||||
try:
|
||||
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ai_config.load_provider_config_by_id: failed to decrypt api_key_enc",
|
||||
provider_id=row.provider_id,
|
||||
)
|
||||
|
||||
return ProviderConfig(
|
||||
provider_id=row.provider_id,
|
||||
api_key=api_key,
|
||||
base_url=row.base_url,
|
||||
model=row.model_name,
|
||||
context_chars=row.context_chars,
|
||||
)
|
||||
|
||||
|
||||
# ── Provider ID validator (D-11 migration) ───────────────────────────────────
|
||||
|
||||
def validate_provider_id(v: str) -> str:
|
||||
"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""
|
||||
@@ -207,21 +98,8 @@ def validate_provider_id(v: str) -> str:
|
||||
return v
|
||||
|
||||
|
||||
# ── Startup seed ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def seed_system_settings_from_env(session: AsyncSession) -> None:
|
||||
"""Populate system_settings with a default provider row on first boot.
|
||||
|
||||
Reads settings.default_ai_provider and settings.default_ai_model from config.
|
||||
If no row exists for that provider_id, inserts one with is_active=True.
|
||||
Never overwrites an existing row — idempotent across restarts (D-04).
|
||||
|
||||
This function is called from the FastAPI lifespan in main.py after the
|
||||
session factory is available. Caller is responsible for committing.
|
||||
|
||||
Args:
|
||||
session: An open AsyncSession.
|
||||
"""
|
||||
"""Seed the default provider row from env settings if it does not exist."""
|
||||
from db.models import SystemSettings # local import to avoid circular deps
|
||||
|
||||
provider_id = settings.default_ai_provider
|
||||
@@ -232,13 +110,10 @@ async def seed_system_settings_from_env(session: AsyncSession) -> None:
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
# Row already exists — never overwrite (idempotent)
|
||||
return
|
||||
|
||||
# Use PROVIDER_DEFAULTS context_chars for the provider, fallback to 8000
|
||||
context_chars = PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000)
|
||||
|
||||
# Insert default row with no API key (local providers like Ollama don't need one)
|
||||
row = SystemSettings(
|
||||
provider_id=provider_id,
|
||||
model_name=model_name,
|
||||
|
||||
+49
-73
@@ -1,20 +1,4 @@
|
||||
"""
|
||||
Auth service — pure Python, no FastAPI coupling.
|
||||
|
||||
Handles:
|
||||
- Password hashing (Argon2 via pwdlib) and constant-time verification (SEC-06)
|
||||
- JWT access token creation/decode (PyJWT)
|
||||
- Refresh token lifecycle with family revocation on reuse (AUTH-07, RFC 9700)
|
||||
- TOTP provisioning and verification with replay prevention (AUTH-08)
|
||||
- Backup code generation, storage, and constant-time verification (AUTH-02)
|
||||
- HaveIBeenPwned k-anonymity check (SEC-03)
|
||||
- Admin account bootstrap (D-04, D-05, D-06)
|
||||
|
||||
Security invariants:
|
||||
- All token/code comparisons use hmac.compare_digest (constant-time, SEC-06)
|
||||
- No function raises HTTPException — callers (api/) map ValueError to HTTP errors
|
||||
- refresh token family revocation enqueues send_security_alert_email.delay (AUTH-07)
|
||||
"""
|
||||
"""Authentication, token, TOTP, and account bootstrap services."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -32,7 +16,7 @@ import jwt
|
||||
import pyotp
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
@@ -45,8 +29,6 @@ _PASSWORD_DETAIL = (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Password hashing ────────────────────────────────────────────────────────────
|
||||
# Single shared PasswordHash instance; Argon2 is the only enabled hasher.
|
||||
_pwd = PasswordHash([Argon2Hasher()])
|
||||
|
||||
|
||||
@@ -82,10 +64,8 @@ def validate_password_strength(password: str) -> None:
|
||||
raise ValueError(_PASSWORD_DETAIL)
|
||||
|
||||
|
||||
# ── JWT helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
|
||||
"""Return 16-char hex fingerprint binding a token to its client context (D-04)."""
|
||||
"""Return a short fingerprint binding a token to its client context."""
|
||||
return hmac.new(
|
||||
settings.secret_key.encode(),
|
||||
(user_agent + "\x00" + accept_lang).encode(),
|
||||
@@ -93,6 +73,36 @@ def _compute_fgp(user_agent: str, accept_lang: str) -> str:
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _jwt_private_key() -> str:
|
||||
return base64.b64decode(settings.jwt_private_key).decode()
|
||||
|
||||
|
||||
def _jwt_public_key() -> str:
|
||||
return base64.b64decode(settings.jwt_public_key).decode()
|
||||
|
||||
|
||||
def _encode_jwt(payload: dict) -> str:
|
||||
return jwt.encode(payload, _jwt_private_key(), algorithm="ES256")
|
||||
|
||||
|
||||
def _decode_jwt(
|
||||
token: str,
|
||||
expected_type: str,
|
||||
expired_message: str,
|
||||
invalid_prefix: str,
|
||||
) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, _jwt_public_key(), algorithms=["ES256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ValueError(expired_message) from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError(f"{invalid_prefix}: {exc}") from exc
|
||||
|
||||
if payload.get("typ") != expected_type:
|
||||
raise ValueError(f"Token type mismatch: expected '{expected_type}'")
|
||||
return payload
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
role: str,
|
||||
@@ -114,8 +124,7 @@ def create_access_token(
|
||||
"jti": str(uuid.uuid4()),
|
||||
"fgp": _compute_fgp(user_agent, accept_lang),
|
||||
}
|
||||
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||
return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||
return _encode_jwt(payload)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
@@ -124,17 +133,7 @@ def decode_access_token(token: str) -> dict:
|
||||
Verifies: signature, expiry, and typ='access' (T-02-01 — prevents password-reset
|
||||
tokens from being used as access tokens).
|
||||
"""
|
||||
try:
|
||||
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ValueError("Token has expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError(f"Invalid token: {exc}") from exc
|
||||
|
||||
if payload.get("typ") != "access":
|
||||
raise ValueError("Token type mismatch: expected 'access'")
|
||||
return payload
|
||||
return _decode_jwt(token, "access", "Token has expired", "Invalid token")
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: str) -> str:
|
||||
@@ -149,8 +148,7 @@ def create_password_reset_token(user_id: str) -> str:
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=3600),
|
||||
}
|
||||
private_pem = base64.b64decode(settings.jwt_private_key).decode()
|
||||
return jwt.encode(payload, private_pem, algorithm="ES256")
|
||||
return _encode_jwt(payload)
|
||||
|
||||
|
||||
def decode_password_reset_token(token: str) -> str:
|
||||
@@ -158,21 +156,15 @@ def decode_password_reset_token(token: str) -> str:
|
||||
|
||||
Returns the user_id string.
|
||||
"""
|
||||
try:
|
||||
public_pem = base64.b64decode(settings.jwt_public_key).decode()
|
||||
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ValueError("Reset token has expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError(f"Invalid reset token: {exc}") from exc
|
||||
|
||||
if payload.get("typ") != "password-reset":
|
||||
raise ValueError("Token type mismatch: expected 'password-reset'")
|
||||
payload = _decode_jwt(
|
||||
token,
|
||||
"password-reset",
|
||||
"Reset token has expired",
|
||||
"Invalid reset token",
|
||||
)
|
||||
return payload["sub"]
|
||||
|
||||
|
||||
# ── Refresh token lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
async def create_refresh_token(
|
||||
session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False
|
||||
) -> str:
|
||||
@@ -181,8 +173,8 @@ async def create_refresh_token(
|
||||
The raw token is returned to the caller and set as an httpOnly cookie.
|
||||
Only the SHA-256 hash is stored in the database.
|
||||
|
||||
remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10)
|
||||
remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11)
|
||||
remember_me=False uses the short-session TTL; remember_me=True uses the
|
||||
extended-session TTL.
|
||||
"""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||
@@ -231,9 +223,7 @@ async def rotate_refresh_token(
|
||||
raise ValueError("Refresh token has expired")
|
||||
|
||||
if row.revoked:
|
||||
# T-02-02: reuse of revoked token — family revocation
|
||||
await revoke_all_refresh_tokens(session, row.user_id)
|
||||
# Enqueue security alert email (deferred import to avoid circular dependency)
|
||||
from tasks.email_tasks import send_security_alert_email # noqa: PLC0415
|
||||
send_security_alert_email.delay(str(row.user_id))
|
||||
raise ValueError("token_family_revoked")
|
||||
@@ -273,8 +263,6 @@ async def revoke_all_refresh_tokens(
|
||||
return count
|
||||
|
||||
|
||||
# ── TOTP provisioning ───────────────────────────────────────────────────────────
|
||||
|
||||
async def provision_totp(
|
||||
session: AsyncSession, user_id: uuid.UUID
|
||||
) -> tuple[str, str]:
|
||||
@@ -329,13 +317,10 @@ async def verify_totp(
|
||||
return True
|
||||
|
||||
|
||||
# ── Backup codes ────────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_backup_codes(n: int = 10) -> list[str]:
|
||||
"""Return *n* random 8-character uppercase alphanumeric backup codes."""
|
||||
codes = []
|
||||
for _ in range(n):
|
||||
# secrets.token_hex(4) returns 8 hex chars; uppercase for readability
|
||||
code = secrets.token_hex(4).upper()
|
||||
codes.append(code)
|
||||
return codes
|
||||
@@ -349,7 +334,6 @@ async def store_backup_codes(
|
||||
Each code is stored as an Argon2 hash (never plaintext, T-02-03).
|
||||
Existing unused codes are deleted first to prevent accumulation.
|
||||
"""
|
||||
# Delete existing unused codes
|
||||
result = await session.execute(
|
||||
select(BackupCode).where(
|
||||
BackupCode.user_id == user_id,
|
||||
@@ -360,7 +344,6 @@ async def store_backup_codes(
|
||||
await session.delete(row)
|
||||
await session.flush()
|
||||
|
||||
# Insert new hashed codes
|
||||
for code in codes:
|
||||
row = BackupCode(
|
||||
id=uuid.uuid4(),
|
||||
@@ -392,21 +375,17 @@ async def verify_backup_code(
|
||||
|
||||
matched_row: Optional[BackupCode] = None
|
||||
for row in rows:
|
||||
# Always call verify_password for ALL rows (constant-time: no early exit)
|
||||
if verify_password(code, row.code_hash):
|
||||
matched_row = row # record match but keep iterating
|
||||
matched_row = row # keep iterating for constant-time behavior
|
||||
|
||||
if matched_row is None:
|
||||
return False
|
||||
|
||||
# Mark the matched code as used
|
||||
matched_row.used_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── HaveIBeenPwned check ────────────────────────────────────────────────────────
|
||||
|
||||
async def check_hibp(password: str) -> bool:
|
||||
"""Check if password appears in HaveIBeenPwned using the k-anonymity model.
|
||||
|
||||
@@ -442,19 +421,17 @@ async def check_hibp(password: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ── Admin bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def bootstrap_admin(session: AsyncSession) -> None:
|
||||
"""Idempotent admin account bootstrap (D-04, D-05, D-06).
|
||||
"""Idempotent admin account bootstrap.
|
||||
|
||||
If the users table is empty AND settings.admin_email and settings.admin_password
|
||||
are both non-empty, creates an admin User row with a Quota row.
|
||||
|
||||
Logs a WARNING if env vars are missing (D-05) but never raises.
|
||||
Logs a warning if env vars are missing but never raises.
|
||||
"""
|
||||
if not settings.admin_email or not settings.admin_password:
|
||||
logger.warning(
|
||||
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set (D-05). "
|
||||
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set. "
|
||||
"Set both env vars to seed the first admin account on startup."
|
||||
)
|
||||
return
|
||||
@@ -462,7 +439,6 @@ async def bootstrap_admin(session: AsyncSession) -> None:
|
||||
# Check if any users exist
|
||||
result = await session.execute(select(User).limit(1))
|
||||
if result.scalar_one_or_none() is not None:
|
||||
# Users already exist — idempotent, skip (D-04)
|
||||
return
|
||||
|
||||
admin_id = uuid.uuid4()
|
||||
@@ -477,7 +453,7 @@ async def bootstrap_admin(session: AsyncSession) -> None:
|
||||
)
|
||||
quota = Quota(
|
||||
user_id=admin_id,
|
||||
limit_bytes=104857600, # 100 MB default (D-06)
|
||||
limit_bytes=104857600,
|
||||
used_bytes=0,
|
||||
)
|
||||
session.add(admin_user)
|
||||
|
||||
+28
-100
@@ -1,21 +1,4 @@
|
||||
"""
|
||||
Async document/topic/settings storage service for DocuVault.
|
||||
|
||||
This module replaces the legacy flat-file + filelock implementation with:
|
||||
- Async SQLAlchemy ORM for document and topic persistence (PostgreSQL)
|
||||
- MinIO SDK (via asyncio.to_thread) for binary object storage
|
||||
|
||||
Public function names are PRESERVED from the old flat-file implementation so
|
||||
that api/documents.py and api/topics.py can be updated in Plan 05 with minimal
|
||||
changes (async def + await + session parameter).
|
||||
|
||||
Phase 3 D-12: load_settings / save_settings / mask_api_key / settings_masked removed.
|
||||
All AI config comes from DB (users.ai_provider / users.ai_model set by admin).
|
||||
|
||||
D-05: Storage service layer switched to PostgreSQL + MinIO.
|
||||
D-06: Object key schema: {user_id}/{document_id}/{uuid4()}{ext} — human filename in DB only.
|
||||
D-03: documents.user_id is None (nullable) in Phase 1 — no auth system yet.
|
||||
"""
|
||||
"""Async document and topic storage helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
@@ -31,26 +14,17 @@ from db.models import Document, DocumentTopic, Topic
|
||||
from storage import get_storage_backend
|
||||
|
||||
|
||||
# ── Lazy singleton storage backend ────────────────────────────────────────────
|
||||
|
||||
_storage = None
|
||||
|
||||
|
||||
def _backend():
|
||||
"""Return the lazily-instantiated StorageBackend singleton.
|
||||
|
||||
Mirrors the module-level singleton behaviour of the old filelock objects so
|
||||
the MinIO client is created once per process, not once per request.
|
||||
"""
|
||||
"""Return the lazily-instantiated StorageBackend singleton."""
|
||||
global _storage
|
||||
_storage = _storage or get_storage_backend()
|
||||
return _storage
|
||||
|
||||
|
||||
# ── Private helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_to_dict(doc: Document, topic_names: list) -> dict:
|
||||
"""Convert a Document ORM row + resolved topic names to the legacy dict shape."""
|
||||
return {
|
||||
"id": str(doc.id),
|
||||
"original_name": doc.filename,
|
||||
@@ -65,8 +39,22 @@ def _doc_to_dict(doc: Document, topic_names: list) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _topic_to_dict(topic: Topic) -> dict:
|
||||
return {
|
||||
"id": str(topic.id),
|
||||
"name": topic.name,
|
||||
"description": topic.description,
|
||||
"color": topic.color,
|
||||
}
|
||||
|
||||
|
||||
def _topic_namespace_filter(name: str, user_id: Optional[uuid.UUID]):
|
||||
criteria = [sql_func.lower(Topic.name) == name.lower()]
|
||||
criteria.append(Topic.user_id.is_(None) if user_id is None else Topic.user_id == user_id)
|
||||
return criteria
|
||||
|
||||
|
||||
async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
|
||||
"""Return the list of topic names for a given document UUID."""
|
||||
q = await session.execute(
|
||||
select(Topic.name)
|
||||
.join(DocumentTopic, DocumentTopic.topic_id == Topic.id)
|
||||
@@ -75,9 +63,6 @@ async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
|
||||
return [row[0] for row in q]
|
||||
|
||||
|
||||
# ── Documents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def save_metadata(session: AsyncSession, meta: dict) -> None:
|
||||
"""Update a Document row from the legacy metadata dict shape.
|
||||
|
||||
@@ -120,10 +105,7 @@ async def get_metadata(session: AsyncSession, doc_id: str) -> Optional[dict]:
|
||||
async def list_metadata(
|
||||
session: AsyncSession, user_id: uuid.UUID, topic: Optional[str] = None
|
||||
) -> list:
|
||||
"""Return a list of metadata dicts for a specific user, optionally filtered by topic name.
|
||||
|
||||
D-16: always filters by user_id — a user can only see their own documents.
|
||||
"""
|
||||
"""Return metadata dicts for a user's documents, optionally filtered by topic."""
|
||||
stmt = select(Document).where(Document.user_id == user_id).order_by(Document.created_at.desc())
|
||||
if topic is not None:
|
||||
stmt = (
|
||||
@@ -176,10 +158,6 @@ async def delete_document(
|
||||
print(f"[storage] WARNING: MinIO delete_object failed for {doc.object_key!r}: {exc}", file=sys.stderr)
|
||||
|
||||
if not skip_quota:
|
||||
# Atomic quota decrement (STORE-06, D-07).
|
||||
# user_id is always set post-migration (Plan 03-03+) — guard removed.
|
||||
# Use CASE WHEN instead of GREATEST() for SQLite compatibility
|
||||
# (PostgreSQL supports both; SQLite lacks the GREATEST scalar function).
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE quotas "
|
||||
@@ -212,12 +190,10 @@ async def update_document_topics(
|
||||
if doc is None:
|
||||
return None
|
||||
|
||||
# Remove all existing associations
|
||||
await session.execute(
|
||||
delete(DocumentTopic).where(DocumentTopic.document_id == uid)
|
||||
)
|
||||
|
||||
# Re-insert, deduplicating by name
|
||||
seen: set = set()
|
||||
for name in topics:
|
||||
if name in seen:
|
||||
@@ -257,15 +233,10 @@ async def remove_topic_from_all_documents(
|
||||
return result.rowcount
|
||||
|
||||
|
||||
# ── Topics ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def load_topics(session: AsyncSession) -> list:
|
||||
"""Return all topics ordered by name."""
|
||||
q = await session.execute(select(Topic).order_by(Topic.name))
|
||||
return [
|
||||
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
for t in q.scalars()
|
||||
]
|
||||
return [_topic_to_dict(t) for t in q.scalars()]
|
||||
|
||||
|
||||
async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> list:
|
||||
@@ -280,17 +251,11 @@ async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> lis
|
||||
or_(Topic.user_id == user_id, Topic.user_id.is_(None))
|
||||
).order_by(Topic.name)
|
||||
)
|
||||
return [
|
||||
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
for t in q.scalars()
|
||||
]
|
||||
return [_topic_to_dict(t) for t in q.scalars()]
|
||||
|
||||
|
||||
async def save_topics(session: AsyncSession, topics: list) -> None:
|
||||
"""Idempotent bulk replace — delete all Topic rows then insert the list.
|
||||
|
||||
# legacy: not used by current endpoints; preserved for API compatibility.
|
||||
"""
|
||||
"""Idempotent bulk replace; kept for compatibility with older callers."""
|
||||
await session.execute(delete(Topic))
|
||||
for t in topics:
|
||||
session.add(
|
||||
@@ -313,7 +278,7 @@ async def get_topic(session: AsyncSession, topic_id: str) -> Optional[dict]:
|
||||
t = await session.get(Topic, uid)
|
||||
if t is None:
|
||||
return None
|
||||
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
return _topic_to_dict(t)
|
||||
|
||||
|
||||
async def create_topic(
|
||||
@@ -323,51 +288,16 @@ async def create_topic(
|
||||
color: str = "#6366f1",
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
) -> dict:
|
||||
"""Create a topic, or return the existing one (case-insensitive, namespace-scoped dedup).
|
||||
|
||||
D-08: user_id=None creates a system topic (visible to all users).
|
||||
D-08: user_id=<uuid> creates a per-user topic (visible only to that user).
|
||||
|
||||
Deduplication is scoped by user_id namespace:
|
||||
- System topics (user_id=None) dedup against other system topics only
|
||||
- Per-user topics dedup within that user's namespace only
|
||||
This allows "Finance" to exist as both a system topic and a per-user topic.
|
||||
|
||||
SQLite note: Uses a branching approach instead of IS NOT DISTINCT FROM
|
||||
(SQLite doesn't support that PostgreSQL construct for NULL comparison).
|
||||
"""
|
||||
if user_id is None:
|
||||
q = await session.execute(
|
||||
select(Topic).where(
|
||||
sql_func.lower(Topic.name) == name.lower(),
|
||||
Topic.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
q = await session.execute(
|
||||
select(Topic).where(
|
||||
sql_func.lower(Topic.name) == name.lower(),
|
||||
Topic.user_id == user_id,
|
||||
)
|
||||
)
|
||||
"""Create a topic, or return an existing case-insensitive namespace match."""
|
||||
q = await session.execute(select(Topic).where(*_topic_namespace_filter(name, user_id)))
|
||||
existing = q.scalars().first()
|
||||
if existing is not None:
|
||||
return {
|
||||
"id": str(existing.id),
|
||||
"name": existing.name,
|
||||
"description": existing.description,
|
||||
"color": existing.color,
|
||||
}
|
||||
return _topic_to_dict(existing)
|
||||
|
||||
topic = Topic(name=name, description=description, color=color, user_id=user_id)
|
||||
session.add(topic)
|
||||
await session.commit()
|
||||
return {
|
||||
"id": str(topic.id),
|
||||
"name": topic.name,
|
||||
"description": topic.description,
|
||||
"color": topic.color,
|
||||
}
|
||||
return _topic_to_dict(topic)
|
||||
|
||||
|
||||
async def update_topic(
|
||||
@@ -392,7 +322,7 @@ async def update_topic(
|
||||
if color is not None:
|
||||
t.color = color
|
||||
await session.commit()
|
||||
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
|
||||
return _topic_to_dict(t)
|
||||
|
||||
|
||||
async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
|
||||
@@ -408,7 +338,7 @@ async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
|
||||
if t is None:
|
||||
return None
|
||||
name = t.name
|
||||
await session.delete(t) # ondelete="CASCADE" removes DocumentTopic rows
|
||||
await session.delete(t)
|
||||
await session.commit()
|
||||
return name
|
||||
|
||||
@@ -437,8 +367,6 @@ async def topic_doc_counts(
|
||||
return {name: count for name, count in q}
|
||||
|
||||
|
||||
# ── Public surface ─────────────────────────────────────────────────────────────
|
||||
|
||||
__all__ = [
|
||||
"save_metadata",
|
||||
"get_metadata",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Factory for user-scoped cloud storage backends."""
|
||||
|
||||
|
||||
def build_cloud_backend(provider: str, credentials: dict):
|
||||
if provider == "google_drive":
|
||||
from storage.google_drive_backend import GoogleDriveBackend # lazy import
|
||||
|
||||
return GoogleDriveBackend(credentials)
|
||||
|
||||
if provider == "onedrive":
|
||||
from storage.onedrive_backend import OneDriveBackend # lazy import
|
||||
|
||||
return OneDriveBackend(credentials)
|
||||
|
||||
if provider == "nextcloud":
|
||||
from storage.nextcloud_backend import NextcloudBackend # lazy import
|
||||
|
||||
return NextcloudBackend(
|
||||
credentials["server_url"],
|
||||
credentials["username"],
|
||||
credentials["password"],
|
||||
)
|
||||
|
||||
if provider == "webdav":
|
||||
from storage.webdav_backend import WebDAVBackend # lazy import
|
||||
|
||||
return WebDAVBackend(
|
||||
credentials["server_url"],
|
||||
credentials["username"],
|
||||
credentials["password"],
|
||||
)
|
||||
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
@@ -6,12 +6,8 @@ class CloudConnectionError(Exception):
|
||||
"""Raised when a cloud provider signals a non-retryable connection problem.
|
||||
|
||||
Attributes:
|
||||
reason: "token_expired" — access token expired; API layer can refresh and retry.
|
||||
reason: "token_expired" — access token expired.
|
||||
"invalid_grant" — refresh token revoked; user must reconnect.
|
||||
|
||||
The backend never updates the DB. The API layer (_call_cloud_op in cloud.py)
|
||||
catches this exception, performs the DB state transition, and decides whether
|
||||
to retry or surface a 503 to the client (B2 design, D-05/D-06).
|
||||
"""
|
||||
|
||||
def __init__(self, msg: str = "", *, reason: str = "") -> None:
|
||||
|
||||
@@ -11,10 +11,8 @@ Design notes:
|
||||
- D-14: presigned_get_url and generate_presigned_put_url raise
|
||||
NotImplementedError. The API upload endpoint detects cloud backends and
|
||||
uses the direct put_object() path instead.
|
||||
- B2 design: This backend is STATELESS. It raises CloudConnectionError but
|
||||
does NOT update the DB or CloudConnection objects. DB state transitions
|
||||
(e.g., REQUIRES_REAUTH) are handled by the _call_cloud_op() helper in
|
||||
cloud.py (Plan 05-05), which has the DB session.
|
||||
- This backend is stateless. It raises CloudConnectionError but does not
|
||||
update the DB or CloudConnection objects.
|
||||
- Token key format stored in credentials dict:
|
||||
access_token — current OAuth bearer token
|
||||
refresh_token — long-lived refresh token
|
||||
|
||||
@@ -10,9 +10,8 @@ Design notes:
|
||||
already async and awaited directly.
|
||||
- CloudConnectionError is imported from google_drive_backend (shared type).
|
||||
This keeps the exception hierarchy unified across all cloud backends.
|
||||
- B2 design: This backend is STATELESS. It raises CloudConnectionError but
|
||||
does NOT update the DB or CloudConnection objects. DB state transitions
|
||||
(e.g., REQUIRES_REAUTH) are handled by _call_cloud_op() in cloud.py.
|
||||
- This backend is stateless. It raises CloudConnectionError but does not
|
||||
update the DB or CloudConnection objects.
|
||||
- _ensure_valid_token() checks expiry before each API call and calls
|
||||
_refresh_token() if the token is within 60 seconds of expiry. If the
|
||||
refresh returns None (invalid_grant), CloudConnectionError is raised.
|
||||
|
||||
@@ -496,15 +496,8 @@ async def test_invalid_grant_sets_requires_reauth(
|
||||
assert "re-authentication" in resp.json().get("detail", "").lower() or \
|
||||
"reconnect" in resp.json().get("detail", "").lower()
|
||||
|
||||
# The test checks the 503 response — the DB REQUIRES_REAUTH state transition is
|
||||
# handled by _call_cloud_op in cloud.py (which is invoked by the real backend flow).
|
||||
# In this test we monkeypatched get_storage_backend_for_document directly, so
|
||||
# documents.py's except CloudConnectionError block fires, returning 503.
|
||||
# The REQUIRES_REAUTH DB state is written by _call_cloud_op, not by documents.py.
|
||||
# For this test we verify: (1) 503 returned, and (2) the conn was not status="ACTIVE"
|
||||
# after the call — since the monkeypatch bypasses _call_cloud_op, we re-check conn status.
|
||||
# The 503 path in documents.py does NOT update conn.status — that is _call_cloud_op's job.
|
||||
# We verify the HTTP contract here; the DB transition is covered by the cloud.py unit tests.
|
||||
# This test verifies the document content endpoint's HTTP contract when the
|
||||
# storage layer reports an invalid cloud grant.
|
||||
|
||||
|
||||
# ── CLOUD-06: Disconnect / credential deletion ────────────────────────────────
|
||||
|
||||
+56
-1
@@ -37,6 +37,55 @@ services:
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
|
||||
MINIO_BUCKET: ${MINIO_BUCKET}
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -eu
|
||||
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD"
|
||||
mc mb --ignore-existing "local/$$MINIO_BUCKET"
|
||||
mc admin user add local "$$MINIO_ACCESS_KEY" "$$MINIO_SECRET_KEY" || true
|
||||
cat > /tmp/docuvault-policy.json <<EOF
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:GetBucketLocation"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::$$MINIO_BUCKET"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::$$MINIO_BUCKET/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
mc admin policy create local docuvault-app /tmp/docuvault-policy.json || true
|
||||
mc admin policy attach local docuvault-app --user "$$MINIO_ACCESS_KEY"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
@@ -66,7 +115,7 @@ services:
|
||||
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:5173"]}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173}
|
||||
- PYTHONDONTWRITEBYTECODE=1
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
@@ -82,6 +131,8 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_healthy
|
||||
read_only: true
|
||||
@@ -119,6 +170,8 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_healthy
|
||||
read_only: true
|
||||
@@ -148,6 +201,8 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_healthy
|
||||
read_only: true
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+14
-9
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<AuthLayout v-if="route.meta.layout === 'auth'" />
|
||||
<router-view v-else-if="route.matched.some(r => r.meta.requiresAdmin)" />
|
||||
<div v-else class="flex h-screen overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<router-view ref="routeViewRef" />
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
@@ -11,8 +12,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import AuthLayout from './layouts/AuthLayout.vue'
|
||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||
@@ -20,11 +21,15 @@ import OsDragOverlay from './components/layout/OsDragOverlay.vue'
|
||||
import { useTopicsStore } from './stores/topics.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const topicsStore = useTopicsStore()
|
||||
const routeViewRef = ref(null)
|
||||
|
||||
function getFileManagerInstance() {
|
||||
return router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default ?? null
|
||||
}
|
||||
|
||||
function onOsFilesDropped(files) {
|
||||
routeViewRef.value?.handleOsDrop?.(files)
|
||||
getFileManagerInstance()?.handleOsDrop?.(files)
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
@@ -34,16 +39,16 @@ function onKeydown(e) {
|
||||
|
||||
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault()
|
||||
routeViewRef.value?.focusSearch?.()
|
||||
getFileManagerInstance()?.focusSearch?.()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
routeViewRef.value?.clearSearch?.()
|
||||
getFileManagerInstance()?.clearSearch?.()
|
||||
}
|
||||
if (e.key === 'u' || e.key === 'U') {
|
||||
routeViewRef.value?.triggerUpload?.()
|
||||
getFileManagerInstance()?.triggerUpload?.()
|
||||
}
|
||||
if (e.key === 'n' || e.key === 'N') {
|
||||
routeViewRef.value?.startNewFolder?.()
|
||||
getFileManagerInstance()?.startNewFolder?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,3 +136,24 @@ describe('UX-08: "N" starts new folder input', () => {
|
||||
expect(() => w.vm.startNewFolder()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gap 4: getFileManagerInstance resolves to actual component, not RouterView proxy', () => {
|
||||
it('matched.find(r => r.instances?.default) returns an object with focusSearch defined when mounted via router-view', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
// Mount via a router-view wrapper so Vue Router populates r.instances.default
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const { RouterView } = await import('vue-router')
|
||||
const App = defineComponent({ render: () => h(RouterView) })
|
||||
mount(App, {
|
||||
global: { plugins: [router], stubs: { QuotaBar: true, AppSpinner: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
const instance = router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default
|
||||
expect(instance).not.toBeNull()
|
||||
expect(instance).toBeDefined()
|
||||
expect(typeof instance.focusSearch).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
+45
-75
@@ -1,31 +1,42 @@
|
||||
import { request, fetchWithRetry } from './utils.js'
|
||||
import { request, fetchWithRetry, jsonRequest } from './utils.js'
|
||||
|
||||
function withPresentParams(initial = {}, params = {}) {
|
||||
const searchParams = new URLSearchParams(initial)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) searchParams.set(key, value)
|
||||
})
|
||||
return searchParams
|
||||
}
|
||||
|
||||
async function downloadCsv(url, filename, errorPrefix) {
|
||||
const res = await fetchWithRetry(url)
|
||||
if (!res.ok) throw new Error(`${errorPrefix}: ${res.status}`)
|
||||
|
||||
const blob = new Blob([await res.text()], { type: 'text/csv' })
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = objectUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
|
||||
}
|
||||
|
||||
export function adminListUsers() {
|
||||
return request('/api/admin/users')
|
||||
}
|
||||
|
||||
export function adminCreateUser(body) {
|
||||
return request('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonRequest('/api/admin/users', 'POST', body)
|
||||
}
|
||||
|
||||
export function adminDeactivateUser(id) {
|
||||
return request(`/api/admin/users/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: false }),
|
||||
})
|
||||
return jsonRequest(`/api/admin/users/${id}/status`, 'PATCH', { is_active: false })
|
||||
}
|
||||
|
||||
export function adminReactivateUser(id) {
|
||||
return request(`/api/admin/users/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: true }),
|
||||
})
|
||||
return jsonRequest(`/api/admin/users/${id}/status`, 'PATCH', { is_active: true })
|
||||
}
|
||||
|
||||
export function adminResetUserPassword(id) {
|
||||
@@ -37,27 +48,18 @@ export function adminGetUserQuota(id) {
|
||||
}
|
||||
|
||||
export function adminUpdateQuota(id, limitBytes) {
|
||||
return request(`/api/admin/users/${id}/quota`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ limit_bytes: limitBytes }),
|
||||
})
|
||||
return jsonRequest(`/api/admin/users/${id}/quota`, 'PATCH', { limit_bytes: limitBytes })
|
||||
}
|
||||
|
||||
export function adminUpdateAiConfig(id, provider, model) {
|
||||
return request(`/api/admin/users/${id}/ai-config`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ai_provider: provider, ai_model: model }),
|
||||
return jsonRequest(`/api/admin/users/${id}/ai-config`, 'PATCH', {
|
||||
ai_provider: provider,
|
||||
ai_model: model,
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteUser(id, adminPassword) {
|
||||
return request(`/api/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ admin_password: adminPassword }),
|
||||
})
|
||||
return jsonRequest(`/api/admin/users/${id}`, 'DELETE', { admin_password: adminPassword })
|
||||
}
|
||||
|
||||
export function getAiConfig() {
|
||||
@@ -65,11 +67,7 @@ export function getAiConfig() {
|
||||
}
|
||||
|
||||
export function saveAiConfig(body) {
|
||||
return request('/api/admin/ai-config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonRequest('/api/admin/ai-config', 'PUT', body)
|
||||
}
|
||||
|
||||
export function testAiConnection(providerId) {
|
||||
@@ -87,38 +85,23 @@ export function getAiModels(providerId) {
|
||||
}
|
||||
|
||||
export function adminListAuditLog({ start, end, user_handle, event_type, page = 1, per_page = 50 } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (start) params.set('start', start)
|
||||
if (end) params.set('end', end)
|
||||
if (user_handle) params.set('user_handle', user_handle)
|
||||
if (event_type) params.set('event_type', event_type)
|
||||
const params = withPresentParams({}, { start, end, user_handle, event_type })
|
||||
params.set('page', page)
|
||||
params.set('per_page', per_page)
|
||||
return request(`/api/admin/audit-log?${params}`)
|
||||
}
|
||||
|
||||
// Unlike window.location.href, fetchWithRetry sends the Authorization Bearer header so
|
||||
// the endpoint can authenticate. Must NOT call res.json() — CSV is text/csv.
|
||||
export async function adminExportAuditLogCsv(params = {}) {
|
||||
const searchParams = new URLSearchParams({ format: 'csv' })
|
||||
if (params.start) searchParams.set('start', params.start)
|
||||
if (params.end) searchParams.set('end', params.end)
|
||||
if (params.user_handle) searchParams.set('user_handle', params.user_handle)
|
||||
if (params.event_type) searchParams.set('event_type', params.event_type)
|
||||
|
||||
const res = await fetchWithRetry(`/api/admin/audit-log/export?${searchParams}`)
|
||||
if (!res.ok) throw new Error(`Export failed: ${res.status}`)
|
||||
|
||||
const text = await res.text()
|
||||
const blob = new Blob([text], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'audit-export.csv'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
const searchParams = withPresentParams(
|
||||
{ format: 'csv' },
|
||||
{
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
user_handle: params.user_handle,
|
||||
event_type: params.event_type,
|
||||
}
|
||||
)
|
||||
return downloadCsv(`/api/admin/audit-log/export?${searchParams}`, 'audit-export.csv', 'Export failed')
|
||||
}
|
||||
|
||||
export function adminListDailyExports() {
|
||||
@@ -129,19 +112,6 @@ export async function getAdminOverview() {
|
||||
return request('/api/admin/overview')
|
||||
}
|
||||
|
||||
// Uses fetchWithRetry() to send the Authorization Bearer header (D-17, T-06.2-04-03).
|
||||
export async function adminDownloadDailyExport(date) {
|
||||
const res = await fetchWithRetry(`/api/admin/audit-log/daily-exports/${date}`)
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
|
||||
|
||||
const text = await res.text()
|
||||
const blob = new Blob([text], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${date}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
return downloadCsv(`/api/admin/audit-log/daily-exports/${date}`, `audit-${date}.csv`, 'Download failed')
|
||||
}
|
||||
|
||||
+10
-35
@@ -5,22 +5,14 @@
|
||||
* TotpEnrollment.vue, PasswordResetView.vue
|
||||
*/
|
||||
|
||||
import { request } from './utils.js'
|
||||
import { request, jsonRequest } from './utils.js'
|
||||
|
||||
export function login(body) {
|
||||
return request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonRequest('/api/auth/login', 'POST', body)
|
||||
}
|
||||
|
||||
export function register(body) {
|
||||
return request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonRequest('/api/auth/register', 'POST', body)
|
||||
}
|
||||
|
||||
export function refreshToken() {
|
||||
@@ -41,11 +33,7 @@ export function getMe() {
|
||||
}
|
||||
|
||||
export function changePassword(body) {
|
||||
return request('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonRequest('/api/auth/change-password', 'POST', body)
|
||||
}
|
||||
|
||||
export function totpSetup() {
|
||||
@@ -53,11 +41,7 @@ export function totpSetup() {
|
||||
}
|
||||
|
||||
export function totpEnable(code) {
|
||||
return request('/api/auth/totp/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
return jsonRequest('/api/auth/totp/enable', 'POST', { code })
|
||||
}
|
||||
|
||||
export function totpDisable() {
|
||||
@@ -65,18 +49,13 @@ export function totpDisable() {
|
||||
}
|
||||
|
||||
export function passwordResetRequest(email) {
|
||||
return request('/api/auth/password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
return jsonRequest('/api/auth/password-reset', 'POST', { email })
|
||||
}
|
||||
|
||||
export function passwordResetConfirm(token, newPassword) {
|
||||
return request('/api/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
return jsonRequest('/api/auth/password-reset/confirm', 'POST', {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,11 +64,7 @@ export function getMyPreferences() {
|
||||
}
|
||||
|
||||
export function updateMyPreferences(payload) {
|
||||
return request('/api/auth/me/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return jsonRequest('/api/auth/me/preferences', 'PATCH', payload)
|
||||
}
|
||||
|
||||
export function getMyQuota() {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* CloudFolderTreeItem.vue
|
||||
*/
|
||||
|
||||
import { request } from './utils.js'
|
||||
import { request, jsonRequest } from './utils.js'
|
||||
|
||||
export function listCloudConnections() {
|
||||
return request('/api/cloud/connections')
|
||||
@@ -17,19 +17,16 @@ export function disconnectCloud(id) {
|
||||
}
|
||||
|
||||
export function connectWebDav(provider, serverUrl, username, password) {
|
||||
return request('/api/cloud/connections/webdav', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, server_url: serverUrl, username, password }),
|
||||
return jsonRequest('/api/cloud/connections/webdav', 'POST', {
|
||||
provider,
|
||||
server_url: serverUrl,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDefaultStorage(backend) {
|
||||
return request('/api/users/me/default-storage', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backend }),
|
||||
})
|
||||
return jsonRequest('/api/users/me/default-storage', 'PATCH', { backend })
|
||||
}
|
||||
|
||||
export function getCloudFolders(provider, folderId) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* DocumentPreviewModal.vue, FileManagerView.vue, CloudFolderView.vue
|
||||
*/
|
||||
|
||||
import { request, fetchWithRetry } from './utils.js'
|
||||
import { request, fetchWithRetry, jsonRequest } from './utils.js'
|
||||
|
||||
export function listDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) {
|
||||
const params = new URLSearchParams({ page, per_page: perPage })
|
||||
@@ -31,18 +31,13 @@ export function deleteDocumentRemoveOnly(id) {
|
||||
}
|
||||
|
||||
export function classifyDocument(id, topics = null) {
|
||||
return request(`/api/documents/${id}/classify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(topics ? { topics } : {}),
|
||||
})
|
||||
return jsonRequest(`/api/documents/${id}/classify`, 'POST', topics ? { topics } : {})
|
||||
}
|
||||
|
||||
export function getUploadUrl(filename, contentType) {
|
||||
return request('/api/documents/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, content_type: contentType }),
|
||||
return jsonRequest('/api/documents/upload-url', 'POST', {
|
||||
filename,
|
||||
content_type: contentType,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Consumers: stores/folders.js, FolderTreeItem.vue, AppSidebar.vue
|
||||
*/
|
||||
|
||||
import { request } from './utils.js'
|
||||
import { request, jsonRequest } from './utils.js'
|
||||
|
||||
export function listFolders(parentId = null) {
|
||||
const params = new URLSearchParams()
|
||||
@@ -14,11 +14,7 @@ export function listFolders(parentId = null) {
|
||||
}
|
||||
|
||||
export function createFolder(name, parentId = null) {
|
||||
return request('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, parent_id: parentId || null }),
|
||||
})
|
||||
return jsonRequest('/api/folders', 'POST', { name, parent_id: parentId || null })
|
||||
}
|
||||
|
||||
export function getFolder(folderId) {
|
||||
@@ -26,11 +22,7 @@ export function getFolder(folderId) {
|
||||
}
|
||||
|
||||
export function renameFolder(folderId, name) {
|
||||
return request(`/api/folders/${folderId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
return jsonRequest(`/api/folders/${folderId}`, 'PATCH', { name })
|
||||
}
|
||||
|
||||
export function deleteFolder(folderId) {
|
||||
@@ -38,9 +30,5 @@ export function deleteFolder(folderId) {
|
||||
}
|
||||
|
||||
export function moveDocument(docId, folderId) {
|
||||
return request(`/api/documents/${docId}/folder`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_id: folderId || null }),
|
||||
})
|
||||
return jsonRequest(`/api/documents/${docId}/folder`, 'PATCH', { folder_id: folderId || null })
|
||||
}
|
||||
|
||||
@@ -4,22 +4,18 @@
|
||||
* Consumers: SharedView.vue, ShareModal.vue
|
||||
*/
|
||||
|
||||
import { request } from './utils.js'
|
||||
import { request, jsonRequest } from './utils.js'
|
||||
|
||||
export function createShare(docId, recipientHandle, permission = 'view') {
|
||||
return request('/api/shares', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document_id: docId, recipient_handle: recipientHandle, permission }),
|
||||
return jsonRequest('/api/shares', 'POST', {
|
||||
document_id: docId,
|
||||
recipient_handle: recipientHandle,
|
||||
permission,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSharePermission(shareId, permission) {
|
||||
return request(`/api/shares/${shareId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ permission }),
|
||||
})
|
||||
return jsonRequest(`/api/shares/${shareId}`, 'PATCH', { permission })
|
||||
}
|
||||
|
||||
export function listShares(docId) {
|
||||
|
||||
@@ -4,26 +4,18 @@
|
||||
* Consumers: stores/topics.js
|
||||
*/
|
||||
|
||||
import { request } from './utils.js'
|
||||
import { request, jsonRequest } from './utils.js'
|
||||
|
||||
export function listTopics() {
|
||||
return request('/api/topics')
|
||||
}
|
||||
|
||||
export function createTopic({ name, description = '', color = '#6366f1' }) {
|
||||
return request('/api/topics', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, color }),
|
||||
})
|
||||
return jsonRequest('/api/topics', 'POST', { name, description, color })
|
||||
}
|
||||
|
||||
export function updateTopic(id, patch) {
|
||||
return request(`/api/topics/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
return jsonRequest(`/api/topics/${id}`, 'PATCH', patch)
|
||||
}
|
||||
|
||||
export function deleteTopic(id) {
|
||||
@@ -31,9 +23,5 @@ export function deleteTopic(id) {
|
||||
}
|
||||
|
||||
export function suggestTopics(documentId) {
|
||||
return request('/api/topics/suggest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document_id: documentId }),
|
||||
})
|
||||
return jsonRequest('/api/topics/suggest', 'POST', { document_id: documentId })
|
||||
}
|
||||
|
||||
+44
-82
@@ -1,56 +1,42 @@
|
||||
/**
|
||||
* HTTP transport + 401-retry consolidator.
|
||||
*
|
||||
* `request()` moved from client.js to break the circular dependency:
|
||||
* domain modules import request from here; client.js re-exports from those
|
||||
* same domain modules, so request cannot also live in client.js.
|
||||
*
|
||||
* `fetchWithRetry()` consolidates 3 blob-download patterns that previously
|
||||
* duplicated identical auth-injection + 401-retry boilerplate:
|
||||
* - adminExportAuditLogCsv (admin.js)
|
||||
* - adminDownloadDailyExport (admin.js)
|
||||
* - fetchDocumentContent (documents.js)
|
||||
*
|
||||
* Security: Bearer token injected from authStore (Pinia memory only — CLAUDE.md).
|
||||
* Token is NEVER read from localStorage or sessionStorage.
|
||||
*/
|
||||
const NO_REFRESH_PATHS = new Set(['/api/auth/login', '/api/auth/register', '/api/auth/refresh'])
|
||||
|
||||
/**
|
||||
* Core HTTP transport. All JSON-returning endpoints go through this function.
|
||||
*
|
||||
* On 401: attempts one token refresh via authStore.refresh() then retries.
|
||||
* Skip-refresh guard: login/register return 401 for bad credentials (not expired
|
||||
* tokens), and refresh itself must not retry (would cause infinite loop).
|
||||
*
|
||||
* @param {string} path — API path (e.g. '/api/documents')
|
||||
* @param {RequestInit & {_retry?: boolean}} [options] — fetch options
|
||||
* @returns {Promise<any>} — parsed JSON response, or null for 204/empty body
|
||||
*/
|
||||
export async function request(path, options = {}) {
|
||||
// Lazy import to avoid circular dependency (stores/auth.js → api/client.js → stores/auth.js)
|
||||
async function getAuthStore() {
|
||||
const { useAuthStore } = await import('../stores/auth.js')
|
||||
const authStore = useAuthStore()
|
||||
return useAuthStore()
|
||||
}
|
||||
|
||||
function withAuthHeaders(options, accessToken) {
|
||||
const headers = { ...(options.headers || {}) }
|
||||
if (authStore.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${authStore.accessToken}`
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
return { ...options, headers, credentials: 'include' }
|
||||
}
|
||||
|
||||
function clearSession(authStore) {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
}
|
||||
|
||||
function fetchAuthenticated(url, options, authStore) {
|
||||
return fetch(url, withAuthHeaders(options, authStore.accessToken))
|
||||
}
|
||||
|
||||
async function refreshAndRetry(authStore, retryFn) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return retryFn()
|
||||
} catch {
|
||||
clearSession(authStore)
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(path, { ...options, headers, credentials: 'include' })
|
||||
/** Core HTTP transport for JSON-returning API endpoints. */
|
||||
export async function request(path, options = {}) {
|
||||
const authStore = await getAuthStore()
|
||||
const res = await fetch(path, withAuthHeaders(options, authStore.accessToken))
|
||||
|
||||
// 401 → attempt refresh → retry once
|
||||
// Skip refresh for auth endpoints: login/register return 401 for bad credentials (not expired tokens),
|
||||
// and refresh itself must not retry to avoid an infinite loop.
|
||||
const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh']
|
||||
if (res.status === 401 && !options._retry && !noRefreshPaths.includes(path)) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return request(path, { ...options, _retry: true })
|
||||
} catch {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
if (res.status === 401 && !options._retry && !NO_REFRESH_PATHS.has(path)) {
|
||||
return refreshAndRetry(authStore, () => request(path, { ...options, _retry: true }))
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -74,45 +60,21 @@ export async function request(path, options = {}) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch with 401-retry for non-JSON responses (blobs, raw Response).
|
||||
*
|
||||
* Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent
|
||||
* which share identical auth-injection + 401-retry boilerplate (CODE-08).
|
||||
*
|
||||
* Unlike request(), this does NOT parse the response — the raw Response is returned
|
||||
* so callers can call .text(), .blob(), etc. as appropriate.
|
||||
*
|
||||
* Security: Bearer token from authStore.accessToken (memory only — CLAUDE.md).
|
||||
* On 401-and-not-already-retried: calls authStore.refresh() and recurses with
|
||||
* _retry=true. On refresh failure: clears in-memory token state and throws
|
||||
* 'Session expired'.
|
||||
*
|
||||
* @param {string} url — full URL to fetch
|
||||
* @param {RequestInit} [options] — fetch options (method, headers, etc.)
|
||||
* @param {boolean} [_retry] — internal retry guard; callers must NOT pass this
|
||||
* @returns {Promise<Response>} — raw Response; caller decides how to consume it
|
||||
*/
|
||||
export function jsonRequest(path, method, body) {
|
||||
return request(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Authenticated fetch with one refresh retry for raw/blob responses. */
|
||||
export async function fetchWithRetry(url, options = {}, _retry = false) {
|
||||
const { useAuthStore } = await import('../stores/auth.js')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const headers = { ...(options.headers || {}) }
|
||||
if (authStore.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${authStore.accessToken}`
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...options, headers, credentials: 'include' })
|
||||
const authStore = await getAuthStore()
|
||||
const res = await fetchAuthenticated(url, options, authStore)
|
||||
|
||||
if (res.status === 401 && !_retry) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return fetchWithRetry(url, options, true)
|
||||
} catch {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
return refreshAndRetry(authStore, () => fetchWithRetry(url, options, true))
|
||||
}
|
||||
|
||||
return res
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
aria-label="Search documents"
|
||||
class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
@input="emit('update:modelValue', $event.target.value)"
|
||||
@keydown.escape="emit('update:modelValue', '')"
|
||||
@keydown.escape.prevent.stop="emit('update:modelValue', '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,13 +53,13 @@ export default {
|
||||
window.addEventListener('dragenter', this.onDragEnter)
|
||||
window.addEventListener('dragleave', this.onDragLeave)
|
||||
window.addEventListener('dragover', this.onDragOver)
|
||||
window.addEventListener('drop', this.onDrop)
|
||||
window.addEventListener('drop', this.onDrop, true)
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('dragenter', this.onDragEnter)
|
||||
window.removeEventListener('dragleave', this.onDragLeave)
|
||||
window.removeEventListener('dragover', this.onDragOver)
|
||||
window.removeEventListener('drop', this.onDrop)
|
||||
window.removeEventListener('drop', this.onDrop, true)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- ── Sticky toolbar ──────────────────────────────────────────────── -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<BreadcrumbBar
|
||||
@@ -29,16 +28,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Content area ───────────────────────────────────────────────── -->
|
||||
<div class="flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Upload zone -->
|
||||
<div class="px-6 pt-5 pb-3">
|
||||
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
</div>
|
||||
|
||||
<!-- Column headers — 5-column grid, consistent for local and cloud -->
|
||||
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none">
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
@@ -47,7 +43,6 @@
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Inline new-folder row (local only) -->
|
||||
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
|
||||
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||
<AppIcon name="folder" class="w-4 h-4 text-amber-400" />
|
||||
@@ -68,10 +63,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Item list ─────────────────────────────────────────────────── -->
|
||||
<div class="mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
|
||||
|
||||
<!-- Folder rows -->
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="`f-${folder.id}`"
|
||||
@@ -89,7 +82,6 @@
|
||||
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<!-- Name / rename input -->
|
||||
<div>
|
||||
<input
|
||||
v-if="renamingId === folder.id"
|
||||
@@ -105,7 +97,6 @@
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">—</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
|
||||
|
||||
<!-- Folder actions (local only) -->
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<button @click.stop="startRename(folder)" title="Rename"
|
||||
@@ -135,7 +126,6 @@
|
||||
<AppIcon name="document" class="w-4 h-4 text-indigo-400" />
|
||||
</div>
|
||||
|
||||
<!-- Name + topics + shared badge -->
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p>
|
||||
@@ -154,15 +144,12 @@
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
|
||||
|
||||
<!-- File actions (local only) -->
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<!-- Share -->
|
||||
<button @click.stop="$emit('file-share', file)" title="Share"
|
||||
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
|
||||
<AppIcon name="share" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<!-- Move -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="openFolderPicker(file.id, $event)"
|
||||
@@ -172,7 +159,6 @@
|
||||
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Delete -->
|
||||
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
|
||||
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
@@ -225,7 +211,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teleported folder picker — avoids overflow:hidden clipping -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="folderPickerFileId"
|
||||
@@ -299,19 +284,15 @@ const emit = defineEmits([
|
||||
'file-delete',
|
||||
])
|
||||
|
||||
const showSearch = computed(() => props.mode === 'local' && props.breadcrumb.length > 0)
|
||||
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
|
||||
|
||||
function topicColor(name) {
|
||||
return props.topicColorFn(name)
|
||||
}
|
||||
|
||||
// ── Child component refs ──────────────────────────────────────────────────────
|
||||
|
||||
const dropZoneRef = ref(null)
|
||||
const searchBarRef = ref(null)
|
||||
|
||||
// ── New folder inline input ───────────────────────────────────────────────────
|
||||
|
||||
const showNewFolderInput = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderError = ref('')
|
||||
@@ -331,8 +312,17 @@ function cancelNewFolder() {
|
||||
|
||||
async function submitNewFolder() {
|
||||
const name = newFolderName.value.trim()
|
||||
if (!name) { newFolderError.value = 'Folder name cannot be empty.'; return }
|
||||
emit('folder-create', { name, onError: (msg) => { newFolderError.value = msg }, onSuccess: cancelNewFolder })
|
||||
if (!name) {
|
||||
newFolderError.value = 'Folder name cannot be empty.'
|
||||
return
|
||||
}
|
||||
emit('folder-create', {
|
||||
name,
|
||||
onError: (msg) => {
|
||||
newFolderError.value = msg
|
||||
},
|
||||
onSuccess: cancelNewFolder,
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -342,8 +332,6 @@ defineExpose({
|
||||
clearSearch: () => emit('search-change', ''),
|
||||
})
|
||||
|
||||
// ── Rename ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const renamingId = ref(null)
|
||||
const renameValue = ref('')
|
||||
|
||||
@@ -352,8 +340,6 @@ function startRename(folder) {
|
||||
renameValue.value = folder.name
|
||||
}
|
||||
|
||||
// ── Drag and drop (local only) ────────────────────────────────────────────────
|
||||
|
||||
const draggingFile = ref(null)
|
||||
const dragOverFolderId = ref(null)
|
||||
|
||||
@@ -383,11 +369,8 @@ async function onDropDocOnFolder(folderId) {
|
||||
draggingFile.value = null
|
||||
}
|
||||
|
||||
// ── Move folder picker (Teleport + getBoundingClientRect) ─────────────────────
|
||||
|
||||
const folderPickerFileId = ref(null)
|
||||
const pickerStyle = ref({})
|
||||
// Map fileId → trigger button element, so scroll listener can reposition
|
||||
const pickerTriggerMap = new Map()
|
||||
|
||||
function updatePickerPosition(rect) {
|
||||
@@ -409,7 +392,6 @@ function updatePickerPosition(rect) {
|
||||
}
|
||||
|
||||
function openFolderPicker(fileId, ev) {
|
||||
// Toggle off if same file
|
||||
if (folderPickerFileId.value === fileId) {
|
||||
folderPickerFileId.value = null
|
||||
return
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import StorageBrowser from '../StorageBrowser.vue'
|
||||
|
||||
const globalStubs = {
|
||||
BreadcrumbBar: true,
|
||||
SearchBar: true,
|
||||
SortControls: true,
|
||||
DropZone: true,
|
||||
UploadProgress: true,
|
||||
TopicBadge: true,
|
||||
AppIcon: true,
|
||||
EmptyState: true,
|
||||
}
|
||||
|
||||
function mountBrowser(mode, breadcrumb = []) {
|
||||
setActivePinia(createPinia())
|
||||
return mount(StorageBrowser, {
|
||||
props: {
|
||||
mode,
|
||||
breadcrumb,
|
||||
documents: [],
|
||||
folders: [],
|
||||
files: [],
|
||||
topicColorFn: () => '#000',
|
||||
loading: false,
|
||||
},
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Gap 2: showSearch visible at root for local and cloud modes', () => {
|
||||
it("mode='local', breadcrumb=[] → showSearch is true (root-level local)", () => {
|
||||
const w = mountBrowser('local', [])
|
||||
expect(w.vm.showSearch).toBe(true)
|
||||
})
|
||||
|
||||
it("mode='local', breadcrumb=[{name:'Folder'}] → showSearch is true (non-root local)", () => {
|
||||
const w = mountBrowser('local', [{ name: 'Folder' }])
|
||||
expect(w.vm.showSearch).toBe(true)
|
||||
})
|
||||
|
||||
it("mode='cloud', breadcrumb=[] → showSearch is true (cloud root)", () => {
|
||||
const w = mountBrowser('cloud', [])
|
||||
expect(w.vm.showSearch).toBe(true)
|
||||
})
|
||||
|
||||
it("mode='shared' → showSearch is false", () => {
|
||||
const w = mountBrowser('shared', [])
|
||||
expect(w.vm.showSearch).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -46,10 +46,13 @@
|
||||
<template v-if="expanded">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-xs text-gray-400 py-1"
|
||||
class="py-1 space-y-1"
|
||||
:style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
|
||||
>
|
||||
Loading…
|
||||
<div v-for="n in 3" :key="`sk-t-${n}`" class="flex items-center gap-2 py-1">
|
||||
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
||||
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadError"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import TreeItem from '../TreeItem.vue'
|
||||
|
||||
vi.mock('../AppIcon.vue', () => ({ default: { template: '<span/>' } }))
|
||||
|
||||
function mountItem(overrides = {}) {
|
||||
return mount(TreeItem, {
|
||||
props: {
|
||||
label: 'Test',
|
||||
loadChildren: vi.fn().mockResolvedValue([]),
|
||||
expandable: true,
|
||||
...overrides,
|
||||
},
|
||||
global: { stubs: { RouterLink: true } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Gap 1: sidebar shimmer rows', () => {
|
||||
it('renders animate-pulse elements when loading=true (expanded via refresh)', async () => {
|
||||
// First expand the item (so expanded=true, childrenLoaded=true)
|
||||
let resolveFn1
|
||||
const loadChildren = vi.fn()
|
||||
.mockReturnValueOnce(Promise.resolve([])) // first load resolves immediately
|
||||
.mockReturnValue(new Promise(resolve => { resolveFn1 = resolve })) // refresh never resolves
|
||||
const w = mountItem({ loadChildren })
|
||||
// Expand by awaiting toggleExpand — sets expanded=true after load completes
|
||||
const setupState = w.vm.$.setupState
|
||||
await setupState.toggleExpand()
|
||||
await flushPromises()
|
||||
// Now call refresh() without awaiting — it calls load() which sets loading=true while expanded stays true
|
||||
setupState.refresh() // NOT awaited — loading=true while promise is pending
|
||||
await nextTick() // let Vue update the DOM
|
||||
const pulseEls = w.findAll('.animate-pulse')
|
||||
expect(pulseEls.length).toBeGreaterThanOrEqual(2)
|
||||
// Cleanup
|
||||
if (resolveFn1) resolveFn1([])
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('does not render "Loading" text when loading=true (expanded via refresh)', async () => {
|
||||
let resolveFn1
|
||||
const loadChildren = vi.fn()
|
||||
.mockReturnValueOnce(Promise.resolve([]))
|
||||
.mockReturnValue(new Promise(resolve => { resolveFn1 = resolve }))
|
||||
const w = mountItem({ loadChildren })
|
||||
const setupState = w.vm.$.setupState
|
||||
await setupState.toggleExpand()
|
||||
await flushPromises()
|
||||
setupState.refresh()
|
||||
await nextTick()
|
||||
expect(w.text()).not.toContain('Loading')
|
||||
if (resolveFn1) resolveFn1([])
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gap 1 regression: non-loading branches unchanged', () => {
|
||||
it('shows Empty text when loadChildren resolves to [] and item is expanded', async () => {
|
||||
const w = mountItem({ loadChildren: vi.fn().mockResolvedValue([]) })
|
||||
const toggleExpand = w.vm.$.setupState.toggleExpand
|
||||
await toggleExpand()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
expect(w.text()).toContain('Empty')
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@
|
||||
@breadcrumb-navigate="handleBreadcrumbNavigate"
|
||||
@upload="onFilesSelected"
|
||||
@folder-navigate="item => navigateTo(item)"
|
||||
@file-open="file => {}"
|
||||
@file-open="onFileOpen"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -21,12 +21,15 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import * as api from '../api/client.js'
|
||||
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
||||
import { providerLabel } from '../utils/formatters.js'
|
||||
import { useToastStore } from '../stores/toast.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const toast = useToastStore()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const provider = computed(() => route.params.provider)
|
||||
@@ -92,6 +95,10 @@ async function onFilesSelected({ files: selectedFiles }) {
|
||||
await load()
|
||||
}
|
||||
|
||||
function onFileOpen(file) {
|
||||
toast.show(`"${file.name}" can't be opened here — open it directly in your cloud storage provider.`, 'info')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([provider, folderId], load)
|
||||
</script>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<input
|
||||
v-model="formByProvider[prov.provider_id].base_url"
|
||||
type="text"
|
||||
:placeholder="providerDefaultBaseUrl(prov.provider_id) || '(default)'"
|
||||
:placeholder="providerDefaultBaseUrl(prov) || '(default)'"
|
||||
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@
|
||||
<SearchableModelSelect
|
||||
v-model="formByProvider[prov.provider_id].model_name"
|
||||
:provider-id="prov.provider_id"
|
||||
:placeholder="providerDefaultModel(prov.provider_id)"
|
||||
:placeholder="providerDefaultModel(prov)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<input
|
||||
v-model.number="formByProvider[prov.provider_id].context_chars"
|
||||
type="number"
|
||||
:placeholder="String(providerDefaultContextChars(prov.provider_id))"
|
||||
:placeholder="String(providerDefaultContextChars(prov))"
|
||||
min="1000"
|
||||
max="2000000"
|
||||
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
@@ -131,8 +131,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Per-user AI provider assignment (existing — DO NOT MODIFY) ─────── -->
|
||||
|
||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
||||
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
|
||||
@@ -210,25 +208,6 @@ import AppIcon from '../../components/ui/AppIcon.vue'
|
||||
import SearchableModelSelect from '../../components/ui/SearchableModelSelect.vue'
|
||||
import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'
|
||||
|
||||
|
||||
const _PROVIDER_DEFAULTS = {
|
||||
openai: { base_url: null, model: 'gpt-4o', context_chars: 120000 },
|
||||
anthropic: { base_url: null, model: 'claude-sonnet-4-6', context_chars: 180000 },
|
||||
gemini: { base_url: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash', context_chars: 800000 },
|
||||
groq: { base_url: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', context_chars: 128000 },
|
||||
xai: { base_url: 'https://api.x.ai/v1', model: 'grok-3-mini', context_chars: 128000 },
|
||||
deepseek: { base_url: 'https://api.deepseek.com', model: 'deepseek-chat', context_chars: 60000 },
|
||||
openrouter: { base_url: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-3.5-sonnet', context_chars: 180000 },
|
||||
mistral: { base_url: 'https://api.mistral.ai/v1', model: 'mistral-large-latest', context_chars: 128000 },
|
||||
ollama: { base_url: 'http://host.docker.internal:11434/v1', model: 'llama3.2', context_chars: 8000 },
|
||||
lmstudio: { base_url: 'http://host.docker.internal:1234/v1', model: 'gemma-4-e4b-it', context_chars: 8000 },
|
||||
}
|
||||
|
||||
function providerDefaultBaseUrl(pid) { return _PROVIDER_DEFAULTS[pid]?.base_url || '' }
|
||||
function providerDefaultModel(pid) { return _PROVIDER_DEFAULTS[pid]?.model || '' }
|
||||
function providerDefaultContextChars(pid) { return _PROVIDER_DEFAULTS[pid]?.context_chars || 8000 }
|
||||
|
||||
|
||||
const systemProviders = ref([])
|
||||
const loadingSystem = ref(false)
|
||||
const systemError = ref(null)
|
||||
@@ -238,6 +217,18 @@ const testResults = reactive({})
|
||||
const savingProvider = ref(null)
|
||||
const testingProvider = ref(null)
|
||||
|
||||
function providerDefaultBaseUrl(provider) {
|
||||
return provider?.base_url || ''
|
||||
}
|
||||
|
||||
function providerDefaultModel(provider) {
|
||||
return provider?.model_name || ''
|
||||
}
|
||||
|
||||
function providerDefaultContextChars(provider) {
|
||||
return provider?.context_chars || 8000
|
||||
}
|
||||
|
||||
function toggleProvider(pid) {
|
||||
openProviderId.value = openProviderId.value === pid ? null : pid
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ function generateRandomPassword() {
|
||||
const lower = 'abcdefghijkmnpqrstuvwxyz'
|
||||
const digits = '23456789'
|
||||
const special = '!@#$%^&*'
|
||||
const charset = upper + lower + digits + special // 64 chars — 256 % 64 === 0, no modulo bias
|
||||
const charset = upper + lower + digits + special
|
||||
|
||||
const arr = new Uint8Array(16)
|
||||
crypto.getRandomValues(arr)
|
||||
@@ -396,62 +396,60 @@ function cancelDelete() {
|
||||
}
|
||||
|
||||
async function confirmDoDelete(id) {
|
||||
pendingAction[id] = true
|
||||
deleteError.value = null
|
||||
try {
|
||||
await runUserAction(id, async () => {
|
||||
await api.adminDeleteUser(id, deletePassword.value)
|
||||
users.value = users.value.filter(u => u.id !== id)
|
||||
cancelDelete()
|
||||
} catch (e) {
|
||||
}, (e) => {
|
||||
deleteError.value = e.message
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmDoDeactivate(id) {
|
||||
pendingAction[id] = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await runUserAction(id, async () => {
|
||||
await api.adminDeactivateUser(id)
|
||||
const idx = users.value.findIndex(u => u.id === id)
|
||||
if (idx !== -1) {
|
||||
users.value[idx] = { ...users.value[idx], is_active: false }
|
||||
}
|
||||
updateUser(id, { is_active: false })
|
||||
confirmDeactivate.value = null
|
||||
} catch (e) {
|
||||
}, (e) => {
|
||||
actionError.value = e.message
|
||||
confirmDeactivate.value = null
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function reactivate(id) {
|
||||
pendingAction[id] = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await runUserAction(id, async () => {
|
||||
await api.adminReactivateUser(id)
|
||||
const idx = users.value.findIndex(u => u.id === id)
|
||||
if (idx !== -1) {
|
||||
users.value[idx] = { ...users.value[idx], is_active: true }
|
||||
}
|
||||
} catch (e) {
|
||||
updateUser(id, { is_active: true })
|
||||
}, (e) => {
|
||||
actionError.value = e.message
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPassword(id) {
|
||||
actionError.value = null
|
||||
await runUserAction(id, () => api.adminResetUserPassword(id), (e) => {
|
||||
actionError.value = e.message
|
||||
})
|
||||
}
|
||||
|
||||
async function runUserAction(id, action, onError) {
|
||||
pendingAction[id] = true
|
||||
try {
|
||||
await action()
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(id) {
|
||||
pendingAction[id] = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await api.adminResetUserPassword(id)
|
||||
} catch (e) {
|
||||
actionError.value = e.message
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
function updateUser(id, patch) {
|
||||
const idx = users.value.findIndex(u => u.id === id)
|
||||
if (idx !== -1) {
|
||||
users.value[idx] = { ...users.value[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user