diff --git a/docs/plan/drive.md b/docs/plan/drive.md index 4947f6f4..c3f94a0f 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -618,9 +618,15 @@ accommodates them without schema migration) | URL | Resolves to | |---|---| -| `/` | Redirect to the caller's personal drive UUID | -| `/drive/` | Drive root view | -| `/drive//` | Folder inside the drive | +| `/` (internal user) | Redirect to `/drive/` of the caller's default personal drive | +| `/` (external user) | Redirect to `/sharedwithme` (no personal drive exists) | +| `/drive/` | Folder view (root or descendant — drive context is recovered server-side from `folders.drive_id`) | +| `/config/drive/` | Drive configuration surface (members, policies, quota). Page is permission-aware: owner sees member management; editor/viewer see a read-only "Drive info" view | +| `/config/user/` | (Future) User configuration — same shape so the `/config//` pattern is consistent across resources | + +**Why folder-id, not drive-uuid + folder-id**: every `storage.folders` row carries `drive_id` after D0, so a single folder UUID recovers the drive context in one cheap lookup. Stable across cross-drive moves (D6): bookmarks keep working when a folder hops drives, because the folder UUID doesn't change. + +**Why `/config/` is a separate top-level segment**, not `/drive//settings`: the URL prefix encodes intent ("we are configuring something"), not just resource location. Future configuration surfaces (`/config/user/`, `/config/group/`, `/config/share/`) compose cleanly under the same prefix. It also avoids the singular-vs-plural ambiguity (`/drive/` vs `/drives//settings`) that's easy to typo and hard to grep for. #### Native WebDAV (`/webdav/...`) @@ -1371,7 +1377,7 @@ us a real rollback window while the new model bakes in production. |---|---|---| | **D-Prep — role_grants refactor** | `access_grants → role_grants` schema migration with role-bundle semantics. `Manage` Permission added to the enum + role bundle. Engine reads role_grants only; `access_grants` removed (after one dual-write release if compat is needed). API gains `role` parameter on grant endpoints; audit log emits one `role_grant.*` event per role assignment instead of N permission events. **No Drive concept yet.** Sets the foundation that all subsequent PRs build on. **Data shape confirmed**: empirical audit shows >99% of existing `access_grants` rows already cluster into the standard bundles (viewer/editor/owner) — the migration is mechanical for the vast majority of data; the <1% edge cases get absorbed by shipping `commenter` and `contributor` roles on day one or get an explicit per-row migration decision logged. | **Medium** — touches the load-bearing authorisation table, but the data shape removes the main migration risk | | **D0 — foundation** | `storage.drives` schema (no `drive_members` — uses `role_grants` from D-Prep); `Drive` domain entity; migration creating personal drives + backfilling `drive_id` on every resource; read-only `GET /api/drives` listing the caller's drives (single query: `SELECT … FROM role_grants WHERE subject_id=$caller AND resource_type='drive'`). Dual-write `user_id` alongside `drive_id` for safety. **No new UI.** **Every upload path stamps `drive_id` at insert**: classic multipart (`file_handler::upload`), chunked NC (`uploads_handler`), streaming CDC (`upload_ingest`), delta upload (`delta_upload_service`), instant upload by hash. Tantivy reindex (see §11) is part of this PR. **Provenance columns added** (see §14): `created_by` and `updated_by` on both `storage.folders` and `storage.files`, FK to `auth.users` with `ON DELETE SET NULL`; backfilled from `user_id` so pre-Drive content has provenance from day one; every mutation path that touches `updated_at` also sets `updated_by`. | **High** — every storage query touches, all upload paths touched | -| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/drive//` frontend routes, default-drive redirect from `/`. WebDAV path dispatcher recognising `drives/` as the drive-explicit prefix on both `/webdav/` and `/remote.php/dav/`. | Medium | +| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/drive/` frontend route (drive context recovered server-side from `folders.drive_id`), `/config/drive/` for drive admin. `/` redirects to `/drive/` of the caller's default personal drive (internal users) or `/sharedwithme` (external users with no personal drive). WebDAV path dispatcher recognising `drives/` as the drive-explicit prefix on `/webdav/` (NC keeps the credential-side scheme — see §9). | Medium | | **D2 — drive membership API + per-drive trash auth** | `POST /api/drives/{id}/members`, `DELETE`, `PUT` for role changes — thin handlers that translate to `role_grants` INSERT/DELETE/UPDATE with `resource_type='drive'`. `Resource::Drive(Uuid)` (added in D-Prep at the enum level) gets its specialised handler surface here. Shared-drive last-owner protection. Group-as-subject support reuses the existing `subject_groups` machinery. **Personal-drive guards** (`add_member`, `remove_member`, `delete_drive` refuse on `kind='personal'` — see §2). **Per-drive trash authorisation** (§12): trash listing filters by drive(s) the caller can read; trash mutations (send/restore/permanent-delete) require `role='owner'` on the drive; `storage.trash_items` VIEW updated to surface `drive_id`; orphan/aborted-upload sweep becomes per-drive. | Medium | | **D3 — group-owned shared drives** | "Create shared drive" flow — admin or group owner triggers, drive created with `kind='shared'`, initial owner row is the group. Group-deletion guard refuses if the group is the last owner of any drive. Drive-rename, drive-delete. | Low | | **D4 — per-drive quota** | Move storage accounting off `auth.users.storage_used_bytes` onto `storage.drives.used_bytes`. **Re-point the existing per-user incremental CTE** (introduced in v0.7.0 — see `b5b80549`, `d6987329`) at drive rows; don't reinvent the counting logic. Upload paths check `drive.quota_bytes` instead of (or in addition to) the user's quota for the dual-write window. **Per-chunk incremental quota check on the NC chunked path** (see §13): MKCOL refuses when the drive is already over quota; each PUT chunk runs an O(1) `used + session_so_far + chunk_size > quota` test and refuses with 507 within one chunk of wasted upload. Closes a pre-existing wart where NC clients could upload GB before learning they were over quota. Reconciliation job runs once per day to fix drift. | Medium | @@ -1652,13 +1658,21 @@ test`), **(c)** `cargo fmt && cargo clippy --all-features personal drive without reconfiguration. The chroot POC's `~` username (or app-password binding) lands a sync into the chosen drive transparently. -- **Manual smoke**: open `/`, get redirected to - `/drive/`. Click sidebar drive switcher → URL - updates, listing reloads. Drive picker shows all of the caller's - drives (default first), each with its quota usage. +- **Manual smoke (internal user)**: open `/`, get redirected to + `/drive/`. Click sidebar + drive switcher → URL updates to `/drive/`, + listing reloads. Drive picker shows all of the caller's drives + (default first), each with its quota usage. Open + `/config/drive/` → owner sees member list + + policies. Open `/config/drive/` as a viewer → + read-only "Drive info" surface. +- **Manual smoke (external user)**: open `/`, get redirected to + `/sharedwithme` (no `/drive/...` for an account without a + personal drive). - **Playwright**: a new `tests/e2e/drive-switching.spec.ts` exercises sidebar → URL → listing → cross-drive isolation (folders in - drive A don't appear in drive B's listing). + drive A don't appear in drive B's listing), plus the + internal-vs-external root redirect split. ### D2 - **Membership API**: `POST /api/drives/{id}/members` with user, with diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c0ae1f70..1ca0c269 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,7 +15,8 @@ const proxy = { '/webdav': { target: BACKEND, changeOrigin: true }, '/caldav': { target: BACKEND, changeOrigin: true }, '/carddav': { target: BACKEND, changeOrigin: true }, - '/wopi': { target: BACKEND, changeOrigin: true } + '/wopi': { target: BACKEND, changeOrigin: true }, + '/magic': { target: BACKEND, changeOrigin: true } }; export default defineConfig({