From cf72f8a77b99bd6b3888fc99934c7311a0321d95 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 20 Jun 2026 00:22:21 +0200 Subject: [PATCH 1/7] feat(drive): clarify UI routes for drive - `/drive/` no change - `/config/drive/` for drive configuraton - add /magic proxy from dev vite server --- docs/plan/drive.md | 32 +++++++++++++++++++++++--------- frontend/vite.config.ts | 3 ++- 2 files changed, 25 insertions(+), 10 deletions(-) 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({ From cf7ad87c546c8ed0af73b208b518de9a3bbecc4f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 20 Jun 2026 01:28:52 +0200 Subject: [PATCH 2/7] feat(drive): add drive picker in sidebar - select by default the home drive --- docs/plan/drive.md | 19 ++- frontend/src/lib/api/endpoints/drives.ts | 15 ++ frontend/src/lib/api/endpoints/folders.ts | 5 - frontend/src/lib/api/types.ts | 21 +++ frontend/src/lib/components/AppShell.svelte | 5 + .../src/lib/components/DrivePicker.svelte | 160 ++++++++++++++++++ frontend/src/lib/stores/drives.svelte.ts | 62 +++++++ frontend/src/lib/stores/session.svelte.ts | 23 +-- frontend/src/lib/styles/ported/breadcrumb.css | 8 +- frontend/src/routes/+page.svelte | 12 +- .../src/routes/files/[...path]/+page.svelte | 54 ++++-- 11 files changed, 344 insertions(+), 40 deletions(-) create mode 100644 frontend/src/lib/api/endpoints/drives.ts create mode 100644 frontend/src/lib/components/DrivePicker.svelte create mode 100644 frontend/src/lib/stores/drives.svelte.ts diff --git a/docs/plan/drive.md b/docs/plan/drive.md index c3f94a0f..8967a206 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -618,11 +618,16 @@ accommodates them without schema migration) | URL | Resolves to | |---|---| -| `/` (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`) | +| `/` (internal user) | Redirect to `/files/` of the caller's default personal drive | +| `/` (external user) | Redirect to `/shared-with-me` (no personal drive exists) | +| `/files` | Default browse — shows the caller's home root (back-compat) | +| `/files/` | Folder view at this folder. Drive context is recovered server-side from `folders.drive_id`. Switching drives = navigating to the new drive's root folder id | +| `/files///` | Folder `c` (descendant of `b`, descendant of `a`). Each segment is a folder UUID; the prefix chain provides breadcrumbs without a server round-trip | | `/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 | +| `/drive/<...>` | **Reserved** for future drive-scoped surfaces that aren't covered by `/files/` or `/config/drive/` | + +**Why `/files/` and not `/drive/`**: the existing files browser already takes a chain of folder UUIDs (`/files///`), with the leaf being the current folder and the prefix providing breadcrumbs. Switching drives just means navigating to a different root folder id under the same prefix — no new route shape required. Reserving `/drive/<...>` for later keeps the door open without forcing a migration now. **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. @@ -1377,7 +1382,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 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 | +| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/files/` reused for cross-drive navigation (existing route — drive context recovered server-side from `folders.drive_id`), `/config/drive/` new route for drive admin. `/` redirects to `/files/` of the caller's default personal drive (internal users) or `/shared-with-me` (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). `/drive/<...>` reserved for future use. | 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 | @@ -1659,15 +1664,15 @@ test`), **(c)** `cargo fmt && cargo clippy --all-features username (or app-password binding) lands a sync into the chosen drive transparently. - **Manual smoke (internal user)**: open `/`, get redirected to - `/drive/`. Click sidebar - drive switcher → URL updates to `/drive/`, + `/files/`. Click sidebar + drive switcher → URL updates to `/files/`, 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 + `/shared-with-me` (no `/files/` 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 diff --git a/frontend/src/lib/api/endpoints/drives.ts b/frontend/src/lib/api/endpoints/drives.ts new file mode 100644 index 00000000..4d0d1e4d --- /dev/null +++ b/frontend/src/lib/api/endpoints/drives.ts @@ -0,0 +1,15 @@ +/** + * Drives endpoints. D0 ships read-only listing; mutations (create / rename / + * member changes) land in D2/D3 and will be added here under the same shape. + * + * Consumers usually go through the `drives` store (`$lib/stores/drives.svelte`) + * which dedupes the request and caches the list — touch this module directly + * only when bypassing the cache is intentional (e.g. an explicit refresh). + */ +import { apiJson } from '$lib/api/client'; +import type { Drive } from '$lib/api/types'; + +/** `GET /api/drives` — every drive the caller can read, default first by convention. */ +export function listDrives(): Promise { + return apiJson('/api/drives', { credentials: 'same-origin' }); +} diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index 0aa00b8b..3880c6c7 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -103,11 +103,6 @@ function parseListing(raw: unknown): FolderListing { }; } -/** Top-level folders for the user; the first entry is the home folder. */ -export function listRootFolders(): Promise { - return apiJson('/api/folders', { credentials: 'same-origin' }); -} - export async function getFolder(id: string): Promise { const folder = await apiJson(`/api/folders/${id}`, NO_CACHE); rememberFolderName(folder.id, folder.name); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index c1aa8bd2..ef7577a0 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -196,3 +196,24 @@ export interface SearchResults { query_time_ms: number; sort_by: string; } + +export type DriveKind = 'personal' | 'shared'; + +/** + * One row from `GET /api/drives`. Mirrors `DriveDto` in + * `src/application/dtos/drive_dto.rs`. `default_for_user` is the caller's + * id when present, `null`/undefined otherwise — used to pick the default + * personal drive without hard-coding name conventions. + */ +export interface Drive { + id: string; + name: string; + kind: DriveKind; + default_for_user?: string | null; + root_folder_id: string; + quota_bytes?: number | null; + used_bytes: number; + policies: Record; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index a6088ae9..1a61464b 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -7,6 +7,8 @@ import { fileInlineUrl } from '$lib/api/endpoints/files'; import type { FileItem, FolderItem } from '$lib/api/types'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; + import CommandPalette from '$lib/components/CommandPalette.svelte'; + import DrivePicker from '$lib/components/DrivePicker.svelte'; import Icon from '$lib/icons/Icon.svelte'; import { iconNameFromClass } from '$lib/utils/display'; import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; @@ -281,6 +283,9 @@ {link.label} + {#if link.href === '/files' && !session.isExternalUser} + (sidebarOpen = false)} /> + {/if} {/each} diff --git a/frontend/src/lib/components/DrivePicker.svelte b/frontend/src/lib/components/DrivePicker.svelte new file mode 100644 index 00000000..d15075fb --- /dev/null +++ b/frontend/src/lib/components/DrivePicker.svelte @@ -0,0 +1,160 @@ + + +{#if drivesStore.loaded && drivesStore.drives.length > 0} +
    + {#each sortedDrives as d (d.id)} +
  • + + {#if pctUsed(d) !== null} +
    +
    +
    + {/if} +
  • + {/each} +
+{/if} + + diff --git a/frontend/src/lib/stores/drives.svelte.ts b/frontend/src/lib/stores/drives.svelte.ts new file mode 100644 index 00000000..7596239a --- /dev/null +++ b/frontend/src/lib/stores/drives.svelte.ts @@ -0,0 +1,62 @@ +/** + * Drives store — caches `GET /api/drives` so the picker, the breadcrumb + * icon, and the session bootstrap all share one fetch. Idempotent `load()`. + * + * Identifying the user's home: always via `default_for_user`, never by + * folder name (users can rename "Personal"). + */ +import { listDrives } from '$lib/api/endpoints/drives'; +import type { Drive } from '$lib/api/types'; + +class DrivesStore { + drives = $state([]); + loaded = $state(false); + private inflight: Promise | null = null; + + async load(): Promise { + if (this.loaded) return this.drives; + if (this.inflight) return this.inflight; + this.inflight = (async () => { + try { + this.drives = await listDrives(); + } catch { + this.drives = []; + } finally { + this.loaded = true; + this.inflight = null; + } + return this.drives; + })(); + return this.inflight; + } + + /** Force a refresh after a mutation (rename, member change, …). */ + invalidate(): void { + this.loaded = false; + this.drives = []; + } + + /** Caller's default-personal drive (one per internal user), or null. */ + findDefault(): Drive | null { + return this.drives.find((d) => d.default_for_user != null) ?? null; + } + + /** Drive whose root folder UUID matches `id`, or null. */ + findByRootFolderId(id: string | null | undefined): Drive | null { + if (!id) return null; + return this.drives.find((d) => d.root_folder_id === id) ?? null; + } +} + +export const drives = new DrivesStore(); + +/** + * Picker / breadcrumb icon for a drive: + * home — default-personal (the user's home) + * folder — secondary personal drive + * users — shared / team drive + */ +export function driveIcon(d: Drive): string { + if (d.default_for_user) return 'home'; + return d.kind === 'shared' ? 'users' : 'folder'; +} diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index c702c882..d93d9b4c 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -7,7 +7,7 @@ * folder and land on the shared-with-me view. */ import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; -import { listRootFolders } from '$lib/api/endpoints/folders'; +import { drives } from '$lib/stores/drives.svelte'; import type { User } from '$lib/api/types'; class SessionStore { @@ -41,20 +41,21 @@ class SessionStore { } /** - * Resolve the home folder (first entry of GET /api/folders). Externals - * (grant-only) have no home folder, so this is skipped for them. + * Resolve the caller's default personal drive's root folder — the landing + * point for `/files` and the `/` redirect. Externals (grant-only) have no + * personal drive, so this is skipped for them. + * + * Identifies the default via `default_for_user`, not folder name: users + * can rename "Personal" without breaking this lookup. */ async loadHomeFolder(): Promise { if (this.homeFolderId) return this.homeFolderId; if (this.isExternalUser) return null; - try { - const folders = await listRootFolders(); - if (folders.length > 0) { - this.homeFolderId = folders[0].id; - this.homeFolderName = folders[0].name; - } - } catch { - /* leave null — caller handles */ + await drives.load(); + const def = drives.findDefault(); + if (def) { + this.homeFolderId = def.root_folder_id; + this.homeFolderName = def.name; } return this.homeFolderId; } diff --git a/frontend/src/lib/styles/ported/breadcrumb.css b/frontend/src/lib/styles/ported/breadcrumb.css index 7f6e2237..e7e9b60b 100644 --- a/frontend/src/lib/styles/ported/breadcrumb.css +++ b/frontend/src/lib/styles/ported/breadcrumb.css @@ -47,12 +47,14 @@ user-select: none; } +/* First crumb (drive root) — icon sits inline with the drive name. The home + icon plays the role of the standalone "home" button in earlier designs; + here it visually fuses with the root crumb so the chain reads + "🏠 Personal > Documents" instead of "🏠 > Personal > Documents". */ .breadcrumb-home { display: inline-flex; align-items: center; - justify-content: center; - width: 24px; - height: 24px; + gap: 0.35em; border-radius: var(--radius-sm); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 5bb4a4b0..1ecc7e35 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,10 +1,18 @@ diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index c9d3a223..b2232ddb 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -44,6 +44,7 @@ import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; + import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -68,6 +69,17 @@ // /files → home root; /files/a/b → folder b inside a inside home. const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0)); + // First-crumb icon mirrors the drive at pathSegments[0]: `home` for the + // default-personal, `folder` for a secondary personal, `users` for a + // shared drive. Falls back to `home` while the drives list is loading + // or when the URL's leading segment isn't a known drive root (deep-link + // into a sub-folder bypasses drive identification — same limitation as + // the breadcrumb name resolution). + const rootIcon = $derived.by(() => { + const drive = drivesStore.findByRootFolderId(pathSegments[0] ?? null); + return drive ? driveIcon(drive) : 'home'; + }); + let listing = $state({ folders: [], files: [], favoriteIds: [], sharedIds: [] }); let crumbs = $state>([]); let currentId = $state(null); @@ -170,6 +182,21 @@ return; } const home = await session.loadHomeFolder(); + + // Canonicalize bare `/files` → `/files/` (or + // the default drive's root when there's no memory yet). Keeps the URL + // explicit, the breadcrumb populated, and the drive picker correctly + // highlighted. The DrivePicker writes `oxi-last-drive-root` on click. + if (pathSegments.length === 0) { + const last = + typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null; + const target = last ?? home; + if (target) { + await goto(`/files/${target}`, { replaceState: true }); + return; + } + } + const folderId = pathSegments.at(-1) ?? home; if (!folderId) { error = t('files.no_home', 'No home folder available.'); @@ -194,6 +221,8 @@ }, 100); // Breadcrumbs resolve independently so they never block the grid paint. + // Bare `/files` was canonicalized above to `/files/` so pathSegments + // is always non-empty here for internal users. void buildCrumbs(pathSegments).then((trail) => { if (seq === loadSeq) crumbs = trail; }); @@ -1277,26 +1306,27 @@ From b2ab938a11045f5ad8069e3bd7947d2619493fa3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 20 Jun 2026 01:44:31 +0200 Subject: [PATCH 3/7] feat(drive): add drive config menu --- .../src/lib/components/DrivePicker.svelte | 44 +++- frontend/src/lib/stores/drives.svelte.ts | 6 + .../routes/config/drive/[uuid]/+page.svelte | 247 ++++++++++++++++++ 3 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 frontend/src/routes/config/drive/[uuid]/+page.svelte diff --git a/frontend/src/lib/components/DrivePicker.svelte b/frontend/src/lib/components/DrivePicker.svelte index d15075fb..92e54f18 100644 --- a/frontend/src/lib/components/DrivePicker.svelte +++ b/frontend/src/lib/components/DrivePicker.svelte @@ -66,11 +66,10 @@ {#if drivesStore.loaded && drivesStore.drives.length > 0}
    {#each sortedDrives as d (d.id)} -
  • +
  • + onnavigate?.()} + > + + {#if pctUsed(d) !== null}
    d.root_folder_id === id) ?? null; } + + /** Drive whose own UUID matches `id`, or null. */ + findById(id: string | null | undefined): Drive | null { + if (!id) return null; + return this.drives.find((d) => d.id === id) ?? null; + } } export const drives = new DrivesStore(); diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte new file mode 100644 index 00000000..9bdc00fe --- /dev/null +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -0,0 +1,247 @@ + + +
    + {#if !drivesStore.loaded} +

    {t('common.loading', 'Loading…')}

    + {:else if !drive} +
    +

    {t('drive.not_found_title', 'Drive not found')}

    +

    + {t( + 'drive.not_found_body', + "This drive doesn't exist or you don't have access to it." + )} +

    + {t('drive.back_to_files', 'Back to Files')} +
    + {:else} +

    + + {drive.name} +

    + +
    +

    {t('drive.info', 'Drive info')}

    +
    +
    {t('drive.field.kind', 'Kind')}
    +
    {kindLabel}
    + + {#if drive.default_for_user} +
    {t('drive.field.default', 'Default')}
    +
    {t('drive.field.default_yes', 'This is your home drive')}
    + {/if} + +
    {t('drive.field.created', 'Created')}
    +
    {formatDate(drive.created_at)}
    + +
    {t('drive.field.updated', 'Last updated')}
    +
    {formatDate(drive.updated_at)}
    + +
    {t('drive.field.id', 'Identifier')}
    +
    {drive.id}
    +
    +
    + +
    +

    {t('drive.storage', 'Storage')}

    +
    +
    +
    {formatBytes(drive.used_bytes)}
    +
    {t('drive.used', 'Used')}
    +
    +
    +
    + {drive.quota_bytes && drive.quota_bytes > 0 ? formatBytes(drive.quota_bytes) : '∞'} +
    +
    {t('drive.quota', 'Quota')}
    +
    +
    +
    + {drive.quota_bytes && drive.quota_bytes > 0 ? `${Math.round(storagePct)}%` : '—'} +
    +
    {t('drive.usage', 'Usage')}
    +
    +
    + {#if drive.quota_bytes && drive.quota_bytes > 0} +
    +
    +
    + {/if} +
    + + {#if policyEntries.length > 0} +
    +

    {t('drive.policies', 'Policies')}

    +
    + {#each policyEntries as p (p.key)} +
    {policyLabel(p.key)}
    +
    {policyValueDisplay(p.value)}
    + {/each} +
    +
    + {/if} + {/if} +
    + + From 498cbbfbab92a179f7cb45fe1e9cf34baf9216cf Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 20 Jun 2026 01:49:45 +0200 Subject: [PATCH 4/7] chore(ai): move CLAUDE.md into AGENTS.md - more generic for agents - ensure safety check before any commit --- AGENTS.md | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 245 +------------------------------------------------- 2 files changed, 261 insertions(+), 244 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b00b8a2b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,260 @@ +# AGENTS.md + +This file provides guidance to coding agents (Claude Code, Codex, Cursor, Aider, …) working with this repository. Claude Code reads it via `@AGENTS.md` in `CLAUDE.md`. + +# Architecture + +This project is split into two parts: +- `/src` — OxiCloud Backend server in **Rust** +- `/frontend` — OxiCloud Frontend: a **SvelteKit (Svelte 5) + TypeScript** single-page app built with Vite + +> The original vanilla-JS/CSS frontend still lives in `/static` and is retained +> during the migration, but new frontend work goes in `/frontend`. Vite builds +> the SvelteKit app to `static-dist/`, which the Rust web layer serves in +> release. + +# Backend part + +## Backend Build & Dev Commands + +```bash +cargo build # Dev build +cargo build --release # Optimized release build +cargo run # Run server (port 8086) +cargo test --workspace # Run all tests (~208) +cargo test # Run a single test by name +cargo test --features test_utils # Run tests that use mockall mocks +cargo clippy -- -D warnings # Lint (zero warnings policy) +cargo fmt --all --check # Format check +cargo fmt --all # Auto-format +RUST_LOG=debug cargo run # Run with debug logging +cargo run --bin generate-openapi # Regenerate resources/gen/openapi.json +``` + +A `justfile` is available for common tasks (`just --list` to see all). Key recipes: `just check` (fmt + clippy), `just test`, `just openapi`. + +Requires **Rust 1.93+** (edition 2024) and **PostgreSQL 13+** (with `pg_trgm` and `ltree` extensions). + +Database setup: `docker compose up -d postgres` — schema is applied automatically via sqlx migrations on app startup. Migration files live in `migrations/`. For local dev, set `DATABASE_URL` in `.env` (see `example.env`). + +## Backend Pre-commit checks + +Always run these before committing, in this order: + +```bash +cargo fmt --all # Auto-format +cargo clippy --all-features --all-targets -- -D warnings # Lint (must pass with zero warnings) +``` + +CI enforces both — commits that fail either check will not merge. + +## Backend Pre-push checks + +When the change touches server code (anything under `src/`, `migrations/`, +`Cargo.toml`, or `tests/`), run the full suite locally before pushing — CI +is slower and a red CI run after a public push wastes maintainer attention: + +```bash +just check # cargo fmt --check + cargo clippy -D warnings +just test # cargo test --workspace +just test-integration # cargo test --tests with integration cfg +just api-test # Hurl API + WebDAV scenarios +``` + +Run them in that order — `just check` is fastest and catches the most +common issues first. Don't push if any step fails; investigate locally. + +## Backend Architecture + +Hexagonal / Clean Architecture with four layers. Dependencies point inward only. + +### Layer structure (`src/`) + +- **`domain/`** — Core business entities (`entities/`) and repository trait definitions (`repositories/`). Pure Rust, no framework dependencies. Entity types: `File`, `Folder`, `User`, `Calendar`, `CalendarEvent`, `Contact`, `Share`, `TrashedItem`, `Session`, `DeviceCode`, `AppPassword`. + +- **`application/`** — Use cases and orchestration. + - `ports/` — Trait definitions (inbound/outbound) for storage, auth, caching, compression, dedup, thumbnails, chunked uploads, CalDAV/CardDAV, etc. This is the hexagonal "ports" layer. + - `services/` — Use case implementations (`FileManagementService`, `FolderService`, `ShareService`, `TrashService`, `CalendarService`, `ContactService`, `SearchService`, `BatchOperations`, etc.). + - `adapters/` — CalDAV/CardDAV protocol adapters (iCalendar/vCard parsing). + - `dtos/` — Data transfer objects for API boundaries. + +- **`infrastructure/`** — Concrete implementations of ports. + - `repositories/pg/` — All PostgreSQL repository implementations (via `sqlx`). Uses `auth` schema for users/sessions, `storage` schema for files/folders/blobs (content-addressable dedup with ltree paths). + - `services/` — JWT, password hashing (Argon2), OIDC, compression, thumbnails, chunked uploads, WOPI discovery, WebDAV locking, file content caching (moka). + - `adapters/` — CalDAV/CardDAV storage adapters bridging domain traits to PG. + - `db.rs` — Dual connection pool setup (user pool + maintenance pool). + +- **`interfaces/`** — HTTP layer (Axum). + - `api/handlers/` — REST API handlers for files, folders, auth, admin, search, shares, WebDAV, CalDAV, CardDAV, WOPI, chunked uploads, batch operations. + - `api/routes.rs` — Route registration, splits protected vs public routes. + - `nextcloud/` — NextCloud-compatible API (WebDAV, OCS, login flow v2, trashbin) with Basic Auth middleware. + - `middleware/` — Auth (JWT validation), CSRF, rate limiting. + - `web/` — Static file serving. + +- **`common/`** — Cross-cutting concerns. + - `di.rs` — `AppServiceFactory` builds all services and produces `AppState` (the central DI container passed to Axum). This is the composition root. + - `config.rs` — `AppConfig::from_env()` loads all `OXICLOUD_*` env vars. + +### Key patterns + +- **DI via `AppState`**: All services are `Arc`-wrapped and assembled in `common/di.rs`. `AppState` is wrapped in `Arc` and passed as Axum state. Many services are `Option>` because they depend on features being enabled (auth, WOPI, trash, etc.). + +- **Content-addressable storage**: Files use BLAKE3 blob dedup. `storage.file_blobs` stores content; `storage.file_metadata` references blobs with ref-counting. See `file_blob_write_repository.rs` and `file_blob_read_repository.rs`. + +- **ltree paths**: Folder hierarchy uses PostgreSQL `ltree` for efficient subtree queries (recursive copies, moves, searches). + +- **Dual DB pools**: `DbPools` in `infrastructure/db.rs` separates user-facing queries from maintenance/background tasks to prevent starvation. + +- **Feature flags**: Major features (auth, trash, search, sharing, quotas) are toggled via `OXICLOUD_ENABLE_*` env vars in `FeaturesConfig`. + +- **UUID columns**: All ID columns use native PostgreSQL `UUID` type. SQL queries must use `::uuid` casts when passing string parameters to UUID columns. + +### Database schemas + +- `auth` schema: `users`, `sessions`, `app_passwords`, `device_codes`, `admin_settings` +- `storage` schema: `folders`, `file_metadata`, `file_blobs`, `trash`, `shares`, `favorites`, `recent_items`, `nextcloud_object_ids` +- `caldav` schema: `calendars`, `calendar_events` +- `carddav` schema: `address_books`, `contacts`, `contact_groups`, `contact_group_members` + +Schema definition: `migrations/` (sqlx migrations, applied on startup) + +### Protocol support + +The server exposes multiple protocol interfaces simultaneously: +- REST API under `/api/` +- WebDAV at `/webdav/` (RFC 4918) +- CalDAV at `/caldav/` +- CardDAV at `/carddav/` +- NextCloud-compatible API at `/remote.php/`, `/ocs/`, `/status.php` +- WOPI at `/wopi/` (when enabled) +- Well-known discovery at `/.well-known/caldav` and `/.well-known/carddav` + +### Test organization + +Tests are primarily `#[cfg(test)]` modules within source files (~36 files have inline tests). Dedicated test files exist at `*_test.rs` alongside their source. The `test_utils` feature flag enables `mockall` mock generation for trait-heavy testing. No separate `tests/` directory. + +### Code duplication + +Never duplicate logic across handlers or services. If the same behaviour is needed in more than one place, extract it into a shared function, method, or service before writing the second callsite. Preferred homes by layer: +- Cross-handler request logic → method on `CoreServices` or `AppState` (`common/di.rs`) +- Reusable infrastructure behaviour → method on the relevant service struct +- Shared port behaviour → default method on the trait + +### Authorization (AuthZ) + +**AuthZ is enforced exclusively in the application service layer, never in handlers.** All permission checks go through `AuthorizationEngine` (port: `application/ports/authorization_ports.rs`) via service methods named with the `_with_perms` suffix. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass `caller_id` into the service — they MUST NOT perform their own ownership/permission checks. The authentication middleware extracts the caller; the service decides if the action is allowed. + +This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation. + +### Audit logging for denials and rejections + +**Every permission denial or auth rejection MUST emit a structured audit log line before returning the error.** Without one, security-relevant outcomes are invisible to operators and incident response loses its primary signal. + +The convention: + +```rust +tracing::info!( + target: "audit", + event = ".", // e.g. "authz.denied", "auth.login_rejected", + // "magic_link.redemption_rejected", + // "user_profile.rejected" + reason = "", // stable machine-readable key for filtering + // (e.g. "bad_password", "expired", "no_visibility_path") + // …structured fields naming the actors / targets… + caller_id = %caller_id, // or subject_id, user_id, granted_by, etc. + target_id = %target_id, // or resource_id, subject_id, etc. + "👮🏻‍♂️ human-readable message: …", // helpful for live tailing, do not parse +); +``` + +Rules: + +- **`target: "audit"`** routes the line to the audit channel (separable from operational `oxicloud::*` debug noise). +- **`event`** uses the dotted form `.` and stays stable — log aggregators key off it. +- **`reason`** is a machine-readable enum-style key. Don't reword across releases. New denial cause → new `reason` value, never repurpose an existing one. +- **Structured fields** carry every actor/target involved (`caller_id`, `target_id`, `resource_id`, `subject_id`, role, is_external flag, etc.). Request id and client IP come from the request-scope span automatically — don't duplicate them. +- **Anti-enumeration is preserved.** Returning `NotFound` to the caller while logging the real reason internally is the canonical pattern (e.g. `user_profile.rejected` with `reason = "external_caller_no_relationship"` returns 404, never 403). Operators see the truth; the attacker sees the same response shape regardless of whether the user exists. +- **Success paths stay quiet** by default — every authorized request would otherwise flood the log. Use `tracing::debug!` with `target: "oxicloud::authz"` (or similar) when a low-volume granted-trace helps debugging. Reserve `tracing::info!(target: "audit", …)` for outcomes worth surfacing in security reviews. + +Canonical examples to mirror: `authz.denied` in `application/ports/authorization_ports.rs::require`, `auth.login_rejected` and `magic_link.redemption_rejected` and `user_profile.rejected` in `application/services/auth_application_service.rs`. + +# Frontend part + +The frontend is a **SvelteKit** single-page app (Svelte 5 + TypeScript, Vite, +`adapter-static`) under `frontend/`. Vite builds it to `static-dist/`, which the +Rust web layer serves in release (unmatched client routes fall back to the SPA +shell); `PROFILE=dev` serves the unbuilt source. The legacy vanilla frontend in +`static/` is retained for now but is **not** where new work goes. + +## Frontend Build & Dev Commands + +Run from `frontend/` (or via the `fe-*` justfile recipes from the repo root): + +```bash +npm ci # install deps (just fe-install) +npm run dev # Vite dev server + HMR (just fe-dev) — backend must run on :8086 +npm run build # build the SPA → static-dist/ (just fe-build) +npm run check # svelte-check + ESLint + Stylelint + Prettier (just fe-check) +npm run test:unit # Vitest (just fe-test) +npm run format # prettier --write . +``` + +`just dev` runs the backend and the Vite dev server together. CI uses **Node 24**; Node 22+ works locally. + +## Frontend Architecture (`frontend/src/`) + +- `routes/` — SvelteKit pages (`+page.svelte`, `+layout.svelte`), one folder per route (`files/[...path]`, `photos`, `shared`, `trash`, `admin`, `s/[token]`, …). +- `lib/components/` — reusable Svelte components (`AppShell`, `PhotoLightbox`, `ShareDialog`, `Modal`, …). +- `lib/api/` — HTTP layer: `client.ts` (`apiFetch`/`apiJson`), `csrf.ts` (`getCsrfHeaders`), `types.ts` (API DTO types — map the backend here), and `endpoints/*.ts` (one module per area: files, folders, photos, people, grants, …). +- `lib/stores/` — global reactive state as `*.svelte.ts` rune stores (`session`, `ui`, `theme`, `dialogs`). +- `lib/composables/` — reusable rune logic (`useSelection`, `useOwnerCache`). +- `lib/i18n/` — bespoke reactive i18n; `t(key, [params], fallback)` reads `frontend/static/locales/*.json` (16 locales) with `{{param}}` interpolation and an English fallback. +- `lib/icons/` — `Icon.svelte` + a generated Font Awesome `registry.ts`. +- `lib/utils/`, `lib/vendor/` — shared helpers and minimal typings/loaders for vendored libs. +- `lib/styles/` — global CSS (`app.css`, `base/`, `ported/`). +- `static/` — served at the web root: `locales/`, `vendors/` (maplibre-gl, pmtiles, hash-wasm), `workers/` (deltaWorker), optional `basemaps/`. + +## Code conventions + +### Svelte / TypeScript + +- **Svelte 5 runes** — `$state`, `$derived`, `$props`, `$effect`, `$bindable`. No legacy `export let` for new components. +- **TypeScript everywhere** (`lang="ts"` in components). **No `any`** — `typescript-eslint` recommended is enforced; prefer precise types, `unknown` + narrowing, or a minimal declared interface for an untyped global (see `lib/vendor/maplibre.ts`). +- ES Modules; `camelCase` for variables/functions, `PascalCase` for components/classes; `const`/`let`, never `var`. +- API DTO shapes live in `lib/api/types.ts`; call the backend through `lib/api/endpoints/*` — don't bare-`fetch` `/api` from components. + +### Code duplication + +Never duplicate logic across modules/components. Extract shared behaviour: +- DOM/UI helpers → `lib/utils/` +- API wrappers → the relevant `lib/api/endpoints/*` module +- Cross-component state/logic → a `lib/stores/*.svelte.ts` store or a `lib/composables/*` +- Shared markup → a component (e.g. `PhotoLightbox` is shared by the photos grid, People and Places) + +### CSS + +- BEM methodology for class names (`.block__element--modifier`). +- Component styles live in the component's scoped `