Merge pull request #507 from EdouardVanbelle/feat/drive-d1
This commit is contained in:
@@ -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 <test_name> # 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<Arc<T>>` 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 = "<domain>.<outcome>", // e.g. "authz.denied", "auth.login_rejected",
|
||||
// "magic_link.redemption_rejected",
|
||||
// "user_profile.rejected"
|
||||
reason = "<short_key>", // 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 `<domain>.<verb_past_tense>` 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 `<style>`; cross-cutting tokens/styles in `lib/styles/`.
|
||||
- **All colors must use `var(--*)`** — no raw hex, rgb, or named colors (Stylelint enforces `function-disallowed-list`); define tokens in `lib/styles/base/variables.css`.
|
||||
- Mobile-first: media queries expand, they don't restrict.
|
||||
- Dark mode keys off `<html data-color-scheme="dark">`.
|
||||
|
||||
## Frontend Pre-commit checks
|
||||
|
||||
Always run from `frontend/` before committing:
|
||||
|
||||
```bash
|
||||
npm run check # svelte-kit sync && svelte-check && eslint . && stylelint "src/**/*.{css,svelte}" && prettier --check .
|
||||
npm run test:unit # Vitest
|
||||
```
|
||||
|
||||
CI runs the same `npm run check` (plus Vitest) — commits that fail will not merge.
|
||||
|
||||
# What agents must NOT do
|
||||
- Edit `Cargo.lock` or `frontend/package-lock.json` by hand
|
||||
- Introduce a different JS framework (React, Vue, etc.) — the frontend is SvelteKit/Svelte 5
|
||||
- Add a heavy runtime npm dependency without discussion — prefer vendoring + lazy-loading under `frontend/static/vendors/` (see maplibre-gl / pmtiles)
|
||||
- Use `any` in TypeScript
|
||||
- Leave debug `console.log` statements in code
|
||||
- Use raw color values in CSS — always use CSS custom properties
|
||||
- Commit without passing all linters (`npm run check` for the frontend; `cargo fmt` + `cargo clippy` for the backend)
|
||||
@@ -1,244 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
# 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 <test_name> # 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 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<Arc<T>>` 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 = "<domain>.<outcome>", // e.g. "authz.denied", "auth.login_rejected",
|
||||
// "magic_link.redemption_rejected",
|
||||
// "user_profile.rejected"
|
||||
reason = "<short_key>", // 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 `<domain>.<verb_past_tense>` 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 `<style>`; cross-cutting tokens/styles in `lib/styles/`.
|
||||
- **All colors must use `var(--*)`** — no raw hex, rgb, or named colors (Stylelint enforces `function-disallowed-list`); define tokens in `lib/styles/base/variables.css`.
|
||||
- Mobile-first: media queries expand, they don't restrict.
|
||||
- Dark mode keys off `<html data-color-scheme="dark">`.
|
||||
|
||||
## Frontend Pre-commit checks
|
||||
|
||||
Always run from `frontend/` before committing:
|
||||
|
||||
```bash
|
||||
npm run check # svelte-kit sync && svelte-check && eslint . && stylelint "src/**/*.{css,svelte}" && prettier --check .
|
||||
npm run test:unit # Vitest
|
||||
```
|
||||
|
||||
CI runs the same `npm run check` (plus Vitest) — commits that fail will not merge.
|
||||
|
||||
# What Claude must NOT do
|
||||
- Edit `Cargo.lock` or `frontend/package-lock.json` by hand
|
||||
- Introduce a different JS framework (React, Vue, etc.) — the frontend is SvelteKit/Svelte 5
|
||||
- Add a heavy runtime npm dependency without discussion — prefer vendoring + lazy-loading under `frontend/static/vendors/` (see maplibre-gl / pmtiles)
|
||||
- Use `any` in TypeScript
|
||||
- Leave debug `console.log` statements in code
|
||||
- Use raw color values in CSS — always use CSS custom properties
|
||||
- Commit without passing all linters (`npm run check` for the frontend; `cargo fmt` + `cargo clippy` for the backend)
|
||||
@AGENTS.md
|
||||
|
||||
+28
-9
@@ -618,9 +618,20 @@ accommodates them without schema migration)
|
||||
|
||||
| URL | Resolves to |
|
||||
|---|---|
|
||||
| `/` | Redirect to the caller's personal drive UUID |
|
||||
| `/drive/<drive-uuid>` | Drive root view |
|
||||
| `/drive/<drive-uuid>/<folder-id>` | Folder inside the drive |
|
||||
| `/` (internal user) | Redirect to `/files/<root-folder-id>` 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-id>` | 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/<a>/<b>/<c>` | 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-uuid>` | 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/<user-uuid>` | (Future) User configuration — same shape so the `/config/<resource-type>/<uuid>` pattern is consistent across resources |
|
||||
| `/drive/<...>` | **Reserved** for future drive-scoped surfaces that aren't covered by `/files/` or `/config/drive/` |
|
||||
|
||||
**Why `/files/<folder-id>` and not `/drive/<folder-id>`**: the existing files browser already takes a chain of folder UUIDs (`/files/<id1>/<id2>/<id3>`), 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.
|
||||
|
||||
**Why `/config/` is a separate top-level segment**, not `/drive/<uuid>/settings`: the URL prefix encodes intent ("we are configuring something"), not just resource location. Future configuration surfaces (`/config/user/<id>`, `/config/group/<id>`, `/config/share/<id>`) compose cleanly under the same prefix. It also avoids the singular-vs-plural ambiguity (`/drive/<X>` vs `/drives/<X>/settings`) that's easy to typo and hard to grep for.
|
||||
|
||||
#### Native WebDAV (`/webdav/...`)
|
||||
|
||||
@@ -1371,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/<uuid>/<folder-id>` frontend routes, default-drive redirect from `/`. WebDAV path dispatcher recognising `drives/<uuid>` as the drive-explicit prefix on both `/webdav/` and `/remote.php/dav/`. | Medium |
|
||||
| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/files/<folder-id>` reused for cross-drive navigation (existing route — drive context recovered server-side from `folders.drive_id`), `/config/drive/<drive-uuid>` new route for drive admin. `/` redirects to `/files/<root-folder-id>` of the caller's default personal drive (internal users) or `/shared-with-me` (external users with no personal drive). WebDAV path dispatcher recognising `drives/<uuid>` 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 |
|
||||
@@ -1652,13 +1663,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/<default-uuid>`. 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
|
||||
`/files/<default-personal-drive-root-folder-id>`. Click sidebar
|
||||
drive switcher → URL updates to `/files/<picked-drive-root-folder-id>`,
|
||||
listing reloads. Drive picker shows all of the caller's drives
|
||||
(default first), each with its quota usage. Open
|
||||
`/config/drive/<personal-drive-uuid>` → owner sees member list +
|
||||
policies. Open `/config/drive/<shared-drive-uuid>` as a viewer →
|
||||
read-only "Drive info" surface.
|
||||
- **Manual smoke (external user)**: open `/`, get redirected to
|
||||
`/shared-with-me` (no `/files/<id>` 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
|
||||
|
||||
@@ -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<Drive[]> {
|
||||
return apiJson<Drive[]>('/api/drives', { credentials: 'same-origin' });
|
||||
}
|
||||
@@ -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<FolderItem[]> {
|
||||
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export async function getFolder(id: string): Promise<FolderItem> {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||
import type { FileItem, FolderItem } from '$lib/api/types';
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.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 +282,9 @@
|
||||
<Icon name={link.icon} />
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
{#if link.href === '/files' && !session.isExternalUser}
|
||||
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import type { Drive } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
interface Props {
|
||||
onnavigate?: () => void;
|
||||
}
|
||||
let { onnavigate }: Props = $props();
|
||||
|
||||
// URL of `/files/<first>/<second>/...` — the first segment identifies the
|
||||
// drive root the user navigated through. We use it to highlight the active
|
||||
// drive in the picker. Deep-linking to a descendant folder of a non-default
|
||||
// drive bypasses this highlight (the URL's leading segment is the deep
|
||||
// folder id, not the drive root); that's acceptable — D2 can refine this
|
||||
// by resolving `folder.drive_id` server-side when the gap matters.
|
||||
const firstFilesSegment = $derived.by(() => {
|
||||
const m = /^\/files\/([^/]+)/.exec(page.url.pathname);
|
||||
return m ? m[1] : null;
|
||||
});
|
||||
|
||||
// Sorting: default-personal drive first, then secondary personals, then
|
||||
// shared. Within each group, by name. Picker UX puts "home" at the top so
|
||||
// the common case is one click.
|
||||
const sortedDrives = $derived(
|
||||
[...drivesStore.drives].sort((a, b) => {
|
||||
const rank = (d: Drive) => (d.default_for_user ? 0 : d.kind === 'personal' ? 1 : 2);
|
||||
const r = rank(a) - rank(b);
|
||||
return r !== 0 ? r : a.name.localeCompare(b.name);
|
||||
})
|
||||
);
|
||||
|
||||
function isActive(d: Drive): boolean {
|
||||
return firstFilesSegment === d.root_folder_id;
|
||||
}
|
||||
|
||||
function pctUsed(d: Drive): number | null {
|
||||
if (!d.quota_bytes || d.quota_bytes <= 0) return null;
|
||||
return Math.min(100, (d.used_bytes / d.quota_bytes) * 100);
|
||||
}
|
||||
|
||||
async function open(d: Drive) {
|
||||
onnavigate?.();
|
||||
// Remember which drive root the user picked so a later click on the
|
||||
// sidebar "Files" link (which goes to bare `/files`) returns here
|
||||
// instead of always bouncing to the default drive.
|
||||
try {
|
||||
localStorage.setItem('oxi-last-drive-root', d.root_folder_id);
|
||||
} catch {
|
||||
/* private mode / quota — silently fall back to default */
|
||||
}
|
||||
await goto(`/files/${d.root_folder_id}`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void drivesStore.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if drivesStore.loaded && drivesStore.drives.length > 0}
|
||||
<ul class="drive-picker" aria-label={t('drive.picker', 'Drives')}>
|
||||
{#each sortedDrives as d (d.id)}
|
||||
<li class="drive-picker__row" class:drive-picker__row--active={isActive(d)}>
|
||||
<button
|
||||
type="button"
|
||||
class="drive-picker__item"
|
||||
onclick={() => open(d)}
|
||||
title={pctUsed(d) !== null
|
||||
? `${d.name} — ${formatBytes(d.used_bytes)} / ${formatBytes(d.quota_bytes ?? 0)}`
|
||||
: `${d.name} — ${formatBytes(d.used_bytes)}`}
|
||||
>
|
||||
<Icon name={driveIcon(d)} />
|
||||
<span class="drive-picker__name">{d.name}</span>
|
||||
</button>
|
||||
<a
|
||||
href={`/config/drive/${d.id}`}
|
||||
class="drive-picker__settings"
|
||||
title={t('drive.settings_aria', 'Drive settings')}
|
||||
aria-label={t('drive.settings_aria', 'Drive settings')}
|
||||
onclick={() => onnavigate?.()}
|
||||
>
|
||||
<Icon name="cog" />
|
||||
</a>
|
||||
{#if pctUsed(d) !== null}
|
||||
<div
|
||||
class="drive-picker__bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pctUsed(d) ?? 0)}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-label={t('drive.usage_aria', 'Drive usage')}
|
||||
>
|
||||
<div class="drive-picker__bar-fill" style:width="{pctUsed(d)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Rendered as nested children under the "Files" nav item — no own border or
|
||||
title; visual nesting via left padding aligned to the parent icon. */
|
||||
.drive-picker {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Each row is a small grid: drive button + gear icon on the top line,
|
||||
optional usage bar spanning both columns below. */
|
||||
.drive-picker__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drive-picker__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.5rem 0.3rem 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-sidebar-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drive-picker__item:hover {
|
||||
background: var(--color-sidebar-hover-bg);
|
||||
color: var(--color-sidebar-text-hover);
|
||||
}
|
||||
|
||||
/* Active drive: just a text-color shift. The parent "Files" row already
|
||||
carries the orange-tinted active bg — anything more on the child
|
||||
crowds the sidebar. Typography alone reads as "you are here" since
|
||||
only one drive can be active at a time. */
|
||||
.drive-picker__row--active .drive-picker__item {
|
||||
color: var(--color-sidebar-text-active);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.drive-picker__settings {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
color: var(--color-sidebar-text);
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.drive-picker__settings:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-sidebar-text-hover);
|
||||
}
|
||||
|
||||
.drive-picker__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mini usage bar tucked under the row, indented to align with the name,
|
||||
spans both grid columns so the gear icon sits above its right edge. */
|
||||
.drive-picker__bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 3px;
|
||||
background: var(--color-sidebar-storage-bar);
|
||||
border-radius: 1.5px;
|
||||
margin: 0 1rem 0.25rem 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drive-picker__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
</style>
|
||||
@@ -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 @@
|
||||
</div>
|
||||
{#if showOwner}
|
||||
<div class="owner-cell">
|
||||
<span class="rl-vignette">
|
||||
<span class="rl-vignette__avatar" aria-hidden="true"
|
||||
>{(entry.ownerName ?? '?').slice(0, 1).toUpperCase()}</span
|
||||
>
|
||||
<span class="rl-vignette__name">{entry.ownerName ?? entry.ownerId ?? ''}</span>
|
||||
</span>
|
||||
{#if entry.ownerId}
|
||||
<UserVignette userId={entry.ownerId} fallbackLabel={entry.ownerName ?? undefined} />
|
||||
{:else}
|
||||
<span class="owner-cell__placeholder">{entry.ownerName ?? '—'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showPath}<div class="path-cell">{entry.path ?? ''}</div>{/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;
|
||||
|
||||
@@ -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<Drive[]>([]);
|
||||
loaded = $state(false);
|
||||
private inflight: Promise<Drive[]> | null = null;
|
||||
|
||||
async load(): Promise<Drive[]> {
|
||||
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';
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
// The app root redirects to the primary files view.
|
||||
// The app root redirects by user kind:
|
||||
// - external users (magic-link / OIDC-only / OCM recipients) have no
|
||||
// personal drive, so they land on Shared-with-me;
|
||||
// - internal users go to the files browser, which resolves the default
|
||||
// personal drive's root folder via `session.loadHomeFolder()` (post-D0
|
||||
// this reads `GET /api/drives` and picks the row whose
|
||||
// `default_for_user` matches the caller).
|
||||
onMount(() => {
|
||||
void goto('/files', { replaceState: true });
|
||||
const target = session.isExternalUser ? '/shared-with-me' : '/files';
|
||||
void goto(target, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import type { Drive } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
import { formatDate } from '$lib/utils/display';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
const uuid = $derived(page.params.uuid ?? '');
|
||||
const drive = $derived<Drive | null>(drivesStore.findById(uuid));
|
||||
|
||||
const kindLabel = $derived.by(() => {
|
||||
if (!drive) return '';
|
||||
return drive.kind === 'shared'
|
||||
? t('drive.kind_shared', 'Shared drive')
|
||||
: t('drive.kind_personal', 'Personal drive');
|
||||
});
|
||||
|
||||
const storagePct = $derived.by(() => {
|
||||
if (!drive || !drive.quota_bytes || drive.quota_bytes <= 0) return 0;
|
||||
return Math.min(100, (drive.used_bytes / drive.quota_bytes) * 100);
|
||||
});
|
||||
|
||||
const policyEntries = $derived.by(() => {
|
||||
if (!drive) return [];
|
||||
return Object.entries(drive.policies).map(([key, value]) => ({ key, value }));
|
||||
});
|
||||
|
||||
function policyLabel(key: string): string {
|
||||
// Known policy keys get a friendlier translated label; unknown keys
|
||||
// surface verbatim so operators still see them (forward-compat).
|
||||
switch (key) {
|
||||
case 'forbid_public_links':
|
||||
return t('drive.policy.forbid_public_links', 'Forbid public links');
|
||||
case 'forbid_external_sharing':
|
||||
return t('drive.policy.forbid_external_sharing', 'Forbid external sharing');
|
||||
case 'forbid_sharing':
|
||||
return t('drive.policy.forbid_sharing', 'Forbid sharing');
|
||||
case 'forbid_cross_drive_move':
|
||||
return t('drive.policy.forbid_cross_drive_move', 'Forbid cross-drive move');
|
||||
case 'include_in_photo_index':
|
||||
return t('drive.policy.include_in_photo_index', 'Include in photo index');
|
||||
case 'forbid_music_index':
|
||||
return t('drive.policy.forbid_music_index', 'Forbid music index');
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function policyValueDisplay(value: unknown): string {
|
||||
if (value === true) return t('drive.policy.on', 'On');
|
||||
if (value === false) return t('drive.policy.off', 'Off');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void drivesStore.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="config-drive">
|
||||
{#if !drivesStore.loaded}
|
||||
<p class="muted">{t('common.loading', 'Loading…')}</p>
|
||||
{:else if !drive}
|
||||
<div class="card">
|
||||
<h2>{t('drive.not_found_title', 'Drive not found')}</h2>
|
||||
<p class="muted">
|
||||
{t('drive.not_found_body', "This drive doesn't exist or you don't have access to it.")}
|
||||
</p>
|
||||
<a class="link" href="/files">{t('drive.back_to_files', 'Back to Files')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>
|
||||
<Icon name={driveIcon(drive)} />
|
||||
{drive.name}
|
||||
</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2><Icon name="info-circle" /> {t('drive.info', 'Drive info')}</h2>
|
||||
<dl class="info-grid">
|
||||
<dt>{t('drive.field.kind', 'Kind')}</dt>
|
||||
<dd>{kindLabel}</dd>
|
||||
|
||||
{#if drive.default_for_user}
|
||||
<dt>{t('drive.field.default', 'Default')}</dt>
|
||||
<dd>{t('drive.field.default_yes', 'This is your home drive')}</dd>
|
||||
{/if}
|
||||
|
||||
<dt>{t('drive.field.created', 'Created')}</dt>
|
||||
<dd>{formatDate(drive.created_at)}</dd>
|
||||
|
||||
<dt>{t('drive.field.updated', 'Last updated')}</dt>
|
||||
<dd>{formatDate(drive.updated_at)}</dd>
|
||||
|
||||
<dt>{t('drive.field.id', 'Identifier')}</dt>
|
||||
<dd class="mono">{drive.id}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><Icon name="hdd" /> {t('drive.storage', 'Storage')}</h2>
|
||||
<div class="storage-row">
|
||||
<div class="storage-stat">
|
||||
<div class="storage-stat__value">{formatBytes(drive.used_bytes)}</div>
|
||||
<div class="storage-stat__label">{t('drive.used', 'Used')}</div>
|
||||
</div>
|
||||
<div class="storage-stat">
|
||||
<div class="storage-stat__value">
|
||||
{drive.quota_bytes && drive.quota_bytes > 0 ? formatBytes(drive.quota_bytes) : '∞'}
|
||||
</div>
|
||||
<div class="storage-stat__label">{t('drive.quota', 'Quota')}</div>
|
||||
</div>
|
||||
<div class="storage-stat">
|
||||
<div class="storage-stat__value">
|
||||
{drive.quota_bytes && drive.quota_bytes > 0 ? `${Math.round(storagePct)}%` : '—'}
|
||||
</div>
|
||||
<div class="storage-stat__label">{t('drive.usage', 'Usage')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if drive.quota_bytes && drive.quota_bytes > 0}
|
||||
<div
|
||||
class="bar"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={Math.round(storagePct)}
|
||||
>
|
||||
<div class="bar__fill" style:width="{storagePct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if policyEntries.length > 0}
|
||||
<div class="card">
|
||||
<h2><Icon name="shield-alt" /> {t('drive.policies', 'Policies')}</h2>
|
||||
<dl class="info-grid">
|
||||
{#each policyEntries as p (p.key)}
|
||||
<dt>{policyLabel(p.key)}</dt>
|
||||
<dd>{policyValueDisplay(p.value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.config-drive {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.config-drive h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-grid dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-grid dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.storage-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.storage-stat__value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.storage-stat__label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar__fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
|
||||
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||
let currentId = $state<string | null>(null);
|
||||
@@ -170,6 +182,21 @@
|
||||
return;
|
||||
}
|
||||
const home = await session.loadHomeFolder();
|
||||
|
||||
// Canonicalize bare `/files` → `/files/<last-chosen-drive-root>` (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/<id>` so pathSegments
|
||||
// is always non-empty here for internal users.
|
||||
void buildCrumbs(pathSegments).then((trail) => {
|
||||
if (seq === loadSeq) crumbs = trail;
|
||||
});
|
||||
@@ -1277,26 +1306,27 @@
|
||||
</ListToolbar>
|
||||
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a
|
||||
href="/files"
|
||||
class="breadcrumb-item breadcrumb-home breadcrumb-link"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondrop={(e) => session.homeFolderId && onCrumbDrop(e, session.homeFolderId)}
|
||||
>
|
||||
<Icon name="home" />
|
||||
</a>
|
||||
{#each crumbs as c, i (c.id)}
|
||||
<span class="breadcrumb-separator">></span>
|
||||
{#if i > 0}
|
||||
<span class="breadcrumb-separator">></span>
|
||||
{/if}
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
|
||||
<span class="breadcrumb-item breadcrumb-current" class:breadcrumb-home={i === 0}>
|
||||
{#if i === 0}<Icon name={rootIcon} />{/if}
|
||||
{c.name}
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
href={crumbHref(i)}
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:breadcrumb-home={i === 0}
|
||||
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondrop={(e) => onCrumbDrop(e, c.id)}>{c.name}</a
|
||||
ondrop={(e) => onCrumbDrop(e, c.id)}
|
||||
>
|
||||
{#if i === 0}<Icon name={rootIcon} />{/if}
|
||||
{c.name}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<IncomingGrantItem[]>([]);
|
||||
let cursor = $state<string | undefined>(undefined);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let groupBy = $state<string>('');
|
||||
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
|
||||
// `<UserVignette userId>` (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}
|
||||
|
||||
@@ -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 @@
|
||||
<Icon name="pencil-alt" />
|
||||
{t('myshares.editSharing', 'Edit sharing')}
|
||||
</button>
|
||||
{:else if lane.header.kind === 'user'}
|
||||
<span class="ms-lane__subject">
|
||||
<UserVignette
|
||||
userId={lane.header.id}
|
||||
fallbackLabel={resolveLabel('user', lane.header.id)}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="ms-lane__subject">
|
||||
<Icon
|
||||
name={lane.header.kind === 'user'
|
||||
? 'user'
|
||||
: lane.header.kind === 'group'
|
||||
? 'user-group'
|
||||
: lane.header.kind === 'linkPassword'
|
||||
? 'lock'
|
||||
: 'link'}
|
||||
name={lane.header.kind === 'group'
|
||||
? 'user-group'
|
||||
: lane.header.kind === 'linkPassword'
|
||||
? 'lock'
|
||||
: 'link'}
|
||||
/>
|
||||
<span class="ms-lane__name">{laneTitle(lane.header)}</span>
|
||||
</span>
|
||||
@@ -416,8 +422,10 @@
|
||||
<span class="ms-row__name">{item.resource.name}</span>
|
||||
</button>
|
||||
{:else if grant.subject_type === 'user'}
|
||||
<Icon name="user" />
|
||||
<span class="ms-row__name">{resolveLabel('user', grant.subject_id)}</span>
|
||||
<UserVignette
|
||||
userId={grant.subject_id}
|
||||
fallbackLabel={resolveLabel('user', grant.subject_id)}
|
||||
/>
|
||||
{:else if grant.subject_type === 'group'}
|
||||
<Icon name="user-group" />
|
||||
<span class="ms-row__name">{resolveLabel('group', grant.subject_id)}</span>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user