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 ` diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index d2288b6f..5f4e3935 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -54,6 +54,7 @@ import EmptyState from '$lib/components/EmptyState.svelte'; import SkeletonList from '$lib/components/SkeletonList.svelte'; import ListToolbar from '$lib/components/ListToolbar.svelte'; + import UserVignette from '$lib/components/UserVignette.svelte'; import VirtualList from '$lib/components/VirtualList.svelte'; import { t } from '$lib/i18n/index.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; @@ -329,12 +330,11 @@ {#if showOwner}
- - - {entry.ownerName ?? entry.ownerId ?? ''} - + {#if entry.ownerId} + + {:else} + {entry.ownerName ?? '—'} + {/if}
{/if} {#if showPath}
{entry.path ?? ''}
{/if} @@ -570,28 +570,7 @@ min-width: 0; } - .rl-vignette { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-width: 0; - } - - .rl-vignette__avatar { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 50%; - background: var(--color-accent-bg-sm); - color: var(--color-accent-text); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - flex: none; - } - - .rl-vignette__name { + .owner-cell__placeholder { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/frontend/src/lib/stores/drives.svelte.ts b/frontend/src/lib/stores/drives.svelte.ts new file mode 100644 index 00000000..4e76ac62 --- /dev/null +++ b/frontend/src/lib/stores/drives.svelte.ts @@ -0,0 +1,68 @@ +/** + * 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; + } + + /** 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(); + +/** + * 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/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte new file mode 100644 index 00000000..fd41a13d --- /dev/null +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -0,0 +1,250 @@ + + +
+ {#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} +
+ + diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index df2f5f80..fe750825 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -60,7 +60,7 @@ ); const groupBys: GroupByDef[] = [ - { key: '', label: t('files.name', 'Name'), orderBy: 'name' }, + { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, { key: 'owner', label: t('groupby.owner', 'Owner'), 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 @@ diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index 32ac1585..883c3527 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -61,7 +61,7 @@ ); const groupBys: GroupByDef[] = [ - { key: '', label: t('files.name', 'Name'), orderBy: 'name' }, + { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, { key: 'owner', label: t('groupby.owner', 'Owner'), diff --git a/frontend/src/routes/shared-with-me/+page.svelte b/frontend/src/routes/shared-with-me/+page.svelte index 2a49b1dd..a1820b6c 100644 --- a/frontend/src/routes/shared-with-me/+page.svelte +++ b/frontend/src/routes/shared-with-me/+page.svelte @@ -2,42 +2,99 @@ import { errorMessage } from '$lib/utils/errors'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; + import { dateBucket, resolveOwnerName, typeLabel } from '$lib/api/endpoints/favorites'; import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants'; import type { FileItem } from '$lib/api/types'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; - import ResourceList, { type ResourceEntry } from '$lib/components/ResourceList.svelte'; + import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte'; + import ResourceList, { + type GroupByDef, + type ResourceEntry + } from '$lib/components/ResourceList.svelte'; import { t } from '$lib/i18n/index.svelte'; let raw = $state([]); let cursor = $state(undefined); let loading = $state(false); let error = $state(null); + let groupBy = $state(''); + let reversed = $state(false); + + const sharers = useOwnerCache(resolveOwnerName); const byId = $derived(new Map(raw.map((it) => [it.resource.id, it]))); const entries = $derived( - raw.map( - (it): ResourceEntry => ({ + raw.map((it): ResourceEntry => { + const isFile = it.resource_type === 'file'; + return { id: it.resource.id, name: it.resource.name, kind: it.resource_type, iconClass: it.resource.icon_class, - path: it.granted_by - ? t('shared_with_me.from', { who: it.granted_by }, 'Shared by {{who}}') - : it.resource.path, - size: it.resource_type === 'file' ? (it.resource as FileItem).size : null, - date: it.granted_at - }) - ) + // The sharer becomes the "owner" surface — ResourceList renders + // `` (avatar / name / external badge), + // resolved lazily via `/api/users/{id}`. `path` keeps the + // resource's real location so the row still shows where it + // lives, not a translated string. + ownerId: it.granted_by ?? null, + ownerName: sharers.name(it.granted_by), + path: it.resource.path, + size: isFile ? (it.resource as FileItem).size : null, + date: it.granted_at, + category: isFile ? it.resource.category : 'Folder' + }; + }) ); - async function load(reset = false) { + // Server-supported sort_by values (see grant_handler.rs:615): + // granted_at, granted_by, name, type + // The first entry (no `bucketOf`) renders a flat list sorted by name — + // the A-Z icon flags it as "sort, not group" so users don't read it as + // a real bucket dimension. The remaining three are honest groupings and + // get the default layer-group icon. + const groupBys: GroupByDef[] = [ + { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, + { + key: 'sharedBy', + label: t('groupby.sharedBy', 'Shared by'), + orderBy: 'granted_by', + bucketOf: (e) => e.ownerId ?? null, + labelOf: (id) => sharers.label(id) + }, + { + key: 'type', + label: t('groupby.type', 'Type'), + orderBy: 'type', + bucketOf: (e) => e.category ?? 'other', + labelOf: (k) => typeLabel(k) + }, + { + key: 'sharedAt', + label: t('groupby.sharedAt', 'Shared date'), + orderBy: 'granted_at', + bucketOf: (e) => dateBucket(e.date) + } + ]; + + function orderByForGroup(): string { + return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'granted_at'; + } + + async function load(reset = false, orderBy = 'granted_at', rev = reversed) { loading = true; error = null; try { - const page = await fetchSharedWithMe({ cursor: reset ? undefined : cursor }); + const page = await fetchSharedWithMe({ + cursor: reset ? undefined : cursor, + orderBy, + reverse: rev + }); raw = reset ? page.items : [...raw, ...page.items]; cursor = page.next_cursor; + // Warm the sharer-name cache so the "Shared by" group headers + // show real names instead of UUIDs. + void sharers.resolve(page.items.map((i) => i.granted_by).filter((id): id is string => !!id)); } catch (e) { error = errorMessage(e); } finally { @@ -79,8 +136,16 @@ {error} emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')} hasMore={!!cursor} - onloadmore={() => load(false)} + showOwner={true} + {groupBys} + bind:groupBy + bind:reversed + onloadmore={() => load(false, orderByForGroup())} onopen={open} + onreload={(orderBy, rev) => { + cursor = undefined; + load(true, orderBy, rev); + }} /> {#if fileViewer.component} diff --git a/frontend/src/routes/shared/+page.svelte b/frontend/src/routes/shared/+page.svelte index d333ecb7..9f72129a 100644 --- a/frontend/src/routes/shared/+page.svelte +++ b/frontend/src/routes/shared/+page.svelte @@ -22,6 +22,7 @@ import Icon from '$lib/icons/Icon.svelte'; import ListToolbar from '$lib/components/ListToolbar.svelte'; import ShareDialog from '$lib/components/ShareDialog.svelte'; + import UserVignette from '$lib/components/UserVignette.svelte'; import { t } from '$lib/i18n/index.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { iconNameFromClass } from '$lib/utils/display'; @@ -388,16 +389,21 @@ {t('myshares.editSharing', 'Edit sharing')} + {:else if lane.header.kind === 'user'} + + + {:else} {laneTitle(lane.header)} @@ -416,8 +422,10 @@ {item.resource.name} {:else if grant.subject_type === 'user'} - - {resolveLabel('user', grant.subject_id)} + {:else if grant.subject_type === 'group'} {resolveLabel('group', grant.subject_id)} 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({