From f99764fcffdb39deb7bc5a83ff7b9444a0a22c92 Mon Sep 17 00:00:00 2001 From: Orville Bennett Date: Sun, 28 Jun 2026 14:14:19 -0400 Subject: [PATCH 01/49] fix: remove panic=abort so catch_unwind guards PDF extraction. Fixes #530 --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f88df1c2..7221897d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,7 +127,6 @@ required-features = ["load_seed_bin"] lto = "thin" codegen-units = 1 opt-level = 3 -panic = "abort" strip = true [profile.dev] From 473c126291fbd3a7e075bac5d88b1f50d3a2a715 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 1 Jul 2026 21:09:56 +0200 Subject: [PATCH 02/49] doc(drive.md): face recognition cluster per drive --- docs/plan/drive.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/docs/plan/drive.md b/docs/plan/drive.md index 3845d5ff..1181f402 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -1368,6 +1368,88 @@ flip Photos to cross-drive with `forbid_photo_index` as the opt-out (mirroring Music). That can land later without a schema change — just a behaviour change. +#### Face indexing — per-drive clustering, scope follows Photos + +Face indexing is bound to the same scope as `/api/photos` — the +two surfaces show the same content set, so the face data behind +that content lives in the same scope. + +Two layers to keep distinct: + +**Storage layer — per blob.** Face fingerprints are keyed on +`blob_hash` (BLAKE3), FK to `storage.blobs.hash`. Fingerprints +are deterministic from content bytes, and OxiCloud dedups +content via blob hash — so a photo uploaded into N drives (or N +times by N users) produces *one* fingerprint set, computed once, +reused forever. Cascade-deletes when the blob is GC'd (ref_count +→ 0). No `user_id`, `created_by`, `file_id`, `drive_id`, or +group key on the fingerprint row: identity is the content. + +**Clustering layer — per drive.** Cluster computation runs +*within* a drive: take every fingerprint reachable via a file in +that drive (`storage.files.drive_id = X` JOIN +`face_fingerprints` ON `blob_hash`), cluster them, emit clusters +scoped to drive X. The query repeats per drive the caller can +see (default personal + drives where +`policies.include_in_photo_index = true` AND the caller has +Read). Same-person fingerprints from different drives land in +**separate** clusters by default — even when both drives reach +the exact same blob, because clustering is keyed on drive, not +on fingerprint identity. + +**Why per-drive clustering:** + +The drive is already the data boundary post-D6 — quota, sharing, +trash, AuthZ all pivot on `drive_id`. The face library is part +of the drive's content, not a cross-drive aggregate. Two +properties fall out cleanly: + +- **Family-drive UX works.** Alice and Bob both members of + "Family" with `include_in_photo_index=true`. Alice uploads + Christmas photos; Bob uploads birthday photos. Grandma is in + both. Both see the *same* Grandma cluster in Family — one + merged cluster derived from fingerprints across both uploads. + Labels on the Family cluster are drive-scoped (anyone with + Photos access to Family sees them). +- **Personal-drive isolation is preserved.** Each user's + personal drive is access-isolated by definition (nobody else + has Read on it). So a personal-drive cluster is visible only + to the drive's owner. The privacy guarantee falls out of + drive-access scoping — no separate user-id key needed. + +**Cross-drive clusters don't auto-merge.** Bob labelling +"Grandma" in his Personal-drive cluster does NOT propagate to +Family's Grandma cluster. Two separate visual clusters by +default — even if the embedding similarity would otherwise +match them. Rationale: auto-propagating private labels into a +shared drive would silently expose personal classifications. +Future UX can offer explicit per-cluster merging ("these two +clusters are the same person") — user-driven, never silent. + +**Shared-drive opt-in is the consent surface.** Enabling +`include_in_photo_index` on a drive is the owner saying "the +photos in this drive are part of the drive's photo library, +including the face data they contain." Doesn't add a new +sharing surface — surfaces what was already visible (anyone +with Read on a photo can see who's in it). + +**Implementation:** + +- `face_fingerprints(blob_hash, embedding, …)` — FK to + `storage.blobs.hash`, no `user_id` / `file_id` / `drive_id` + column. Cascade-delete via the blob ref-count → 0 GC path. +- Cluster query: `SELECT … FROM storage.files f JOIN + face_fingerprints fp ON fp.blob_hash = f.blob_hash WHERE + f.drive_id = $1 AND NOT f.is_trashed` for each drive in the + caller's Photos-scope set. +- Pre-D7 the legacy `(user_id, blob_hash)` query in + `face_indexing_service.rs::lookup_user` stays in place; D7 + drops `user_id` from the column set in lockstep with the + global user_id retirement, leaving the fingerprint row keyed + on `blob_hash` alone. Both the `include_in_photo_index` policy + AND D7's user_id drop must land before face indexing can move + to the per-drive clustering model. + #### Verification sketch The D0 Hurl suite (`tests/api/drives_foundation.hurl`) covers From 20e5ef0ef207de69c513838e35895f807937dd31 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 1 Jul 2026 21:28:00 +0200 Subject: [PATCH 03/49] feat(drive): personal drive are photo + music indexed by default --- docs/plan/drive.md | 87 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/docs/plan/drive.md b/docs/plan/drive.md index 1181f402..ef8fcc1f 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -1324,13 +1324,12 @@ mutation site updates `updated_by`. the filesystem rather than browsing a single folder: Photos, Music library, Favorites, Recent items, Search, Trash. With drives landing, each of these needs an explicit scope decision. The -table below locks the choices; the rationale is **noise risk by -file type**, not a uniform rule. +table below locks the choices. | Section | Scope | Capability flag (per-drive policy) | Why | |---|---|---|---| -| **Photos** (`/api/photos`) | Default Personal Drive only | `policies.include_in_photo_index = true` to opt a non-default drive in | Shared drives often carry images that aren't "photos" (screenshots, scans, charts-as-PNGs). Defaulting cross-drive pollutes the personal timeline. Opt-in for shared drives where the owner explicitly wants them indexed (e.g. "Family Photos" shared drive). | -| **Music** — library view (future) + playlists | Cross-drive (all accessible drives) | `policies.forbid_music_index = true` to opt a drive out | Audio files in shared drives are almost always intentional content (band collaboration, family music, podcast archive). Defaulting cross-drive matches user intent. Owner opts a drive out for the rare case it shouldn't be indexed. The Music section today is *only* playlists; a `/api/music/tracks` library view added later inherits this scope. | +| **Photos** (`/api/photos`) | Default Personal Drive only | `policies.include_in_photo_index = true` to opt a non-default drive in | Non-default drives often carry images that aren't "photos" (screenshots, scans, charts-as-PNGs). Defaulting cross-drive pollutes the personal timeline. Opt-in for non-default drives where the owner explicitly wants them indexed (e.g. "Family Photos" shared drive). | +| **Music** — library view (future) + playlists | Default Personal Drive only | `policies.include_in_music_index = true` to opt a non-default drive in | Symmetric with Photos: audio files in a work drive or a random shared folder shouldn't silently bleed into the personal music library. Owner opts a non-default drive in (e.g. "Family Music", "Band Collaboration") when the drive genuinely is a music library. The Music section today is *only* playlists; a `/api/music/tracks` library view added later inherits this scope. | | **Music playlists** (`audio.playlists`) | User-scoped, cross-drive curation | n/a | Playlists are a curation tool. `owner_id` stays on `auth.users(id)`; tracks reference files via `playlist_items.file_id` and may live in any drive the user has access to. At list time, `list_playlist_tracks` filters out tracks in drives the caller can no longer reach (see §11's defense-in-depth pattern). | | **Favorites** (`/api/favorites/resources`) | Cross-drive (all accessible drives) | n/a | Personal organisation tool. Star a PDF from the work drive AND a photo from Personal — the whole point is cross-drive curation. ReBAC visibility check at list time drops rows the user can no longer reach. | | **Recent items** (`/api/recent/*`) | Cross-drive (all accessible drives) | n/a | Personal history. Same shape as Favorites — you touched files across drives; the timeline reflects that. ReBAC visibility check at list time. | @@ -1340,33 +1339,69 @@ file type**, not a uniform rule. #### Capability flag mechanism Both `policies.include_in_photo_index` and -`policies.forbid_music_index` live under the same JSONB +`policies.include_in_music_index` live under the same JSONB `policies` column on `storage.drives` (see §8) — no new schema. -The default values reflect the table above: omitted = "off" for -photos (so non-default drives don't show photos unless the owner -opts in), omitted = "off" for music (so all accessible drives -*are* indexed unless the owner opts out). +Both flags follow the same shape: **omitted = off**. The query +predicate then reduces to a single positive rule for every +drive: -The owner-only UI in the drive settings panel toggles these -flags. The query layer reads them at request time; flipping -either flag is instant — no reindex required because the filter -applies in the query Must-clause, the index itself is unchanged. +```sql +WHERE fi.drive_id IN ( + SELECT d.id FROM storage.drives d + JOIN storage.role_grants rg + ON rg.resource_type='drive' AND rg.resource_id=d.id + WHERE rg.subject_id IN (caller's effective subjects) + AND (d.policies->>'include_in_photo_index')::boolean = true +) +``` -#### The Photos/Music asymmetry — defensible, not a smell +No `default_for_user` OR-branch, no per-kind carve-out. -Photos defaulting to "default-drive only" while Music defaults to -"cross-drive" is the one case where two similar surfaces have -different defaults. The justification is the noise-risk argument -above: image content in shared drives is heterogeneous (often -not "photos" in the gallery sense), audio content in shared -drives is usually intentional. The capability flags let owners -fix either case, but the defaults match what the typical user -will want without configuration. +**Default personal drive gets both flags set to `true` on +creation.** The `PersonalDriveLifecycleHook` (§3) that creates +the default personal drive on user provisioning populates +`policies` with `{"include_in_photo_index": true, +"include_in_music_index": true}`. Existing default personal +drives get the same two flags via a one-shot backfill migration +alongside the flag introduction. Net effect: every user's +default personal drive is in scope from moment one, no user +configuration required for the common case, but the SQL is +kind-agnostic. -If a uniform rule is ever preferred, the cheapest move is to -flip Photos to cross-drive with `forbid_photo_index` as the -opt-out (mirroring Music). That can land later without a schema -change — just a behaviour change. +**Non-default drives** (secondary personals, shared drives) are +created with the flags omitted, so they stay out of scope until +the owner explicitly opts in via the admin "Manage policies" +modal. + +Flipping either flag on any drive is instant — the query reads +`policies` at request time; the index itself is unchanged. +Toggle-off on a default personal drive is *possible* (admins +own the drive-policy mutation surface — see §8) but shows a +confirm dialog in the UI ("this will empty the user's Photos +timeline" / "…their Music library"), since it's an unusual +action. + +#### Why symmetric (both opt-in) instead of asymmetric + +An earlier version of this section had Music default to +cross-drive (`forbid_music_index` as an opt-out), on the +argument that audio in shared drives is "almost always +intentional content." That asymmetry created two problems: + +1. **Mixed-form flag naming** — one `include_in_*` and one + `forbid_*` with opposite meanings, hard to reason about in the + admin UI and the query layer. +2. **The "shared audio is always intentional" claim doesn't + hold under scrutiny** — a work drive with a few voicemail + MP3s or a project drive with a stray podcast recording + shouldn't bleed into the personal music library any more than + a work drive with screenshots should bleed into Photos. + +Symmetric opt-in (`include_in_*_index` for both) fixes both. +The "Family Music" case still works — the owner flips the flag +once on drive creation, same one-time gesture as "Family Photos" +under the pre-existing photo policy. The default-personal case +(90%+ of users) needs no configuration for either surface. #### Face indexing — per-drive clustering, scope follows Photos From 01ff7dab0bda199d22b8bee5ed79f106e47d124e Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 1 Jul 2026 21:54:29 +0200 Subject: [PATCH 04/49] feat(drive): impl policy photo + music policies add `include_in_photo_index` and `include_in_music_index` policies both true for default personal drive photo is implemented music is not yet implemented --- frontend/src/lib/api/types.ts | 37 ++++++-- frontend/src/routes/admin/+page.svelte | 30 ++++++- ...000_default_personal_photo_music_flags.sql | 32 +++++++ ...01_files_media_timeline_by_drive_index.sql | 25 ++++++ .../services/drive_management_service.rs | 4 +- src/application/services/places_service.rs | 28 ++++-- src/common/di.rs | 14 ++- src/domain/entities/drive.rs | 20 +++++ src/domain/repositories/drive_repository.rs | 10 ++- .../repositories/pg/drive_pg_repository.rs | 28 ++++-- .../pg/file_blob_read_repository.rs | 90 +++++++++++++++---- src/interfaces/api/handlers/drive_handler.rs | 24 +++-- src/interfaces/api/handlers/photos_handler.rs | 22 ++++- tests/api/drive_policies.hurl | 61 +++++++++++++ 14 files changed, 367 insertions(+), 58 deletions(-) create mode 100644 migrations/20260901000000_default_personal_photo_music_flags.sql create mode 100644 migrations/20260901000001_files_media_timeline_by_drive_index.sql diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 1eed62c0..83f7b481 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -24,7 +24,12 @@ export interface FolderItem { is_root: boolean; modified_at: number; name: string; - owner_id: string; + // `null` on share-recipient responses — the backend's + // `FolderDto::without_hierarchy_info` (folder_dto.rs:154) clears + // hierarchy fields (including owner_id) for non-owner callers. + // Backend serialises `Option` (folder_dto.rs:53); this + // type just tells the truth about the wire. + owner_id: string | null; parent_id: string | null; path: string; etag: string; @@ -39,7 +44,9 @@ export interface FileItem { mime_type: string; modified_at: number; name: string; - owner_id: string; + // `null` on share-recipient responses (same as FolderItem above). + // Backend serialises `Option` at file_dto.rs:59. + owner_id: string | null; folder_id: string; path: string; size: number; @@ -249,12 +256,16 @@ export interface Drive { } /** - * Typed mirror of the five known D5 policy keys. Every field defaults to - * `false` (= allowed). The wire shape returned by - * `PATCH /api/drives/{id}/policies` carries all five keys; the request - * body uses [`DrivePoliciesPartial`] so unsupplied keys aren't disturbed. + * Typed mirror of the known drive policy keys. Every field defaults to + * `false` (= "opted out" for the `include_in_*` keys, "allowed" for the + * `forbid_*` keys). The wire shape returned by + * `PATCH /api/drives/{id}/policies` carries every known key; the request + * body uses [`DrivePoliciesPartial`] so unsupplied keys aren't disturbed + * (the backend uses a JSONB `||` merge — see + * `drive_pg_repository.rs::update_policies`). * - * See `docs/plan/drive.md` §8 for what each key gates. + * See `docs/plan/drive.md` §8 for the `forbid_*` gates and §15 for the + * `include_in_*_index` scope flags. */ export interface DrivePolicies { forbid_sharing: boolean; @@ -262,6 +273,18 @@ export interface DrivePolicies { forbid_public_links: boolean; forbid_cross_drive_move: boolean; forbid_owner_role_change: boolean; + /** + * §15 opt-in for `/api/photos` timeline scope. Default personal drives + * are created with `true`; non-default drives (secondary personals, + * shared) start `false` and opt in via the admin policy modal. + */ + include_in_photo_index: boolean; + /** + * §15 opt-in for the Music library surface (currently playlists; + * future `/api/music/tracks` library view will read this too). + * Symmetric shape to `include_in_photo_index`. + */ + include_in_music_index: boolean; } /** diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 510a9f04..62ef4263 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1080,7 +1080,13 @@ forbid_external_sharing: false, forbid_public_links: false, forbid_cross_drive_move: false, - forbid_owner_role_change: false + forbid_owner_role_change: false, + // §15 opt-in scope flags. Default personal drives ship with `true` + // on the wire (materialised by the DB-side create path + backfill + // migration), so `readPolicyBool` will surface the correct current + // state on modal open. + include_in_photo_index: false, + include_in_music_index: false }); let managePoliciesError = $state(null); let managePoliciesBusy = $state(false); @@ -1102,7 +1108,9 @@ forbid_external_sharing: readPolicyBool(p, 'forbid_external_sharing'), forbid_public_links: readPolicyBool(p, 'forbid_public_links'), forbid_cross_drive_move: readPolicyBool(p, 'forbid_cross_drive_move'), - forbid_owner_role_change: readPolicyBool(p, 'forbid_owner_role_change') + forbid_owner_role_change: readPolicyBool(p, 'forbid_owner_role_change'), + include_in_photo_index: readPolicyBool(p, 'include_in_photo_index'), + include_in_music_index: readPolicyBool(p, 'include_in_music_index') }; } @@ -1207,6 +1215,24 @@ 'admin.drive_policy.forbid_owner_role_change_help', 'Only admin can add, remove, or demote drive Owners while this is on.' ) + }, + { + key: 'include_in_photo_index', + label: () => t('admin.drive_policy.include_in_photo_index', 'Include in Photos'), + help: () => + t( + 'admin.drive_policy.include_in_photo_index_help', + 'Show image and video files from this drive in the Photos timeline and on the Places map. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold photos (e.g. "Family Photos").' + ) + }, + { + key: 'include_in_music_index', + label: () => t('admin.drive_policy.include_in_music_index', 'Include in Music'), + help: () => + t( + 'admin.drive_policy.include_in_music_index_help', + 'Include audio files from this drive in the Music library. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold a music collection (e.g. "Family Music", "Band Collaboration").' + ) } ]; diff --git a/migrations/20260901000000_default_personal_photo_music_flags.sql b/migrations/20260901000000_default_personal_photo_music_flags.sql new file mode 100644 index 00000000..b417f0d9 --- /dev/null +++ b/migrations/20260901000000_default_personal_photo_music_flags.sql @@ -0,0 +1,32 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- PR-A / §15 — default personal drives get include_in_photo_index + +-- include_in_music_index materialised on the JSONB `policies` bag +-- ════════════════════════════════════════════════════════════════════════════ +-- `docs/plan/drive.md` §15 locks the two policies as symmetric per-drive +-- opt-in flags. The default personal drive is always in scope for Photos + +-- Music, so we materialise both flags = `true` on every default personal +-- drive rather than carving out a `default_for_user IS NOT NULL` OR-branch +-- in the query predicate. Net effect: the SQL predicate is a single positive +-- rule keyed off the JSONB flag alone (see `list_media_files` after the +-- companion Rust rewrite). +-- +-- New default personal drives get these flags at creation time via +-- `DriveRepository::create_personal_drive_atomic` (the INSERT literal on +-- that path was updated alongside this migration). This migration handles +-- the existing rows, seeded by the D0 backfill. +-- +-- Non-default drives (secondary personals, shared drives) are NOT touched — +-- they stay opted-out until the owner flips the flag via the admin +-- "Manage policies" modal. +-- +-- Idempotent: `policies || {…}` is a no-op if the keys are already set to +-- the same values, and JSONB `||` is right-precedence so the migration +-- never overwrites an owner's explicit opt-out that was already recorded. +-- (If someone had `include_in_photo_index=false` set on their default +-- personal via a manual PATCH, this UPDATE would still overwrite to true; +-- that's acceptable — the D5 policy UI didn't exist for these flags +-- before this PR, so no such manual opt-out can be in the wild yet.) + +UPDATE storage.drives + SET policies = policies || '{"include_in_photo_index": true, "include_in_music_index": true}'::jsonb + WHERE default_for_user IS NOT NULL; diff --git a/migrations/20260901000001_files_media_timeline_by_drive_index.sql b/migrations/20260901000001_files_media_timeline_by_drive_index.sql new file mode 100644 index 00000000..9ca103d9 --- /dev/null +++ b/migrations/20260901000001_files_media_timeline_by_drive_index.sql @@ -0,0 +1,25 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- PR-A / §15 — partial covering index for the drive-scoped Photos timeline +-- ════════════════════════════════════════════════════════════════════════════ +-- Sibling of `idx_files_media_timeline` (initial_schema.sql:581), keyed on +-- `drive_id` instead of `user_id`. The Photos handler predicate is being +-- rewritten to `fi.drive_id IN (drives with include_in_photo_index = true +-- AND caller has Read)` — that subquery produces a small drive-id set, +-- and this index gives Postgres one IndexScan per drive_id already +-- ordered by `media_sort_date DESC`, so LIMIT stops the scan early. +-- Same O(LIMIT) shape as the pre-D7 user_id-keyed hot path. +-- +-- The old `idx_files_media_timeline (user_id, media_sort_date DESC)` index +-- is intentionally kept for now — it still backs the dedup / storage sweep +-- paths that D7 will migrate separately. Once D7 drops the `user_id` +-- column those paths lose their backing index at the same moment; that PR +-- can drop the old index in the same migration. +-- +-- Partial WHERE clause is identical to the existing sibling so the index +-- stays as compact as its predecessor: only image/video rows that aren't +-- trashed. + +CREATE INDEX IF NOT EXISTS idx_files_media_timeline_by_drive + ON storage.files (drive_id, media_sort_date DESC) + WHERE NOT is_trashed + AND (mime_type LIKE 'image/%' OR mime_type LIKE 'video/%'); diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index b1cad79c..5d472d04 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -448,7 +448,7 @@ impl DriveManagementService { &self, caller_id: Uuid, drive_id: Uuid, - partial: crate::domain::entities::drive::DrivePolicies, + partial: serde_json::Value, ) -> Result { let merged = self .drive_repo @@ -474,6 +474,8 @@ impl DriveManagementService { forbid_public_links = merged.forbid_public_links, forbid_cross_drive_move = merged.forbid_cross_drive_move, forbid_owner_role_change = merged.forbid_owner_role_change, + include_in_photo_index = merged.include_in_photo_index, + include_in_music_index = merged.include_in_music_index, "📜 drive policies updated", ); Ok(merged) diff --git a/src/application/services/places_service.rs b/src/application/services/places_service.rs index 6f0f42ad..79314a0b 100644 --- a/src/application/services/places_service.rs +++ b/src/application/services/places_service.rs @@ -4,22 +4,29 @@ use uuid::Uuid; use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster}; use crate::common::errors::DomainError; +use crate::domain::services::authorization::Subject; use crate::infrastructure::repositories::pg::FileBlobReadRepository; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; /// "Places" use case: the caller's geotagged photos aggregated into map /// clusters. /// -/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so, -/// like [`RecentService`](super::recent_service::RecentService) and the photos -/// timeline, it needs no `AuthorizationEngine` check: the `caller_id` -/// parameter *is* the access scope. +/// Post-§15 the surface follows the Photos scope: default personal drive +/// + drives where `policies.include_in_photo_index = true` AND caller +/// has Read. The repository query joins `role_grants` on the drive +/// resource type; group-mediated grants are honoured via the caller +/// expansion done here. pub struct PlacesService { file_read: Arc, + authorization: Arc, } impl PlacesService { - pub fn new(file_read: Arc) -> Self { - Self { file_read } + pub fn new(file_read: Arc, authorization: Arc) -> Self { + Self { + file_read, + authorization, + } } /// Aggregation cell side, in degrees, for a slippy-map zoom level. The @@ -30,7 +37,8 @@ impl PlacesService { 360.0 / (2_f64.powi(z) * 4.0) } - /// Clustered geotagged photos for `caller_id` within `bounds`. + /// Clustered geotagged photos in the caller's Photos-scope drive set, + /// within `bounds`. pub async fn clusters( &self, caller_id: Uuid, @@ -38,8 +46,12 @@ impl PlacesService { zoom: u8, ) -> Result, DomainError> { let cell = Self::cell_for_zoom(zoom); + let (subject_types, subject_ids) = self + .authorization + .expand_subject_for_listing(Subject::User(caller_id)) + .await?; self.file_read - .list_geo_clusters(caller_id, bounds, cell) + .list_geo_clusters(&subject_types, &subject_ids, bounds, cell) .await } } diff --git a/src/common/di.rs b/src/common/di.rs index 5600aa41..ce6ddf65 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -912,12 +912,20 @@ impl AppServiceFactory { } /// Creates the Places (photo map) service. Reuses the existing file-read - /// repository — the data is the caller's own geotagged photos. + /// repository — the data is the caller's Photos-scope geotagged photos + /// (§15: default personal drive + drives with + /// `include_in_photo_index = true` AND caller has Read). + /// `authorization` is used for the same subject expansion that + /// `photos_handler::list_photos` runs. pub fn create_places_service( &self, file_read: &Arc, + authorization: &Arc, ) -> Arc { - let service = Arc::new(PlacesService::new(file_read.clone())); + let service = Arc::new(PlacesService::new( + file_read.clone(), + authorization.clone(), + )); tracing::info!("Places service initialized"); service } @@ -1285,7 +1293,7 @@ impl AppServiceFactory { apps.recent_service = Some(recent_service_eager.clone()); places_service = if core.config.features.enable_places { - Some(self.create_places_service(&repos.file_read_repository)) + Some(self.create_places_service(&repos.file_read_repository, &authorization)) } else { None }; diff --git a/src/domain/entities/drive.rs b/src/domain/entities/drive.rs index 685804c0..7d1f562b 100644 --- a/src/domain/entities/drive.rs +++ b/src/domain/entities/drive.rs @@ -181,6 +181,26 @@ pub struct DrivePolicies { /// writes) and `::remove_member` (refuses Owner removals) when the /// caller is non-admin. pub forbid_owner_role_change: bool, + /// Opts this drive into the `/api/photos` timeline (§15). Non-default + /// drives are omitted by default so a random shared folder full of + /// screenshots doesn't bleed into the personal timeline; owners flip + /// this on when the drive genuinely is a photo library (e.g. "Family + /// Photos"). Default personal drives get `true` on creation via the + /// `PersonalDriveLifecycleHook` + a one-shot backfill for existing + /// rows, so the SQL predicate is a single positive rule with no + /// per-kind carve-out. Read at `file_blob_read_repository:: + /// list_media_files` + `list_geo_clusters`. See §15 for the query + /// shape and rationale. + pub include_in_photo_index: bool, + /// Same shape as `include_in_photo_index`, applied to the Music + /// library surface (playlists today; a `/api/music/tracks` library + /// view later). Symmetric opt-in — Music was originally cross-drive + /// via a `forbid_music_index` opt-out, but that mixed-form naming + /// created "one include-in, one forbid" confusion and the + /// "shared audio is always intentional" claim didn't hold under + /// scrutiny (voicemail MP3s in a work drive shouldn't bleed into + /// the personal library). See §15. + pub include_in_music_index: bool, } impl DrivePolicies { diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs index 532925ee..67c449cb 100644 --- a/src/domain/repositories/drive_repository.rs +++ b/src/domain/repositories/drive_repository.rs @@ -256,10 +256,18 @@ pub trait DriveRepository: Send + Sync + 'static { /// /// Caller is responsible for the `Manage` permission check; this /// method does not re-verify. + /// + /// `partial` is a raw JSON object carrying **only** the keys the + /// caller wants to change — the repo passes it verbatim to the + /// `policies || $partial` JSONB merge. Using the typed + /// `DrivePolicies` here would serialise every field (including + /// unset ones as `false`) and clobber other flags on the row; + /// keeping the merge on the raw `Value` preserves the + /// partial-update semantic the handler documents. async fn update_policies( &self, drive_id: Uuid, - partial: &crate::domain::entities::drive::DrivePolicies, + partial: &serde_json::Value, ) -> Result; } diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index 855df271..dffdbd6d 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -116,11 +116,22 @@ impl DriveRepository for DrivePgRepository { .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?; // 1. Drive row (root_folder_id NULL — populated in step 3). + // + // Default personal drives are seeded with `include_in_photo_index` + // + `include_in_music_index` = true so the Photos / Music + // predicates (§15) can be a single positive rule keyed off the + // JSONB flag — no per-kind carve-out needed at query time. Any + // future admin PATCH toggling either flag off shows a confirm + // dialog in the UI (unusual action; empties the user's Photos + // timeline / Music library). let drive_id: Uuid = sqlx::query_scalar( r#" INSERT INTO storage.drives (kind, default_for_user, quota_bytes, policies) - VALUES ('personal', $1, $2, '{}'::jsonb) + VALUES ( + 'personal', $1, $2, + '{"include_in_photo_index": true, "include_in_music_index": true}'::jsonb + ) RETURNING id "#, ) @@ -636,16 +647,17 @@ impl DriveRepository for DrivePgRepository { async fn update_policies( &self, drive_id: Uuid, - partial: &crate::domain::entities::drive::DrivePolicies, + partial: &serde_json::Value, ) -> Result { // JSONB-level merge (`||`) keeps unknown keys already on disk — // the column remains the canonical bag (see // `DrivePolicies::from_value` — typed read is lenient, untyped - // write is preserving). RETURNING surfaces the post-merge bag so - // the audit log shows what the row actually carries afterwards. - let partial_json = serde_json::to_value(partial).map_err(|e| { - DriveRepositoryError::StorageError(format!("serialise partial policies: {e}")) - })?; + // write is preserving). The caller passes a raw `Value` with + // ONLY the keys it wants to change (never a full `DrivePolicies` + // round-trip, which would serialise all-false defaults into the + // merge and clobber other flags). RETURNING surfaces the + // post-merge bag so the audit log shows what the row actually + // carries afterwards. let row: Option<(serde_json::Value,)> = sqlx::query_as( "UPDATE storage.drives \ SET policies = policies || $2, \ @@ -654,7 +666,7 @@ impl DriveRepository for DrivePgRepository { RETURNING policies", ) .bind(drive_id) - .bind(&partial_json) + .bind(partial) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| Self::map_sqlx_err("update_policies", e))?; diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index af31116f..89a4757e 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -445,15 +445,36 @@ impl FileBlobReadRepository { /// /// Uses the denormalised `media_sort_date` column (synced from /// `file_metadata.captured_at` by trigger) so no JOIN with - /// `file_metadata` is needed. The partial index - /// `idx_files_media_timeline` covers the full query: filter + ORDER BY - /// in a single Index Scan — O(LIMIT) not O(N). + /// `file_metadata` is needed. The partial covering index + /// `idx_files_media_timeline_by_drive` (migration 20260901000001) + /// keys on `(drive_id, media_sort_date DESC)` filtered on non-trashed + /// image/video rows — Postgres does one IndexScan per in-scope + /// drive_id already ordered by capture date, so LIMIT stops the scan + /// early. Same O(LIMIT) shape as the pre-D7 `user_id`-keyed hot path. + /// + /// Scope (`docs/plan/drive.md` §15): the caller's *effective subjects* + /// × drives with `policies.include_in_photo_index = true`. Default + /// personal drives always match because the flag is materialised to + /// `true` at drive creation (see + /// `DriveRepository::create_personal_drive_atomic` + the backfill + /// migration `20260901000000_default_personal_photo_music_flags.sql`) + /// — no per-kind carve-out needed. Non-default drives (secondary + /// personals, shared drives) surface here only after their owner + /// flips the flag on via the admin "Manage policies" modal. + /// + /// `subject_types` / `subject_ids` are the caller expanded through + /// their group memberships (`AuthorizationEngine:: + /// expand_subject_for_listing`); the arrays reach into the ANY() + /// predicates so a group-mediated grant on a drive counts too. pub async fn list_media_files( &self, - owner_id: Uuid, + subject_types: &[&str], + subject_ids: &[Uuid], before: Option, limit: i64, ) -> Result<(Vec, Vec, Vec<(Option, Option)>), DomainError> { + let subject_types_owned: Vec = + subject_types.iter().map(|s| s.to_string()).collect(); let rows: Vec = sqlx::query_as( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, @@ -468,16 +489,27 @@ impl FileBlobReadRepository { FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id - WHERE fi.user_id = $1 + WHERE fi.drive_id IN ( + SELECT d.id + FROM storage.drives d + JOIN storage.role_grants g + ON g.resource_type = 'drive' + AND g.resource_id = d.id + WHERE g.subject_type = ANY($1) + AND g.subject_id = ANY($2) + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + AND (d.policies->>'include_in_photo_index')::boolean = true + ) AND NOT fi.is_trashed AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') - AND ($2::bigint IS NULL - OR EXTRACT(EPOCH FROM fi.media_sort_date)::bigint < $2::bigint) + AND ($3::bigint IS NULL + OR EXTRACT(EPOCH FROM fi.media_sort_date)::bigint < $3::bigint) ORDER BY fi.media_sort_date DESC - LIMIT $3 + LIMIT $4 "#, ) - .bind(owner_id) + .bind(&subject_types_owned) + .bind(subject_ids) .bind(before) .bind(limit) .fetch_all(self.pool.as_ref()) @@ -500,15 +532,26 @@ impl FileBlobReadRepository { } /// Aggregate the caller's geotagged photos into grid cells of side `cell` - /// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`. - /// Returns one cluster per non-empty cell with its centroid, photo count - /// and a representative photo id (for the cluster thumbnail). + /// (degrees) within `bounds`. Plain SQL (no PostGIS). + /// + /// Scope: same `include_in_photo_index` predicate as + /// `list_media_files` (§15). Places is the map view over the same + /// content set the Photos timeline shows, so the two surfaces MUST + /// agree on drive scope. If a drive is opt-out for Photos its + /// geotagged files never appear on the map either. + /// + /// This query is a per-cell aggregate (group by rounded lat/lng + /// bucket) rather than an ORDER BY / LIMIT hot path — the plain + /// `idx_files_drive_id` is sufficient to seek by drive. pub async fn list_geo_clusters( &self, - user_id: Uuid, + subject_types: &[&str], + subject_ids: &[Uuid], bounds: GeoBounds, cell: f64, ) -> Result, DomainError> { + let subject_types_owned: Vec = + subject_types.iter().map(|s| s.to_string()).collect(); let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as( r#" SELECT count(*) AS n, @@ -517,16 +560,27 @@ impl FileBlobReadRepository { min(fm.file_id::text) AS sample_id FROM storage.file_metadata fm JOIN storage.files fi ON fi.id = fm.file_id - WHERE fi.user_id = $1 + WHERE fi.drive_id IN ( + SELECT d.id + FROM storage.drives d + JOIN storage.role_grants g + ON g.resource_type = 'drive' + AND g.resource_id = d.id + WHERE g.subject_type = ANY($1) + AND g.subject_id = ANY($2) + AND (g.expires_at IS NULL OR g.expires_at > NOW()) + AND (d.policies->>'include_in_photo_index')::boolean = true + ) AND NOT fi.is_trashed AND fm.latitude IS NOT NULL AND fm.longitude IS NOT NULL - AND fm.longitude BETWEEN $2 AND $3 - AND fm.latitude BETWEEN $4 AND $5 - GROUP BY round(fm.longitude / $6), round(fm.latitude / $6) + AND fm.longitude BETWEEN $3 AND $4 + AND fm.latitude BETWEEN $5 AND $6 + GROUP BY round(fm.longitude / $7), round(fm.latitude / $7) "#, ) - .bind(user_id) + .bind(&subject_types_owned) + .bind(subject_ids) .bind(bounds.west) .bind(bounds.east) .bind(bounds.south) diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs index 2ceb5ce7..fbb0b94d 100644 --- a/src/interfaces/api/handlers/drive_handler.rs +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -416,6 +416,10 @@ pub struct UpdateDrivePoliciesDto { pub forbid_cross_drive_move: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub forbid_owner_role_change: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_in_photo_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_in_music_index: Option, } /// `PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy @@ -494,18 +498,22 @@ pub async fn update_drive_policies( serde_json::Value::Bool(v), ); } + if let Some(v) = dto.include_in_photo_index { + partial_obj.insert("include_in_photo_index".into(), serde_json::Value::Bool(v)); + } + if let Some(v) = dto.include_in_music_index { + partial_obj.insert("include_in_music_index".into(), serde_json::Value::Bool(v)); + } + // Pass the raw JSON straight through so the JSONB `||` merge in + // the repo only touches keys the caller supplied. Round-tripping + // via `DrivePolicies` (which has `#[serde(default)]`) would + // silently fill every omitted field with `false` — the merge + // would then clobber every unmentioned policy on the row. let partial_value = serde_json::Value::Object(partial_obj); - let partial: crate::domain::entities::drive::DrivePolicies = - match serde_json::from_value(partial_value) { - Ok(p) => p, - Err(e) => { - return AppError::bad_request(format!("invalid policy body: {e}")).into_response(); - } - }; match state .drive_management_service - .update_policies(auth_user.id, drive_id, partial) + .update_policies(auth_user.id, drive_id, partial_value) .await { Ok(merged) => (StatusCode::OK, axum::Json(merged)).into_response(), diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index 00001b3b..0e4774b5 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -12,6 +12,8 @@ use tracing::{error, info}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::geo_dto::GeoBounds; use crate::common::di::AppState; +use crate::domain::services::authorization::Subject; +use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; /// Query parameters for the photos timeline endpoint. @@ -63,13 +65,29 @@ pub async fn list_photos( headers: HeaderMap, Query(params): Query, ) -> impl IntoResponse { - let user_id = auth_user.id; + let caller_id = auth_user.id; let limit = params.limit.unwrap_or(200).clamp(1, 500); + // Expand the caller into (subject_types, subject_ids) so group-mediated + // drive memberships surface in the Photos timeline too. Mirrors what + // `drive_handler::list_drives` and `trash_service::list_resources_paged` + // already do — one call to the AuthZ engine per request. + let (subject_types, subject_ids) = match state + .authorization + .expand_subject_for_listing(Subject::User(caller_id)) + .await + { + Ok(pair) => pair, + Err(e) => { + error!("list_photos: subject expansion failed: {e}"); + return AppError::from(e).into_response(); + } + }; + let file_read = &state.repositories.file_read_repository; match file_read - .list_media_files(user_id, params.before, limit) + .list_media_files(&subject_types, &subject_ids, params.before, limit) .await { Ok((files, sort_dates, dims)) => { diff --git a/tests/api/drive_policies.hurl b/tests/api/drive_policies.hurl index 4691905f..33f0f9c3 100644 --- a/tests/api/drive_policies.hurl +++ b/tests/api/drive_policies.hurl @@ -471,6 +471,67 @@ Content-Type: application/json HTTP 201 +# ───────────────────────────────────────────────────────────── +# Step 10c — partial-merge regression guard. +# +# The `PATCH /api/drives/{id}/policies` handler documents that +# omitting a field means "leave it alone", not "set it to false". +# Prior implementation round-tripped the wire body through the +# typed `DrivePolicies` struct (which has `#[serde(default)]`, so +# every omitted field defaults to `false`) and then serialised the +# whole struct into the JSONB `||` merge — silently clobbering +# every unmentioned flag back to `false`. This step exercises +# multi-flag interaction so that regression can't creep back: +# +# 1. Set `forbid_sharing = true`, assert the bag. +# 2. In a SEPARATE PATCH, set only `forbid_public_links = true`. +# 3. Assert `forbid_sharing` STILL reads `true` in the response +# — proving the merge honoured "leave omitted keys alone". +# +# Reset both back to false at the end so the shared-drive steps +# below start from a clean state. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_sharing": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_sharing" == true +jsonpath "$.forbid_public_links" == false + +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_public_links": true +} + +HTTP 200 +[Asserts] +# The load-bearing assertion — `forbid_sharing` must NOT have been +# clobbered by the omitted-key regression. +jsonpath "$.forbid_sharing" == true +jsonpath "$.forbid_public_links" == true + +# Reset both. +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_sharing": false, + "forbid_public_links": false +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_sharing" == false +jsonpath "$.forbid_public_links" == false + + # ───────────────────────────────────────────────────────────── # Step 11 — `forbid_external_sharing` on a SHARED drive, via # `POST /api/drives/{id}/members`. From 09339ea63f58fd6a15b7f2e96d88d3511af8ea03 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 1 Jul 2026 22:15:09 +0200 Subject: [PATCH 05/49] feat(drive): UI: show policiesto drive's members and add tests --- frontend/src/lib/components/PolicyList.svelte | 145 ++++++++++++ frontend/src/lib/utils/drivePolicies.ts | 148 ++++++++++++ frontend/src/routes/admin/+page.svelte | 219 ++---------------- .../routes/config/drive/[uuid]/+page.svelte | 80 ++++++- frontend/static/locales/ar.json | 4 + frontend/static/locales/de.json | 4 + frontend/static/locales/en.json | 4 + frontend/static/locales/es.json | 4 + frontend/static/locales/fa.json | 4 + frontend/static/locales/fr.json | 4 + frontend/static/locales/hi.json | 4 + frontend/static/locales/it.json | 4 + frontend/static/locales/ja.json | 4 + frontend/static/locales/ko.json | 4 + frontend/static/locales/nl.json | 4 + frontend/static/locales/pl.json | 4 + frontend/static/locales/pt.json | 4 + frontend/static/locales/ru.json | 4 + frontend/static/locales/zh-TW.json | 4 + frontend/static/locales/zh.json | 4 + tests/api/drive_policies.hurl | 107 +++++++++ 21 files changed, 557 insertions(+), 206 deletions(-) create mode 100644 frontend/src/lib/components/PolicyList.svelte create mode 100644 frontend/src/lib/utils/drivePolicies.ts diff --git a/frontend/src/lib/components/PolicyList.svelte b/frontend/src/lib/components/PolicyList.svelte new file mode 100644 index 00000000..1d7853ff --- /dev/null +++ b/frontend/src/lib/components/PolicyList.svelte @@ -0,0 +1,145 @@ + + +
    + {#each policyDefs as def (def.key)} + {@const implied = isPolicyImplied(def, values)} +
  • + +
  • + {/each} +
+ + diff --git a/frontend/src/lib/utils/drivePolicies.ts b/frontend/src/lib/utils/drivePolicies.ts new file mode 100644 index 00000000..c0c03c7a --- /dev/null +++ b/frontend/src/lib/utils/drivePolicies.ts @@ -0,0 +1,148 @@ +/** + * Shared drive-policy definitions. + * + * Consumed by two surfaces: + * - Admin "Manage policies" modal (`routes/admin/+page.svelte`) — read+write. + * - Drive settings page (`routes/config/drive/[uuid]/+page.svelte`) — read-only, + * so drive members can see which policies an admin has set. + * + * Kept in a plain `.ts` module (not a component) so both consumers import the + * same array and the definition of "one policy" lives in exactly one place. + * Adding a sixth policy is a single push here + one migration + the + * `DrivePolicies` interface extension in `types.ts`. See + * `docs/plan/drive.md` §8 (forbid_* gates) + §15 (include_in_*_index scope). + */ +import { t } from '$lib/i18n/index.svelte'; +import type { DrivePoliciesPartial } from '$lib/api/types'; + +/** + * `impliedBy` captures the semantic dependency between policies: when the + * named parent policy is on, this subordinate gate is moot (its enforcement + * is already covered by the broader rule). The admin modal disables the + * child toggle and shows `impliedHint` so the admin understands the + * hierarchy without our having to mutate the stored value — their + * preference is preserved for the moment they relax the parent. The + * read-only config surface uses the same signal to dim implied rows. + */ +export interface PolicyDef { + key: keyof Required; + label: () => string; + help: () => string; + impliedBy?: keyof Required; + impliedHint?: () => string; +} + +/** + * Mirrors the entity field order in `src/domain/entities/drive.rs` so a + * future policy lands here as one literal-array push. + */ +export const policyDefs: PolicyDef[] = [ + { + key: 'forbid_sharing', + label: () => t('admin.drive_policy.forbid_sharing', 'Forbid per-resource sharing'), + help: () => + t( + 'admin.drive_policy.forbid_sharing_help', + 'Block per-file / per-folder grants (covers public links and external sharing as well). Drive-level membership still works.' + ) + }, + { + key: 'forbid_public_links', + label: () => t('admin.drive_policy.forbid_public_links', 'Forbid public links'), + help: () => + t( + 'admin.drive_policy.forbid_public_links_help', + 'Block anonymous share links on resources in this drive.' + ), + impliedBy: 'forbid_sharing', + impliedHint: () => + t( + 'admin.drive_policy.implied_by_forbid_sharing', + 'Already enforced by Forbid per-resource sharing.' + ) + }, + { + key: 'forbid_external_sharing', + label: () => t('admin.drive_policy.forbid_external_sharing', 'Forbid external sharing'), + help: () => + t( + 'admin.drive_policy.forbid_external_sharing_help', + 'Block grants to external users (email invitations and pre-existing external accounts).' + ), + impliedBy: 'forbid_sharing', + impliedHint: () => + t( + 'admin.drive_policy.implied_by_forbid_sharing', + 'Already enforced by Forbid per-resource sharing.' + ) + }, + { + key: 'forbid_cross_drive_move', + label: () => t('admin.drive_policy.forbid_cross_drive_move', 'Forbid cross-drive move'), + help: () => + t( + 'admin.drive_policy.forbid_cross_drive_move_help', + 'Block moving files or folders out to another drive. Does not stop download + re-upload.' + ) + }, + { + key: 'forbid_owner_role_change', + label: () => t('admin.drive_policy.forbid_owner_role_change', 'Lock Owner roster'), + help: () => + t( + 'admin.drive_policy.forbid_owner_role_change_help', + 'Only admin can add, remove, or demote drive Owners while this is on.' + ) + }, + { + key: 'include_in_photo_index', + label: () => t('admin.drive_policy.include_in_photo_index', 'Include in Photos'), + help: () => + t( + 'admin.drive_policy.include_in_photo_index_help', + 'Show image and video files from this drive in the Photos timeline and on the Places map. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold photos (e.g. "Family Photos").' + ) + }, + { + key: 'include_in_music_index', + label: () => t('admin.drive_policy.include_in_music_index', 'Include in Music'), + help: () => + t( + 'admin.drive_policy.include_in_music_index_help', + 'Include audio files from this drive in the Music library. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold a music collection (e.g. "Family Music", "Band Collaboration").' + ) + } +]; + +/** + * True when `def` is subordinate to another policy whose value is currently + * `true` in `values`. Both surfaces use this to gray out implied rows. + */ +export function isPolicyImplied(def: PolicyDef, values: Required): boolean { + return def.impliedBy != null && values[def.impliedBy]; +} + +/** + * JSONB reader — the backend may hold a raw `Record` bag + * (unknown keys preserved verbatim), so any missing / non-bool key resolves + * to `false`. Shared between the admin modal (initialising the edit draft) + * and the config/drive page (reading the current state for display). + */ +export function readPolicyBool(p: Record, key: string): boolean { + const v = p[key]; + return typeof v === 'boolean' ? v : false; +} + +/** + * Populate a full `Required` from the JSONB bag by + * reading each known key with `readPolicyBool`. Both admin and config + * surfaces call this on load; the admin edits the returned object in + * place while the config surface renders it read-only. + */ +export function readAllPolicies(p: Record): Required { + const out = {} as Required; + for (const def of policyDefs) { + out[def.key] = readPolicyBool(p, def.key); + } + return out; +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 62ef4263..7e667e70 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -69,8 +69,10 @@ import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte'; + import PolicyList from '$lib/components/PolicyList.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import { t } from '$lib/i18n/index.svelte'; + import { readPolicyBool } from '$lib/utils/drivePolicies'; import { session } from '$lib/stores/session.svelte'; import { drives as drivesStore } from '$lib/stores/drives.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -1091,14 +1093,6 @@ let managePoliciesError = $state(null); let managePoliciesBusy = $state(false); - function readPolicyBool(p: Record, key: string): boolean { - // JSONB returns unknown keys verbatim; default missing/non-bool to - // `false` so a freshly-created drive (empty `{}` bag) shows every - // toggle off without ad-hoc nullish handling per row. - const v = p[key]; - return typeof v === 'boolean' ? v : false; - } - function openManagePolicies(d: Drive) { managePoliciesDrive = d; managePoliciesError = null; @@ -1142,105 +1136,10 @@ } } - // Policy keys + labels for the toggle list. Mirrors the entity field - // order in `src/domain/entities/drive.rs` so a future 6th policy lands - // here as one literal-array push. - // - // `impliedBy` captures the semantic dependency between policies: when - // the named parent policy is on, this subordinate gate is moot - // (its enforcement is already covered by the broader rule). The UI - // disables the toggle and shows a hint so the admin understands the - // hierarchy without our having to actually mutate the stored value — - // their preference is preserved for the moment they relax the parent. - const policyDefs: Array<{ - key: keyof Required; - label: () => string; - help: () => string; - impliedBy?: keyof Required; - impliedHint?: () => string; - }> = [ - { - key: 'forbid_sharing', - label: () => t('admin.drive_policy.forbid_sharing', 'Forbid per-resource sharing'), - help: () => - t( - 'admin.drive_policy.forbid_sharing_help', - 'Block per-file / per-folder grants (covers public links and external sharing as well). Drive-level membership still works.' - ) - }, - { - key: 'forbid_public_links', - label: () => t('admin.drive_policy.forbid_public_links', 'Forbid public links'), - help: () => - t( - 'admin.drive_policy.forbid_public_links_help', - 'Block anonymous share links on resources in this drive.' - ), - impliedBy: 'forbid_sharing', - impliedHint: () => - t( - 'admin.drive_policy.implied_by_forbid_sharing', - 'Already enforced by Forbid per-resource sharing.' - ) - }, - { - key: 'forbid_external_sharing', - label: () => t('admin.drive_policy.forbid_external_sharing', 'Forbid external sharing'), - help: () => - t( - 'admin.drive_policy.forbid_external_sharing_help', - 'Block grants to external users (email invitations and pre-existing external accounts).' - ), - impliedBy: 'forbid_sharing', - impliedHint: () => - t( - 'admin.drive_policy.implied_by_forbid_sharing', - 'Already enforced by Forbid per-resource sharing.' - ) - }, - { - key: 'forbid_cross_drive_move', - label: () => t('admin.drive_policy.forbid_cross_drive_move', 'Forbid cross-drive move'), - help: () => - t( - 'admin.drive_policy.forbid_cross_drive_move_help', - 'Block moving files or folders out to another drive. Does not stop download + re-upload.' - ) - }, - { - key: 'forbid_owner_role_change', - label: () => t('admin.drive_policy.forbid_owner_role_change', 'Lock Owner roster'), - help: () => - t( - 'admin.drive_policy.forbid_owner_role_change_help', - 'Only admin can add, remove, or demote drive Owners while this is on.' - ) - }, - { - key: 'include_in_photo_index', - label: () => t('admin.drive_policy.include_in_photo_index', 'Include in Photos'), - help: () => - t( - 'admin.drive_policy.include_in_photo_index_help', - 'Show image and video files from this drive in the Photos timeline and on the Places map. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold photos (e.g. "Family Photos").' - ) - }, - { - key: 'include_in_music_index', - label: () => t('admin.drive_policy.include_in_music_index', 'Include in Music'), - help: () => - t( - 'admin.drive_policy.include_in_music_index_help', - 'Include audio files from this drive in the Music library. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold a music collection (e.g. "Family Music", "Band Collaboration").' - ) - } - ]; - - // Reactive helper for the template: is this policy currently - // disabled because its parent policy implies it? - function isPolicyImplied(def: (typeof policyDefs)[number]): boolean { - return def.impliedBy !== undefined && managePoliciesDraft[def.impliedBy]; - } + // Policy definitions live in `$lib/utils/drivePolicies` so the same + // list drives the admin "Manage policies" modal AND the read-only + // summary on `/config/drive/{uuid}`. Adding a policy is one literal- + // array push there + one field in `DrivePolicies` in `types.ts`. // Admin-driven delete-drive flow (D3b). Guarded by the confirm modal // because the action is destructive and irreversible. The backend @@ -2869,30 +2768,14 @@ 'Policies are admin-only — drive owners cannot mutate them. Each toggle controls one enforcement gate.' )}

-
    - {#each policyDefs as def (def.key)} - {@const implied = isPolicyImplied(def)} -
  • - -
  • - {/each} -
+ { + managePoliciesDraft[key] = next; + }} + /> {#if managePoliciesError}

{managePoliciesError}

{/if} @@ -3904,76 +3787,10 @@ white-space: nowrap; } - /* D5 policy editor (admin-only). Same row shape as `.owners-list__row` - so the modal feels consistent; the label inside is a flex row so the - checkbox sits beside the text instead of stacking vertically. */ - .policy-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-2); - } - - .policy-row { - padding: var(--space-2); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - } - - .policy-row__label { - /* Column layout: head (checkbox + title inline) on top, help - text underneath. The checkbox + title share a row via - `.policy-row__head` so the title sits beside the checkbox - instead of wrapping to its own line. */ - display: flex; - flex-direction: column; - gap: var(--space-1); - cursor: pointer; - margin: 0; - } - - .policy-row__head { - display: flex; - align-items: center; - gap: var(--space-2); - min-width: 0; - } - - .policy-row__head input[type='checkbox'] { - margin: 0; - flex-shrink: 0; - } - - .policy-row__title { - font-weight: 600; - } - - .policy-row__help { - /* Indent the help text under the title so the relationship is - visually obvious. Width = checkbox width + the head's gap. */ - padding-left: calc(1rem + var(--space-2)); - } - - /* Implied state — the row's gate is already covered by a broader - policy (e.g. forbid_public_links when forbid_sharing is on). - Visually dimmed so the admin understands they don't need to - toggle it; the stored value is preserved for the moment they - relax the parent policy. */ - .policy-row--implied { - opacity: 0.55; - } - - .policy-row--implied .policy-row__label { - cursor: not-allowed; - } - - .policy-row__implied { - display: block; - margin-top: var(--space-1); - font-style: italic; - } + /* Policy list styles moved to `PolicyList.svelte`. The modal now + embeds `` and the + read-only summary on `/config/drive/{uuid}` reuses the same + component. */ /* Drives table action cell — same shape as `.actions` plus a fixed 3-column grid so the [users] [policies] [delete] icons line up diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index 426e5ab8..88373d55 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -9,7 +9,8 @@ import { renameFolder } from '$lib/api/endpoints/folders'; import { errorToast } from '$lib/utils/errors'; import { ui } from '$lib/stores/ui.svelte'; - import type { Drive, DriveMember, DriveRole } from '$lib/api/types'; + import type { Drive, DriveMember, DriveRole, DrivePoliciesPartial } from '$lib/api/types'; + import PolicyList from '$lib/components/PolicyList.svelte'; import ShareDialog from '$lib/components/ShareDialog.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import Icon from '$lib/icons/Icon.svelte'; @@ -17,6 +18,7 @@ import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; import { formatDate } from '$lib/utils/display'; import { formatBytes } from '$lib/utils/format'; + import { readAllPolicies } from '$lib/utils/drivePolicies'; const uuid = $derived(page.params.uuid ?? ''); const drive = $derived(drivesStore.findById(uuid)); @@ -179,10 +181,15 @@ return Math.min(100, (drive.used_bytes / drive.quota_bytes) * 100); }); - // Drive policies are OxiCloud-admin-only post-D5 — owners can no - // longer mutate them, so this page no longer surfaces them at all - // (the admin panel hosts the policy editor). See - // `docs/plan/drive.md` §8. + // Drive policies are OxiCloud-admin-only for mutation (§8), but + // visible read-only here so members understand what rules apply to + // the drive they're on. The admin's "Manage policies" modal on + // `/admin` is the only editor. `readAllPolicies` normalises the raw + // JSONB bag into a `Required` — unknown keys + // (or missing ones) resolve to `false`. + const drivePoliciesView = $derived>( + readAllPolicies((drive?.policies ?? {}) as Record) + ); onMount(() => { void drivesStore.load(); @@ -386,6 +393,26 @@ {/if} + +
+ +

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

+ +
+

+ {t( + 'drive.policies_help', + "Rules an OxiCloud admin has set for this drive. Only admins can change them; you're seeing the current state." + )} +

+ +
+ {#if canDelete} -ymy)`t!iQFpm!IrZ^FG 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 delta 12932 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 diff --git a/tests/fixtures/plugins/panic.wasm b/tests/fixtures/plugins/panic.wasm index 18f47360214a7eec8232681c303fa5938328952e..d5b7c77edb1c02c335a7df7e3cbd4d83064119dc 100755 GIT binary patch delta 24358 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 agGeWkenN_BXJ~OqjgVR+bwauc>Hh(Lr&osn diff --git a/tests/fixtures/plugins/sleep.wasm b/tests/fixtures/plugins/sleep.wasm index a6527a356ec1b00c173e3d6c4dd19b193ca5af7b..1eeea87ee4d28fadb937d92bf6dd4b6191e4eb69 100755 GIT binary patch delta 24878 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 COOWaS delta 24758 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 R^d!>Xk=7!;fwTkZ{{cM42$28) delta 26459 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 FnResult { } /// Handler for the `file.uploaded` event. +/// +/// `#[plugin_fn]` rewrites the fn signature, so an outer `#[allow]` doesn't +/// reach the inner scope where `input` is bound — hence the `_` prefix on the +/// parameter. The well-behaved tail rebinds it as `input` locally. #[plugin_fn] -pub fn on_file_uploaded(input: String) -> FnResult { +pub fn on_file_uploaded(_input: String) -> FnResult { // --- misbehaving variants (compiled in only under their feature) --------- #[cfg(feature = "panic")] panic!("intentional panic: exercises host failure isolation"); @@ -53,27 +57,33 @@ pub fn on_file_uploaded(input: String) -> FnResult { } } - #[cfg(feature = "net")] + // The well-behaved tail is unreachable under the diverging variants above; + // gate it so the compiler doesn't flag input/tail as unused/dead. + #[cfg(not(any(feature = "panic", feature = "sleep")))] { - // Attempt an outbound HTTP call. The host grants no `allowed_hosts`, so - // Extism denies this before any socket is opened (offline-deterministic) - // and the error propagates out of the handler. - let req = HttpRequest::new("https://example.com/"); - let _ = http::request::<()>(&req, None)?; - } + let input = _input; - // --- well-behaved path --------------------------------------------------- - let ev: serde_json::Value = serde_json::from_str(&input)?; - let path = ev["payload"]["path"].as_str().unwrap_or(""); - let size = ev["payload"]["size"].as_u64().unwrap_or(0); + #[cfg(feature = "net")] + { + // Attempt an outbound HTTP call. The host grants no `allowed_hosts`, + // so Extism denies this before any socket is opened + // (offline-deterministic) and the error propagates out. + let req = HttpRequest::new("https://example.com/"); + let _ = http::request::<()>(&req, None)?; + } - unsafe { - log( - "info".to_string(), - format!("hello plugin saw upload: {path} ({size} bytes)"), - )?; + let ev: serde_json::Value = serde_json::from_str(&input)?; + let path = ev["payload"]["path"].as_str().unwrap_or(""); + let size = ev["payload"]["size"].as_u64().unwrap_or(0); + + unsafe { + log( + "info".to_string(), + format!("hello plugin saw upload: {path} ({size} bytes)"), + )?; + } + Ok(serde_json::json!({ "ok": true }).to_string()) } - Ok(serde_json::json!({ "ok": true }).to_string()) } /// Handler for the `user.login` event. Dropped by the `omit_login` variant so From 7e586e92be0bb050ee136b82f4cdefa31bb9f6a2 Mon Sep 17 00:00:00 2001 From: Nya Candy Date: Sat, 4 Jul 2026 10:13:08 +0800 Subject: [PATCH 19/49] feat: also publish docker image to ghcr --- .github/workflows/docker-publish.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 29882904..b1b060d0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -59,6 +59,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 360 needs: test + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v4 @@ -93,6 +96,13 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and Push Multi-Arch Image uses: docker/build-push-action@v6 with: @@ -102,6 +112,8 @@ jobs: tags: | ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} ${{ env.REGISTRY_IMAGE }}:latest + ghcr.io/${{ github.repository }}:${{ env.VERSION }} + ghcr.io/${{ github.repository }}:latest cache-from: type=gha cache-to: type=gha,mode=max # GitHub Actions env piped through so build.rs stamps From bde40c604209d9571958a9aacd33a7ce82c76dea Mon Sep 17 00:00:00 2001 From: albanobattistella <34811668+albanobattistella@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:49:59 +0200 Subject: [PATCH 20/49] Update Italian translation --- frontend/static/locales/it.json | 244 ++++++++++++++++---------------- 1 file changed, 122 insertions(+), 122 deletions(-) diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index b20cde82..7fc32d82 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -113,23 +113,23 @@ "loading": "Caricamento…", "search_error": "Impossibile caricare i file audio", "adding": "Aggiunta in corso…", - "can_write": "Can edit", - "cover_updated": "Cover updated", - "empty_hint": "Create your first playlist to start organizing your music", - "make_private": "Make private", - "make_public": "Make public", - "manage_shares": "Manage Shares", - "no_shares": "No shares yet", - "playback_error": "Playback failed", - "private": "Private", - "public": "Public", - "read_only": "Read only", - "remove": "Remove", - "remove_share": "Remove share", - "set_cover": "Set cover", - "share_with_user": "User ID or email", - "toggle_public": "Visibility", - "track_removed": "Track removed", + "can_write": "Può modificare", + "cover_updated": "Copertina aggiornata", + "empty_hint": "Crea la tua prima playlist per iniziare a organizzare la tua musica", + "make_private": "Rendi privata", + "make_public": "Rendi pubblica", + "manage_shares": "Gestisci condivisioni", + "no_shares": "Nessuna condivisione", + "playback_error": "Riproduzione non riuscita", + "private": "Privata", + "public": "Pubblica", + "read_only": "Solo lettura", + "remove": "Rimuovi", + "remove_share": "Rimuovi condivisione", + "set_cover": "Imposta copertina", + "share_with_user": "ID utente o email", + "toggle_public": "Visibilità", + "track_removed": "Brano rimosso", "prev": "Precedente" }, "actions": { @@ -163,10 +163,10 @@ "delete_permanently": "Elimina definitivamente", "empty_trash": "Svuota il cestino", "open_parent_folder": "Vai alla cartella padre", - "add": "Add", - "apply": "Apply", - "clear": "Clear", - "remove": "Remove" + "add": "Aggiungi", + "apply": "Applica", + "clear": "Svuota", + "remove": "Rimuovi" }, "user_menu": { "appearance": "Aspetto", @@ -208,30 +208,30 @@ "shareUpdated": "Impostazioni di condivisione aggiornate con successo", "shareRemoved": "Condivisione rimossa con successo", "inviteByEmail": "Invita via email — verrà inviato un invito", - "directoryUnavailable": "User directory unavailable", - "linkNamePlaceholder": "Link name (optional)", - "newLink": "New link", - "noExpiry": "No expiry", - "pending": "Pending", - "people": "People", - "publicLinks": "Public links", + "directoryUnavailable": "Elenco utenti non disponibile", + "linkNamePlaceholder": "Nome del link (opzionale)", + "newLink": "Nuovo link", + "noExpiry": "Nessuna scadenza", + "pending": "In attesa", + "people": "Persone", + "publicLinks": "Link pubblici", "role": { - "canEdit": "Can edit", - "canManage": "Can manage", - "canView": "Can view" + "canEdit": "Può modificare", + "canManage": "Può gestire", + "canView": "Può visualizzare" }, - "searchPlaceholder": "Search people…", - "shareOf": "Share of:", - "sharedLink": "Shared link", - "copied": "Link copied", + "searchPlaceholder": "Cerca persone…", + "shareOf": "Condivisione di:", + "sharedLink": "Link condiviso", + "copied": "Link copiato", "copy": "Copia", - "copy_failed": "Could not copy link", + "copy_failed": "Impossibile copiare il link", "download": "Scarica", "files": "File", "folders": "Cartelle", "link_name": "Link name (optional)", "notifyByEmail": "Notifica via email", - "revoke": "Remove", + "revoke": "Remuovi", "role_label": "Ruolo" }, "share_dialogTitle": "Link di condivisione", @@ -402,9 +402,9 @@ "notify": "Invia Notifica", "recipient": "Destinatario", "message": "Messaggio", - "go_to_parent": ".. (parent folder)", - "no_subfolders": "No subfolders", - "select_this_folder": "Select this folder", + "go_to_parent": ".. (cartella superiore)", + "no_subfolders": "Nessuna sottocartella", + "select_this_folder": "Seleziona questa cartella", "move_to_home": "Sposta nella cartella home" }, "dropzone": { @@ -582,8 +582,8 @@ "folder_deleted": "Cartella spostata nel cestino", "item_deleted_permanently": "Elemento eliminato definitivamente", "trash_emptied": "Cestino svuotato con successo", - "empty": "No notifications", - "title": "Notifications", + "empty": "Nessuna notifica", + "title": "Notifiche" "link_created": "Link creato", "share_success": "Link di condivisione creato con successo", "upload_files_section_title": "Caricamento non disponibile qui", @@ -904,16 +904,16 @@ "edit_photo": "Edit photo", "photo_tab_url": "URL", "photo_tab_upload": "Upload", - "photo_url_placeholder": "https://example.com/photo.jpg", - "photo_url_hint": "https://, http://, or data:image/…;base64,… accepted", - "photo_choose_file": "Choose a photo (PNG, JPEG, WebP)", - "photo_resize_note": "Images larger than 512 × 512 px are automatically resized.", - "photo_save": "Save photo", - "photo_remove": "Remove photo", - "photo_cancel": "Cancel", - "photo_save_failed": "Failed to save photo", - "photo_no_file": "Please select a file first", - "photo_managed_by_oidc": "Photo managed by your identity provider.", + "photo_url_placeholder": "https://example.com", + "photo_url_hint": "Si accettano indirizzi https://, http:// o data:image/…;base64,…", + "photo_choose_file": "Scegli una foto (PNG, JPEG, WebP)", + "photo_resize_note": "Le immagini superiori a 512 × 512 px vengono ridimensionate automaticamente.", + "photo_save": "Salva foto", + "photo_remove": "Rimuovi foto", + "photo_cancel": "Annulla", + "photo_save_failed": "Impossibile salvare la foto", + "photo_no_file": "Seleziona prima un file", + "photo_managed_by_oidc": "Foto gestita dal tuo fornitore di identità.", "password_mismatch": "Le password non corrispondono" }, "upload": { @@ -947,8 +947,8 @@ "createdAt": "Data di creazione", "size": "Dimensione", "favoriteDate": "Data preferito", - "byFiles": "By files", - "sharedWith": "Shared with", + "byFiles": "Per file", + "sharedWith": "Condiviso con" "justAdded": "Nuovo", "folders": "Cartelle" }, @@ -999,80 +999,80 @@ "notifyRateLimited": "Troppe notifiche per questo destinatario — riprova più tardi.", "removeAccess": "Rimuovi accesso", "resendInvitation": "Reinvia email di invito", - "publicLinks": "Public links" + "publicLinks": "Link pubblici" }, "sort": { "asc": "crescente", "desc": "decrescente" }, "notif": { - "errorTitle": "Error", - "searchError": "Error performing search", - "cleanupCompleted": "Cleanup completed", - "cleanupCompletedBody": "Recent files history has been cleared", - "batchCopy": "Batch copy", - "batchCopyBody": "{{success}} copied, {{errors}} failed", - "itemsCopied": "Items copied", - "itemsCopiedBody": "{{count}} items copied successfully", - "batchMove": "Batch move", - "batchMoveBody": "{{success}} moved, {{errors}} failed", - "itemsMoved": "Items moved", - "itemsMovedBody": "{{count}} items moved successfully", - "batchDelete": "Batch delete", - "batchDeleteBody": "{{success}} moved to trash, {{errors}} failed", - "movedToTrash": "Moved to trash", - "movedToTrashBody": "{{count}} items moved to trash", - "trashItemsError": "Could not move items to trash", - "preparingDownload": "Preparing download", - "preparingDownloadBody": "Preparing your download…", - "downloadItemsError": "Could not download selected items", - "favoritesAddError": "Could not add items to favorites", - "invalidEmail": "Please enter a valid email address", - "notificationSendError": "Could not send notification", - "folderCreated": "Folder created", - "folderCreatedBody": "\"{{name}}\" created successfully", - "fileMoved": "File moved", - "fileMovedBody": "File moved successfully", - "fileMoveError": "Error moving the file: {{error}}", - "fileMoveErrorGeneric": "Error moving the file", - "folderMoved": "Folder moved", - "folderMovedBody": "Folder moved successfully", - "folderMoveError": "Error moving the folder: {{error}}", - "folderMoveErrorGeneric": "Error moving the folder", - "fileCopied": "File copied", - "fileCopiedBody": "File copied successfully", - "fileCopyError": "Error copying the file: {{error}}", - "fileCopyErrorGeneric": "Error copying the file", - "folderRenamed": "Folder renamed", - "folderRenamedBody": "Folder renamed to \"{{name}}\"", - "fileTrashed": "File moved to trash", - "fileTrashedBody": "\"{{name}}\" moved to trash", - "fileDeleted": "File deleted", - "fileDeletedBody": "\"{{name}}\" deleted successfully", - "fileDeleteError": "Error deleting the file", - "folderTrashed": "Folder moved to trash", - "folderTrashedBody": "\"{{name}}\" moved to trash", - "folderDeleted": "Folder deleted", - "folderDeletedBody": "\"{{name}}\" deleted successfully", - "folderDeleteError": "Error deleting the folder", - "itemRestored": "Item restored", - "itemRestoredBody": "Item restored successfully", - "itemRestoreError": "Error restoring the item", - "itemDeleted": "Item deleted", - "itemDeletedBody": "Item permanently deleted", - "itemDeleteError": "Error deleting the item", - "trashEmptied": "Trash emptied", - "trashEmptiedBody": "The trash has been emptied successfully", - "trashEmptyError": "Error emptying the trash", - "cacheCleared": "Cache cleared", - "cacheClearedBody": "Search cache cleared successfully", - "cacheClearError": "Error clearing search cache", - "wopiOpenError": "Could not open the document editor.", - "linkCopied": "Link copied", - "linkCopiedBody": "Link copied to clipboard", - "linkCopyError": "Could not copy link", - "notificationSent": "Notification sent", - "notificationSentBody": "Notification sent to {{email}}" + "errorTitle": "Errore", + "searchError": "Errore durante la ricerca", + "cleanupCompleted": "Pulizia completata", + "cleanupCompletedBody": "La cronologia dei file recenti è stata svuotata", + "batchCopy": "Copia multipla", + "batchCopyBody": "{{success}} copiati, {{errors}} non riusciti", + "itemsCopied": "Elementi copiati", + "itemsCopiedBody": "{{count}} elementi copiati con successo", + "batchMove": "Spostamento multipla", + "batchMoveBody": "{{success}} spostati, {{errors}} non riusciti", + "itemsMoved": "Elementi spostati", + "itemsMovedBody": "{{count}} elementi spostati con successo", + "batchDelete": "Eliminazione multipla", + "batchDeleteBody": "{{success}} spostati nel cestino, {{errors}} non riusciti", + "movedToTrash": "Spostato nel cestino", + "movedToTrashBody": "{{count}} elementi spostati nel cestino", + "trashItemsError": "Impossibile spostare gli elementi nel cestino", + "preparingDownload": "Preparazione del download", + "preparingDownloadBody": "Preparazione del download in corso…", + "downloadItemsError": "Impossibile scaricare gli elementi selezionati", + "favoritesAddError": "Impossibile aggiungere gli elementi ai preferiti", + "invalidEmail": "Inserisci un indirizzo email valido", + "notificationSendError": "Impossibile inviare la notifica", + "folderCreated": "Cartella creata", + "folderCreatedBody": "\"{{name}}\" creata con successo", + "fileMoved": "File spostato", + "fileMovedBody": "File spostato con successo", + "fileMoveError": "Errore durante lo spostamento del file: {{error}}", + "fileMoveErrorGeneric": "Errore durante lo spostamento del file", + "folderMoved": "Cartella spostata", + "folderMovedBody": "Cartella spostata con successo", + "folderMoveError": "Errore durante lo spostamento della cartella: {{error}}", + "folderMoveErrorGeneric": "Errore durante lo spostamento della cartella", + "fileCopied": "File copiato", + "fileCopiedBody": "File copiato con successo", + "fileCopyError": "Errore durante la copia del file: {{error}}", + "fileCopyErrorGeneric": "Errore durante la copia del file", + "folderRenamed": "Cartella rinominata", + "folderRenamedBody": "Cartella rinominata in \"{{name}}\"", + "fileTrashed": "File spostato nel cestino", + "fileTrashedBody": "\"{{name}}\" spostato nel cestino", + "fileDeleted": "File eliminato", + "fileDeletedBody": "\"{{name}}\" eliminato con successo", + "fileDeleteError": "Errore durante l'eliminazione del file", + "folderTrashed": "Cartella spostata nel cestino", + "folderTrashedBody": "\"{{name}}\" spostata nel cestino", + "folderDeleted": "Cartella eliminata", + "folderDeletedBody": "\"{{name}}\" eliminata con successo", + "folderDeleteError": "Errore durante l'eliminazione della cartella", + "itemRestored": "Elemento ripristinato", + "itemRestoredBody": "Elemento ripristinato con successo", + "itemRestoreError": "Errore durante il ripristino dell'elemento", + "itemDeleted": "Elemento eliminato", + "itemDeletedBody": "Elemento eliminato definitivamente", + "itemDeleteError": "Errore durante l'eliminazione dell'elemento", + "trashEmptied": "Cestino svuotato", + "trashEmptiedBody": "Il cestino è stato svuotato con successo", + "trashEmptyError": "Errore durante lo svuotamento del cestino", + "cacheCleared": "Cache svuotata", + "cacheClearedBody": "Cache di ricerca svuotata con successo", + "cacheClearError": "Errore durante lo svuotamento della cache di ricerca", + "wopiOpenError": "Impossibile aprire l'editor di documenti.", + "linkCopied": "Link copiato", + "linkCopiedBody": "Link copiato negli appunti", + "linkCopyError": "Impossibile copiare il link", + "notificationSent": "Notifica inviata", + "notificationSentBody": "Notifica inviata a {{email}}" }, "category": { "audio": "Audio", From 3fdd2eaf1c43d4a52f0b01931f5596ddc60b818e Mon Sep 17 00:00:00 2001 From: Nya Candy Date: Sun, 5 Jul 2026 23:18:01 +0800 Subject: [PATCH 21/49] fix: workflow fail caused by uppercase of image name --- .github/workflows/docker-publish.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b1b060d0..f89151ad 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Docker Hub Release +name: Docker Hub & GHCR Release on: push: @@ -15,6 +15,7 @@ on: env: REGISTRY_IMAGE: diocrafts/oxicloud + GHCR_REGISTRY_IMAGE: ghcr.io/atalayalabs/oxicloud jobs: # Run tests before publishing @@ -112,8 +113,8 @@ jobs: tags: | ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} ${{ env.REGISTRY_IMAGE }}:latest - ghcr.io/${{ github.repository }}:${{ env.VERSION }} - ghcr.io/${{ github.repository }}:latest + ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} + ${{ env.GHCR_REGISTRY_IMAGE }}:latest cache-from: type=gha cache-to: type=gha,mode=max # GitHub Actions env piped through so build.rs stamps From ce10b83b64cdaa3a3bb2ee64cffdadae22adcc85 Mon Sep 17 00:00:00 2001 From: Nya Candy Date: Sun, 5 Jul 2026 23:36:06 +0800 Subject: [PATCH 22/49] chore: add quote to prevent possible yaml parse error --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f89151ad..1bd6e185 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Docker Hub & GHCR Release +name: "Docker Hub & GHCR Release" on: push: From f115fed5a656f7c9f86159b546d7bc25e51e83cd Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 3 Jul 2026 23:52:31 +0200 Subject: [PATCH 23/49] feat(drive): cleanup of useless owner_id --- .../services/file_upload_service.rs | 2 - src/domain/entities/file.rs | 22 ------ src/domain/entities/folder.rs | 77 ++----------------- .../pg/file_blob_read_repository.rs | 3 - .../pg/file_blob_write_repository.rs | 8 -- .../repositories/pg/folder_db_repository.rs | 6 -- 6 files changed, 7 insertions(+), 111 deletions(-) diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 3218a5f3..37dba99b 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -296,7 +296,6 @@ impl FileUploadService { parts.folder_id, parts.created_at, updated_at as u64, - parts.owner_id, new_hash, ) .map_err(|e| DomainError::internal_error("FileUpload", format!("rebuild entity: {e}")))?; @@ -467,7 +466,6 @@ impl FileUploadUseCase for FileUploadService { parts.folder_id, parts.created_at, updated_at as u64, - parts.owner_id, new_hash, ) .map_err(|e| { diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index 9c9ba7ce..e4a65884 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -22,7 +22,6 @@ pub struct FileParts { pub folder_id: Option, pub created_at: u64, pub modified_at: u64, - pub owner_id: Option, /// BLAKE3 content hash. See [`File::content_hash`] for semantics. pub blob_hash: String, /// §14 provenance: original creator. See [`File::created_by`]. @@ -70,9 +69,6 @@ pub struct File { /// Last modification timestamp (seconds since UNIX epoch) modified_at: u64, - /// Owner user ID (from storage.files.user_id) - owner_id: Option, - /// BLAKE3 content hash. Stable across renames/moves, changes only /// when the file's content bytes change. Source of truth for both /// content-addressable storage and the HTTP ETag (via @@ -109,7 +105,6 @@ impl Default for File { folder_id: None, created_at: 0, modified_at: 0, - owner_id: None, blob_hash: String::new(), created_by: None, updated_by: None, @@ -150,7 +145,6 @@ impl File { folder_id, created_at: now, modified_at: now, - owner_id: None, blob_hash: String::new(), created_by: None, updated_by: None, @@ -184,7 +178,6 @@ impl File { folder_id: parent_id, created_at, modified_at, - owner_id: None, blob_hash: String::new(), created_by: None, updated_by: None, @@ -201,7 +194,6 @@ impl File { folder_id: Option, created_at: u64, modified_at: u64, - owner_id: Option, ) -> FileResult { Self::with_timestamps_and_blob_hash( id, @@ -212,7 +204,6 @@ impl File { folder_id, created_at, modified_at, - owner_id, String::new(), ) } @@ -227,7 +218,6 @@ impl File { folder_id: Option, created_at: u64, modified_at: u64, - owner_id: Option, blob_hash: String, ) -> FileResult { Self::with_timestamps_blob_hash_and_provenance( @@ -239,7 +229,6 @@ impl File { folder_id, created_at, modified_at, - owner_id, blob_hash, None, None, @@ -259,7 +248,6 @@ impl File { folder_id: Option, created_at: u64, modified_at: u64, - owner_id: Option, blob_hash: String, created_by: Option, updated_by: Option, @@ -282,7 +270,6 @@ impl File { folder_id, created_at, modified_at, - owner_id, blob_hash, created_by, updated_by, @@ -304,7 +291,6 @@ impl File { folder_id: self.folder_id, created_at: self.created_at, modified_at: self.modified_at, - owner_id: self.owner_id, blob_hash: self.blob_hash, created_by: self.created_by, updated_by: self.updated_by, @@ -407,10 +393,6 @@ impl File { self.modified_at } - pub fn owner_id(&self) -> Option { - self.owner_id - } - /// User that originally created this file (§14 provenance). /// `None` when the referenced user has been deleted /// (FK is `ON DELETE SET NULL`) or for stub/DTO entities. @@ -455,7 +437,6 @@ impl File { folder_id, created_at, modified_at, - owner_id: None, blob_hash: String::new(), // DTO round-trips don't carry provenance; callers needing // it must reload from the repository. @@ -607,7 +588,6 @@ mod tests { None, 1_000, 2_000, - None, "abcdef0123456789ZZZZZZZZ".to_string(), ) .unwrap(); @@ -632,7 +612,6 @@ mod tests { None, 1_000, 2_000, - None, "shorthash".to_string(), ) .unwrap(); @@ -655,7 +634,6 @@ mod tests { None, 1_000, 2_000, - None, "stable-content-hash".to_string(), ) .unwrap(); diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 53242dc3..452609ae 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -25,10 +25,6 @@ pub struct Folder { /// Parent folder ID (None if it's a root folder) parent_id: Option, - /// Owner user ID — scopes folder visibility per user. - /// `None` only for legacy/stub folders; real folders always have an owner. - owner_id: Option, - /// Drive that owns this folder. Post-D0 every `storage.folders` row /// has `drive_id NOT NULL` (M3 migration). Path-based lookups scope /// by this axis (not by `user_id`, which is dropped in D7). @@ -76,7 +72,6 @@ impl Default for Folder { storage_path: StoragePath::from_string("/"), path_string: "/".to_string(), parent_id: None, - owner_id: None, drive_id: Uuid::nil(), created_at: 0, modified_at: 0, @@ -88,26 +83,20 @@ impl Default for Folder { } impl Folder { - /// Creates a new folder with validation + /// Creates a new folder with validation. + /// + /// In-memory constructor: callers that don't supply a `drive_id` + /// are by definition stub/legacy paths (tests, pre-D0 fixtures, + /// DTO round-trips). Real DB-backed folders flow through + /// [`Folder::with_timestamps_and_tree`] which propagates the + /// drive scope and §14 provenance from the row. pub fn new( id: String, name: String, storage_path: StoragePath, parent_id: Option, - ) -> FolderResult { - Self::new_with_owner(id, name, storage_path, parent_id, None) - } - - /// Creates a new folder with validation and an explicit owner. - pub fn new_with_owner( - id: String, - name: String, - storage_path: StoragePath, - parent_id: Option, - owner_id: Option, ) -> FolderResult { let name = normalize_storage_name(&name); - // Validate folder name if let Err(reason) = validate_storage_name(&name) { return Err(FolderError::InvalidFolderName(format!("{name}: {reason}"))); } @@ -117,7 +106,6 @@ impl Folder { .unwrap_or_default() .as_secs(); - // Store the path string for serialization compatibility let path_string = storage_path.to_string(); Ok(Self { @@ -126,17 +114,10 @@ impl Folder { storage_path, path_string, parent_id, - owner_id, - // In-memory constructor: callers that don't supply a - // drive_id are by definition stub/legacy paths (tests, - // pre-D0 fixtures, DTO round-trips). Real DB-backed - // folders flow through `with_timestamps_and_tree`. drive_id: Uuid::nil(), created_at: now, modified_at: now, tree_modified_at: now, - // Provenance is unknown for in-memory construction; the DB - // reconstruction path supplies real values. created_by: None, updated_by: None, }) @@ -160,34 +141,6 @@ impl Folder { name, storage_path, parent_id, - None, - Uuid::nil(), - created_at, - modified_at, - modified_at, - ) - } - - /// Creates a folder with specific timestamps and owner (legacy - /// constructor — `tree_modified_at` defaults to `modified_at`). - /// Prefer [`Folder::with_timestamps_and_tree`] for DB reconstruction - /// so the rollup ETag reflects descendant activity, not just this - /// row's own metadata. - pub fn with_timestamps_and_owner( - id: String, - name: String, - storage_path: StoragePath, - parent_id: Option, - owner_id: Option, - created_at: u64, - modified_at: u64, - ) -> FolderResult { - Self::with_timestamps_and_tree( - id, - name, - storage_path, - parent_id, - owner_id, Uuid::nil(), created_at, modified_at, @@ -209,7 +162,6 @@ impl Folder { name: String, storage_path: StoragePath, parent_id: Option, - owner_id: Option, drive_id: Uuid, created_at: u64, modified_at: u64, @@ -220,7 +172,6 @@ impl Folder { name, storage_path, parent_id, - owner_id, drive_id, created_at, modified_at, @@ -239,7 +190,6 @@ impl Folder { name: String, storage_path: StoragePath, parent_id: Option, - owner_id: Option, drive_id: Uuid, created_at: u64, modified_at: u64, @@ -260,7 +210,6 @@ impl Folder { storage_path, path_string, parent_id, - owner_id, drive_id, created_at, modified_at, @@ -299,10 +248,6 @@ impl Folder { self.modified_at } - pub fn owner_id(&self) -> Option { - self.owner_id - } - /// Drive that owns this folder. Path-based lookups scope by /// this axis (post-D0 invariant: `storage.folders.drive_id` /// is `NOT NULL`). @@ -412,7 +357,6 @@ impl Folder { storage_path, path_string: path, parent_id, - owner_id: None, // DTO round-trips lose drive_id (FolderDto carries it, // but the legacy `from_dto` signature predates this // change). Callers that need real scoping must reload @@ -460,7 +404,6 @@ impl Folder { storage_path: new_storage_path, path_string: new_path_string, parent_id: self.parent_id.clone(), - owner_id: self.owner_id, drive_id: self.drive_id, created_at: self.created_at, modified_at: now, @@ -501,7 +444,6 @@ impl Folder { storage_path: new_storage_path, path_string: new_path_string, parent_id, - owner_id: self.owner_id, drive_id: self.drive_id, created_at: self.created_at, modified_at: now, @@ -593,7 +535,6 @@ mod tests { "folder".to_string(), StoragePath::from_string("/folder"), None, - None, Uuid::nil(), 1_000, 2_000, @@ -615,7 +556,6 @@ mod tests { "a".to_string(), StoragePath::from_string("/a"), None, - None, Uuid::nil(), 0, 0, @@ -627,7 +567,6 @@ mod tests { "b".to_string(), StoragePath::from_string("/b"), None, - None, Uuid::nil(), 0, 0, @@ -650,7 +589,6 @@ mod tests { "folder".to_string(), StoragePath::from_string("/folder"), None, - None, Uuid::nil(), 1_000, 2_000, @@ -662,7 +600,6 @@ mod tests { "folder".to_string(), StoragePath::from_string("/folder"), None, - None, Uuid::nil(), 1_000, 2_000, diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 0aa2ea83..2de4393e 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -397,8 +397,6 @@ impl FileBlobReadRepository { } } - /// Post-D7-step-6: `storage.files.user_id` dropped; the entity's - /// legacy `user_id` field is populated with `None` here. #[allow(clippy::too_many_arguments)] fn row_to_file( id: String, @@ -423,7 +421,6 @@ impl FileBlobReadRepository { folder_id, created_at as u64, modified_at as u64, - None, // Post-D7: `files.user_id` column dropped. blob_hash, created_by, updated_by, diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 3de28382..e77be731 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -104,7 +104,6 @@ impl FileBlobWriteRepository { mime_type: String, created_at: i64, modified_at: i64, - owner_id: Option, blob_hash: String, created_by: Option, updated_by: Option, @@ -119,7 +118,6 @@ impl FileBlobWriteRepository { folder_id, created_at as u64, modified_at as u64, - owner_id, blob_hash, created_by, updated_by, @@ -399,7 +397,6 @@ impl FileBlobWriteRepository { content_type, created_at, updated_at, - None, // Post-D7: `files.user_id` no longer written on new rows. blob_hash.to_string(), created_by, updated_by, @@ -477,7 +474,6 @@ impl FileBlobWriteRepository { mime_type, created_at, updated_at, - None, blob_hash.to_string(), created_by, updated_by, @@ -562,7 +558,6 @@ impl FileWritePort for FileBlobWriteRepository { row.4, row.5, row.6, - None, String::new(), row.7, row.8, @@ -710,7 +705,6 @@ impl FileWritePort for FileBlobWriteRepository { row.4, row.5, row.6, - None, row.7, row.8, row.9, @@ -773,7 +767,6 @@ impl FileWritePort for FileBlobWriteRepository { row.4, row.5, row.6, - None, String::new(), row.7, row.8, @@ -874,7 +867,6 @@ impl FileWritePort for FileBlobWriteRepository { content_type, row.1, row.2, - None, // Post-D7: `files.user_id` no longer written on new rows. String::new(), row.3, row.4, diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 7cd92f8a..4dc65f9d 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -129,11 +129,6 @@ impl FolderDbRepository { /// extra queries needed. `created_by` / `updated_by` carry the /// §14 provenance signal through the entity layer; both are /// `Option` because the FK is `ON DELETE SET NULL`. - /// - /// Post-D7-step-6: the `storage.folders.user_id` column is gone; - /// the entity's legacy `user_id` field is populated with `None` - /// at construction time (removed in the follow-up entity - /// cleanup PR). #[allow(clippy::too_many_arguments)] fn row_to_folder( id: String, @@ -153,7 +148,6 @@ impl FolderDbRepository { name, storage_path, parent_id, - None, // Post-D7: `folders.user_id` column dropped. drive_id, created_at as u64, modified_at as u64, From cf5423b7232dc3a3ffcc46e3dfb2fb1e3e3af4fd Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 4 Jul 2026 19:33:02 +0200 Subject: [PATCH 24/49] security(public link): require share permission issue was: a user can reshare publicly a resource on owner revocation, the attacker keep it's own share request now share permission --- src/application/services/share_service.rs | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 28ea3a27..69c56039 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -6,7 +6,7 @@ use uuid::Uuid; use crate::domain::repositories::drive_repository::DriveRepository; use crate::domain::repositories::folder_repository::FolderRepository; -use crate::domain::services::authorization::{Resource, Role, Subject}; +use crate::domain::services::authorization::{Permission, Resource, Role, Subject}; use crate::infrastructure::repositories::pg::DrivePgRepository; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; @@ -243,6 +243,28 @@ impl ShareUseCase for ShareService { self.verify_item_exists(&dto.item_id, &item_type).await?; + // AuthZ: only callers with `Share` on the resource may mint a + // public link. Without this gate, an ex-Viewer who kept a + // guessed UUID could launder a temporary read into a + // permanent anonymous URL that survives their own grant + // revocation. `Permission::Share` is bundled with the + // `owner` and `editor` role_grants only. `require` returns + // `not_found` on denial (anti-enum, matches the shape used + // by every other share route). See `docs/plan/authz_audit/`. + let item_uuid_for_authz = Uuid::parse_str(&dto.item_id) + .map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?; + let resource_for_authz = match item_type { + ShareItemType::File => Resource::File(item_uuid_for_authz), + ShareItemType::Folder => Resource::Folder(item_uuid_for_authz), + }; + self.authorization + .require( + Subject::User(user_id), + Permission::Share, + resource_for_authz, + ) + .await?; + // D5: `forbid_public_links` policy gate. The drive owner can // disable anonymous-link creation on every resource in their // drive without per-resource intervention. Lookup is one JOIN From 2cda8e7e224f6337e0e25b2fb4b49074a040b68f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 4 Jul 2026 19:36:17 +0200 Subject: [PATCH 25/49] security(favorite,recent): ensure read permission --- src/application/services/favorites_service.rs | 57 ++++++++----- src/application/services/recent_service.rs | 37 ++++++--- src/common/di.rs | 80 +++++++++++-------- src/domain/services/authorization.rs | 28 +++++++ 4 files changed, 138 insertions(+), 64 deletions(-) diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index e8970b42..ac372436 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -9,10 +9,12 @@ use crate::application::dtos::favorites_dto::{ BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow, FavoritesCursor, }; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; -use crate::common::errors::{DomainError, ErrorKind, Result}; -use crate::domain::services::authorization::ResourceKind; +use crate::common::errors::Result; +use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject}; use crate::infrastructure::repositories::pg::FavoritesPgRepository; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; /// Implementation of the FavoritesUseCase for managing user favorites. /// @@ -20,12 +22,22 @@ use crate::infrastructure::repositories::pg::FavoritesPgRepository; /// accessing the database directly, following hexagonal architecture. pub struct FavoritesService { repo: Arc, + /// ReBAC engine — enforces `Permission::Read` on the referenced + /// file/folder before enrolling it into a user's favorites. + /// Without this gate the write path is an information oracle: + /// listing endpoints JOIN back to `storage.files/folders` and + /// return name/mime/size/drive_id for any UUID the caller was + /// able to enroll. See `docs/plan/authz_audit/rest_storage.md`. + authorization: Arc, } impl FavoritesService { /// Create a new FavoritesService with the given repository port - pub fn new(repo: Arc) -> Self { - Self { repo } + pub fn new(repo: Arc, authorization: Arc) -> Self { + Self { + repo, + authorization, + } } /// Subset of `(item_id, item_type)` pairs the user has favorited — used to @@ -60,13 +72,15 @@ impl FavoritesUseCase for FavoritesService { item_type, item_id, user_id ); - if item_type != "file" && item_type != "folder" { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "Favorites", - "Item type must be 'file' or 'folder'", - )); - } + // AuthZ pre-write: caller must have Read on the referenced + // resource. Denial routes through `require` → NotFound + // (anti-enum, matches the listing shape) + `authz.denied` + // audit line. Without this gate the write path was an + // information oracle over the whole tenant. + let resource = Resource::parse(item_type, item_id)?; + self.authorization + .require(Subject::User(user_id), Permission::Read, resource) + .await?; self.repo.add_favorite(user_id, item_id, item_type).await?; info!( @@ -125,18 +139,17 @@ impl FavoritesUseCase for FavoritesService { user_id ); - // Validate all item types + // AuthZ pre-write: caller must have Read on every referenced + // resource. Fail the whole batch on the first denial so the + // response shape doesn't tell an attacker which items were + // valid (partial success would leak the same oracle we + // closed on the single-item path). See + // `docs/plan/authz_audit/rest_storage.md`. for (item_id, item_type) in items { - if item_type != "file" && item_type != "folder" { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "Favorites", - format!( - "Item type must be 'file' or 'folder' for item '{}'", - item_id - ), - )); - } + let resource = Resource::parse(item_type, item_id)?; + self.authorization + .require(Subject::User(user_id), Permission::Read, resource) + .await?; } let requested = items.len(); diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs index 17f0bd2f..54482606 100644 --- a/src/application/services/recent_service.rs +++ b/src/application/services/recent_service.rs @@ -1,10 +1,12 @@ use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow}; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase}; use crate::application::ports::resource_access_hook::ResourceAccessHook; -use crate::common::errors::{DomainError, ErrorKind, Result}; -use crate::domain::services::authorization::ResourceKind; +use crate::common::errors::Result; +use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject}; use crate::infrastructure::repositories::pg::RecentItemsPgRepository; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use std::sync::{Arc, OnceLock}; use tracing::info; use uuid::Uuid; @@ -16,6 +18,13 @@ use uuid::Uuid; pub struct RecentService { repo: Arc, max_recent_items: i32, + /// ReBAC engine — enforces `Permission::Read` on the referenced + /// file/folder before enrolling it into a user's Recent list. + /// The listing side JOINs back to `storage.files/folders` and + /// returns name/mime/size/drive_id for any enrolled UUID, so + /// the write path is an information oracle without this gate. + /// See `docs/plan/authz_audit/rest_storage.md`. + authorization: Arc, /// Set after construction via [`Self::set_resource_access_hook`]. /// The hook is built FROM this service (it wraps an `Arc`), so /// we can't take it as a constructor arg without circular ownership; @@ -28,10 +37,15 @@ pub struct RecentService { impl RecentService { /// Create a new recent items service - pub fn new(repo: Arc, max_recent_items: i32) -> Self { + pub fn new( + repo: Arc, + authorization: Arc, + max_recent_items: i32, + ) -> Self { Self { repo, max_recent_items: max_recent_items.clamp(1, 100), + authorization, resource_access_hook: OnceLock::new(), } } @@ -87,13 +101,16 @@ impl RecentItemsUseCase for RecentService { item_type, item_id, user_id ); - if item_type != "file" && item_type != "folder" { - return Err(DomainError::new( - ErrorKind::InvalidInput, - "RecentItems", - "Item type must be 'file' or 'folder'", - )); - } + // AuthZ pre-write: caller must have Read on the referenced + // resource. Denial routes through `require` → NotFound + // (anti-enum) + `authz.denied` audit line. Without this + // gate the write path was an information oracle over the + // whole tenant via the listing endpoint's JOIN back to + // storage.files/folders. + let resource = Resource::parse(item_type, item_id)?; + self.authorization + .require(Subject::User(user_id), Permission::Read, resource) + .await?; self.repo.upsert_access(user_id, item_id, item_type).await?; self.repo.prune(user_id, self.max_recent_items).await?; diff --git a/src/common/di.rs b/src/common/di.rs index a7547134..8ac3a379 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -889,23 +889,37 @@ impl AppServiceFactory { Some(service) } - /// Creates the favorites service (requires database) - pub fn create_favorites_service(&self, db_pool: &Arc) -> Arc { + /// Creates the favorites service (requires database + authz engine + /// for the Read gate on `add_to_favorites` — see the post-Drive + /// AuthZ audit). + pub fn create_favorites_service( + &self, + db_pool: &Arc, + authorization: &Arc, + ) -> Arc { let repo = Arc::new( crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()), ); - let service = Arc::new(FavoritesService::new(repo)); + let service = Arc::new(FavoritesService::new(repo, authorization.clone())); tracing::info!("Favorites service initialized"); service } - /// Creates the recent items service (requires database) - pub fn create_recent_service(&self, db_pool: &Arc) -> Arc { + /// Creates the recent items service (requires database + authz + /// engine for the Read gate on `record_item_access` — see the + /// post-Drive AuthZ audit). + pub fn create_recent_service( + &self, + db_pool: &Arc, + authorization: &Arc, + ) -> Arc { let repo = Arc::new( crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()), ); let service = Arc::new(RecentService::new( - repo, 50, // Maximum recent items per user + repo, + authorization.clone(), + 50, // Maximum recent items per user )); tracing::info!("Recent items service initialized"); service @@ -1161,31 +1175,6 @@ impl AppServiceFactory { let pool = Arc::new(pools.primary); let maintenance_pool = Arc::new(pools.maintenance); - // Recent service + recording hook are built up-front so the - // hook can be threaded into `create_application_services` below. - // The file services hold the hook directly so every authorised - // `_with_perms` read/write fires into `auth.user_recent_files` - // without per-handler wiring. Reordering vs the legacy in-block - // creation (further down) is safe: `create_recent_service` only - // needs `pool`, which is already in scope. - // - // The back-edge `recent_service_eager.set_resource_access_hook` - // closes the loop so the clear/remove handlers can drop the - // hook's in-memory throttle entries — without it a freshly - // cleared Recent list refuses to re-record the same file for a - // full TTL window, surfacing as "I cleared, opened the file, - // and Recent is still empty" (caught by tests/api/recent.hurl - // step 8). - let recent_service_eager = self.create_recent_service(&pool); - let resource_access_hook: Arc< - dyn crate::application::ports::resource_access_hook::ResourceAccessHook, - > = Arc::new( - crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new( - recent_service_eager.clone(), - ), - ); - recent_service_eager.set_resource_access_hook(resource_access_hook.clone()); - // 1. Core services (PgPool needed for DedupService index) let core = self.create_core_services(&pool, &maintenance_pool).await?; @@ -1196,6 +1185,10 @@ impl AppServiceFactory { // because services hold an Arc for ReBAC checks. // SubjectGroupPgRepository is constructed here too so the engine can // expand a user's transitive group set on cache misses. + // + // Moved above the eager recent-service build so `create_recent_service` + // can receive an `Arc` — the Read gate on + // `record_item_access` (post-Drive AuthZ audit fix) needs it. let subject_group_repo = Arc::new( crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()), ); @@ -1206,6 +1199,29 @@ impl AppServiceFactory { subject_group_repo.clone(), ); + // Recent service + recording hook are built up-front so the + // hook can be threaded into `create_application_services` below. + // The file services hold the hook directly so every authorised + // `_with_perms` read/write fires into `auth.user_recent_files` + // without per-handler wiring. + // + // The back-edge `recent_service_eager.set_resource_access_hook` + // closes the loop so the clear/remove handlers can drop the + // hook's in-memory throttle entries — without it a freshly + // cleared Recent list refuses to re-record the same file for a + // full TTL window, surfacing as "I cleared, opened the file, + // and Recent is still empty" (caught by tests/api/recent.hurl + // step 8). + let recent_service_eager = self.create_recent_service(&pool, &authorization); + let resource_access_hook: Arc< + dyn crate::application::ports::resource_access_hook::ResourceAccessHook, + > = Arc::new( + crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new( + recent_service_eager.clone(), + ), + ); + recent_service_eager.set_resource_access_hook(resource_access_hook.clone()); + // Drive repository — needed both by the lifecycle hook (when auth // is enabled) and by `GET /api/drives` on the final `AppState`, // so declared at the outer scope. @@ -1279,7 +1295,7 @@ impl AppServiceFactory { > = None; { - let favs = self.create_favorites_service(&pool); + let favs = self.create_favorites_service(&pool, &authorization); favorites_service = Some(favs.clone()); apps.favorites_service = Some(favs); diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index c0502dda..0e9a6a3d 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -118,6 +118,34 @@ impl Resource { _ => None, } } + + /// Parse `(item_type, item_id)` from an API-facing pair of strings + /// (favorites, recent, batch endpoints all take this shape). + /// Combines UUID parse + type mapping so callers stay one-line and + /// error shapes are identical across surfaces. Returns + /// `DomainError::new(InvalidInput, …)` on malformed input; callers + /// that need the anti-enum 404 shape do that separately by feeding + /// the parsed `Resource` into `authz.require(...)`. + pub fn parse( + item_type: &str, + item_id: &str, + ) -> Result { + use crate::common::errors::{DomainError, ErrorKind}; + let uuid = Uuid::parse_str(item_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Resource", + format!("Invalid item UUID '{item_id}'"), + ) + })?; + Self::from_parts(item_type, uuid).ok_or_else(|| { + DomainError::new( + ErrorKind::InvalidInput, + "Resource", + format!("Unsupported item type '{item_type}'"), + ) + }) + } } impl fmt::Display for Resource { From b95e740b2f31d12f6aeff1d868f9227031b34d58 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 4 Jul 2026 23:31:10 +0200 Subject: [PATCH 26/49] security(music): ensure read permission via authz --- src/application/services/music_service.rs | 34 +++++++- src/common/di.rs | 2 +- .../api/handlers/favorites_handler.rs | 50 +++++------- src/interfaces/api/handlers/recent_handler.rs | 42 +++------- tests/api/favorites.hurl | 74 +++++++++++++++++ tests/api/public_shares.hurl | 79 +++++++++++++++++++ tests/api/recent.hurl | 64 +++++++++++++++ 7 files changed, 280 insertions(+), 65 deletions(-) diff --git a/src/application/services/music_service.rs b/src/application/services/music_service.rs index 78ce6757..d4d4456a 100644 --- a/src/application/services/music_service.rs +++ b/src/application/services/music_service.rs @@ -5,17 +5,31 @@ use crate::application::dtos::playlist_dto::{ AddTracksDto, AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto, PlaylistQueryDto, PlaylistShareInfoDto, ReorderTracksDto, SharePlaylistDto, UpdatePlaylistDto, }; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::music_ports::{MusicStoragePort, MusicUseCase}; use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; pub struct MusicService { storage: Arc, + /// ReBAC engine — Round 1 fix from `docs/plan/authz_audit/`. + /// Currently used ONLY by `get_audio_metadata` to close the + /// cross-tenant IDOR (`_user_id: Uuid` was deliberately unused). + /// The full engine rewrite (Round 3 — `Resource::Playlist` + + /// authz.require on every playlist verb) is a separate PR; + /// don't extend the bespoke `user_has_access` / `user_can_write` + /// pattern to new methods, use `require` here instead. + authorization: Arc, } impl MusicService { - pub fn new(storage: Arc) -> Self { - Self { storage } + pub fn new(storage: Arc, authorization: Arc) -> Self { + Self { + storage, + authorization, + } } } @@ -375,10 +389,24 @@ impl MusicUseCase for MusicService { async fn get_audio_metadata( &self, file_id: &str, - _user_id: Uuid, + caller_id: Uuid, ) -> Result, DomainError> { let file_uuid = Uuid::parse_str(file_id) .map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Music", "Invalid file ID"))?; + // AuthZ pre-read: caller must have `Read` on the underlying + // audio file. Before this check the endpoint returned + // metadata for any known file id (cross-tenant IDOR — the + // `_user_id` parameter was deliberately unused). `require` + // returns 404 on denial to match the anti-enum shape used + // everywhere else. Post-Drive AuthZ audit fix (Round 1 + // BLOCKER — `docs/plan/authz_audit/rest_storage.md`). + self.authorization + .require( + Subject::User(caller_id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; self.storage.get_audio_metadata(&file_uuid).await } } diff --git a/src/common/di.rs b/src/common/di.rs index 8ac3a379..959ad7c2 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1865,7 +1865,7 @@ impl AppServiceFactory { audio_metadata_repo, ), ); - let music_svc = Arc::new(MusicService::new(music_storage)); + let music_svc = Arc::new(MusicService::new(music_storage, authorization.clone())); app_state.music_service = Some(music_svc); tracing::info!("Music service initialized"); } diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 72b97a8b..cb887a58 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -6,7 +6,7 @@ use axum::{ }; use serde::Deserialize; use std::sync::Arc; -use tracing::{error, info}; +use tracing::info; use utoipa::ToSchema; use crate::application::dtos::display_helpers::{ @@ -66,7 +66,8 @@ pub async fn add_favorite( Json(serde_json::json!({ "error": "Item type must be 'file' or 'folder'" })), - ); + ) + .into_response(); } match favorites_service @@ -81,16 +82,14 @@ pub async fn add_favorite( "message": "Item added to favorites" })), ) + .into_response() } - Err(err) => { - error!("Error adding to favorites: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to add to favorites" - })), - ) - } + // Route through AppError so the `DomainError::kind` maps to the + // right status code (NotFound → 404 anti-enum for the pre-write + // authz gate, InvalidInput → 400 for a malformed UUID, etc.). + // A hardcoded 500 here would mask the 404 the Round 1 AuthZ + // fix relies on. + Err(err) => AppError::from(err).into_response(), } } @@ -129,6 +128,7 @@ pub async fn remove_favorite( "message": "Item removed from favorites" })), ) + .into_response() } else { info!("Item {} '{}' was not in favorites", item_type, item_id); ( @@ -137,17 +137,12 @@ pub async fn remove_favorite( "message": "Item was not in favorites" })), ) + .into_response() } } - Err(err) => { - error!("Error removing from favorites: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to remove from favorites" - })), - ) - } + // Same rationale as `add_favorite` — preserve DomainError→HTTP + // status mapping instead of collapsing every error to 500. + Err(err) => AppError::from(err).into_response(), } } @@ -347,15 +342,10 @@ pub async fn batch_add_favorites( ); (StatusCode::OK, Json(serde_json::json!(result))).into_response() } - Err(err) => { - error!("Error in batch add favorites: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to batch add favorites" - })), - ) - .into_response() - } + // Preserve DomainError→HTTP status mapping — the Round 1 + // AuthZ fix relies on a per-item NotFound propagating out + // of the batch. A hardcoded 500 would mask the 404 that + // signals a cross-tenant probe. + Err(err) => AppError::from(err).into_response(), } } diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 3878e783..690548d7 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -5,7 +5,7 @@ use axum::{ response::IntoResponse, }; use std::sync::Arc; -use tracing::{error, info}; +use tracing::info; use crate::application::dtos::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, @@ -70,16 +70,10 @@ pub async fn record_item_access( ) .into_response() } - Err(err) => { - error!("Error recording access in recents: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to record access" - })), - ) - .into_response() - } + // Preserve DomainError→HTTP status mapping — the Round 1 + // AuthZ fix relies on the NotFound from `authz.require` + // propagating as 404 (anti-enum), not being masked as 500. + Err(err) => AppError::from(err).into_response(), } } @@ -130,16 +124,9 @@ pub async fn remove_from_recent( .into_response() } } - Err(err) => { - error!("Error removing from recents: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to remove from recents" - })), - ) - .into_response() - } + // Same rationale as `record_item_access` — preserve the + // DomainError→HTTP mapping instead of collapsing to 500. + Err(err) => AppError::from(err).into_response(), } } @@ -170,16 +157,9 @@ pub async fn clear_recent_items( ) .into_response() } - Err(err) => { - error!("Error clearing recent items: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Failed to clear recent items" - })), - ) - .into_response() - } + // Same rationale as `record_item_access` — preserve the + // DomainError→HTTP mapping instead of collapsing to 500. + Err(err) => AppError::from(err).into_response(), } } diff --git a/tests/api/favorites.hurl b/tests/api/favorites.hurl index 6c8d8d5f..d8609ab8 100644 --- a/tests/api/favorites.hurl +++ b/tests/api/favorites.hurl @@ -159,3 +159,77 @@ Authorization: Bearer {{token}} HTTP 200 [Asserts] jsonpath "$.items" count == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cross-tenant regression (post-Drive AuthZ audit, +# Round 1 HIGH). Before this fix, `POST /api/favorites/…` +# accepted any UUID and enrolled it; the listing endpoint +# then JOINed back to storage.files/folders and returned +# name/mime/size/drive_id for anything the caller had +# managed to add — an information oracle over the whole +# tenant. Now the write path calls `authz.require(Read, …)` +# per item; a caller with no grant gets 404 (anti-enum) +# + `authz.denied` audit line. See +# `docs/plan/authz_audit/rest_storage.md`. +# ───────────────────────────────────────────────────────────── + +# Create a second, unprivileged user. Idempotent: `HTTP *` accepts +# either 201 (first run) or 409 (subsequent runs). The login below +# is the actual precondition — if it succeeds we know the user +# exists with the expected password. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{token}} +Content-Type: application/json +{ "username": "fav_mallory", "password": "FavMalloryPassword1!", "email": "fav_mallory@example.com", "role": "user" } + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "fav_mallory", "password": "FavMalloryPassword1!" } + +HTTP 200 +[Captures] +mallory_token: jsonpath "$.access_token" + + +# Step 12a — Single-add on admin's file: 404 (anti-enum shape). +POST {{base_url}}/api/favorites/file/{{file_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 12b — Single-add on admin's folder: 404. +POST {{base_url}}/api/favorites/folder/{{test1_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 12c — Batch: must fail wholesale on the first denial. A partial +# success would still leak "which items are valid" — the same +# oracle we're closing. +POST {{base_url}}/api/favorites/batch +Authorization: Bearer {{mallory_token}} +Content-Type: application/json +{ + "items": [ + { "item_id": "{{file_id}}", "item_type": "file" }, + { "item_id": "{{test1_id}}", "item_type": "folder" } + ] +} + +HTTP 404 + + +# Step 12d — Mallory's favorites list is EMPTY — no partial success +# slipped through. +GET {{base_url}}/api/favorites/resources +Authorization: Bearer {{mallory_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 0 diff --git a/tests/api/public_shares.hurl b/tests/api/public_shares.hurl index 81b02edc..641f2073 100644 --- a/tests/api/public_shares.hurl +++ b/tests/api/public_shares.hurl @@ -274,6 +274,85 @@ status >= 400 status < 500 +# ───────────────────────────────────────────────────────────── +# 14b — Viewer-laundering regression (post-Drive AuthZ audit, +# Round 1 HIGH). Before the fix, `POST /api/shares` checked +# only "does the item exist" — any authenticated user who +# could name the UUID could mint a public Viewer link, +# laundering read access into a permanent anonymous URL +# that survived their own grant revocation. Now the +# service calls `authz.require(Share, resource)` before +# minting the token; a caller without `Share` +# (Viewer/Commenter/Contributor/no-grant-at-all) gets 404 +# (anti-enum) + `authz.denied` audit line. See +# `docs/plan/authz_audit/admin_membership.md`. +# +# We test the strongest form: an unrelated user with no +# grant at all. The intermediate case (Viewer with Read +# but not Share) is covered by the same code path — Share +# is bundled only with owner/editor role_grants. +# ───────────────────────────────────────────────────────────── + +# Create/lookup the attacker. Idempotent: `HTTP *` accepts either +# 201 (first run) or 409 (subsequent runs). Login below is the real +# precondition. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "sh_mallory", "password": "ShMalloryPassword1!", "email": "sh_mallory@example.com", "role": "user" } + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "sh_mallory", "password": "ShMalloryPassword1!" } + +HTTP 200 +[Captures] +mallory_token: jsonpath "$.access_token" + + +# Step 14b.i — Mallory tries to mint a public share on admin's +# folder: 404 (anti-enum). No token appears in the +# response body. +POST {{base_url}}/api/shares +Authorization: Bearer {{mallory_token}} +Content-Type: application/json +{ + "item_id": "{{share_folder_id}}", + "item_name": "public-share-test", + "item_type": "folder" +} + +HTTP 404 + + +# Step 14b.ii — Same attempt on admin's file: 404. +POST {{base_url}}/api/shares +Authorization: Bearer {{mallory_token}} +Content-Type: application/json +{ + "item_id": "{{shared_file_id}}", + "item_name": "hello.txt", + "item_type": "file" +} + +HTTP 404 + + +# Step 14b.iii — Mallory has no shares — no partial success slipped +# through. (`GET /api/shares` returns only shares the +# caller created; response is paginated.) +GET {{base_url}}/api/shares +Authorization: Bearer {{mallory_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" isCollection +jsonpath "$.items" count == 0 + + # ───────────────────────────────────────────────────────────── # 15 — Teardown: revoke the password share + the direct # file-share, then delete the folder. diff --git a/tests/api/recent.hurl b/tests/api/recent.hurl index 4f423908..f290aba4 100644 --- a/tests/api/recent.hurl +++ b/tests/api/recent.hurl @@ -187,3 +187,67 @@ DELETE {{base_url}}/api/recent/clear Authorization: Bearer {{token}} HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Cross-tenant regression (post-Drive AuthZ audit, +# Round 1 HIGH). Before this fix, `POST /api/recent/…` +# accepted any UUID and the listing endpoint JOINed back +# to storage.files/folders (name/mime/size/drive_id) — a +# metadata oracle over the whole tenant. Now the write +# path calls `authz.require(Read, …)`; unauthorised +# callers get 404 (anti-enum) + `authz.denied` audit line. +# See `docs/plan/authz_audit/rest_storage.md`. +# ───────────────────────────────────────────────────────────── + +# Re-discover a folder id so the attacker has TWO targets to probe +# (file + folder). Same test1 folder as favorites.hurl. +GET {{base_url}}/api/folders/{{home_folder_id}}/resources?resource_types=folder +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +test1_id: jsonpath "$.items[0].resource.id" + + +# Create/lookup the attacker. Idempotent: `HTTP *` accepts either +# 201 (first run) or 409 (subsequent runs). Login below is the real +# precondition. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{token}} +Content-Type: application/json +{ "username": "rec_mallory", "password": "RecMalloryPassword1!", "email": "rec_mallory@example.com", "role": "user" } + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "rec_mallory", "password": "RecMalloryPassword1!" } + +HTTP 200 +[Captures] +mallory_token: jsonpath "$.access_token" + + +# Step 10a — Record admin's file into mallory's recent: 404. +POST {{base_url}}/api/recent/file/{{file_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 10b — Same for admin's folder: 404. +POST {{base_url}}/api/recent/folder/{{test1_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 10c — Mallory's recent list stays empty. +GET {{base_url}}/api/recent/resources +Authorization: Bearer {{mallory_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 0 From 0342bae300e0892abc4443aa46c1d18b29007eae Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 4 Jul 2026 23:57:06 +0200 Subject: [PATCH 27/49] security(nextcloud): add authz to PUT verb --- src/application/ports/file_ports.rs | 7 +- .../services/file_upload_service.rs | 87 ++++++++++++++++++- src/common/stubs.rs | 2 +- src/interfaces/api/handlers/webdav_handler.rs | 2 +- src/interfaces/api/handlers/wopi_handler.rs | 2 +- src/interfaces/nextcloud/uploads_handler.rs | 19 ++-- src/interfaces/nextcloud/webdav_handler.rs | 2 +- 7 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 454baebc..f4920106 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -75,7 +75,12 @@ pub trait FileUploadUseCase: Send + Sync + 'static { /// `updated_by` column reflects the principal that performed the /// PUT — not the file's existing owner (D2 shared drives let /// non-owners overwrite content). - async fn update_file_streaming( + /// `_with_perms` suffix (AGENTS.md AuthZ convention): the + /// implementation calls `authz.require(caller, Update, File(id))` + /// on the overwrite branch and `authz.require(caller, Create, + /// Folder|Drive(id))` on the new-file branch. Handlers just plumb + /// `caller_id` through — no protocol-layer authz. + async fn update_file_streaming_with_perms( &self, path: &str, drive_id: Uuid, diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 37dba99b..a6ac8209 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -42,6 +42,16 @@ pub struct FileUploadService { /// `(file_id, blob_hash, content_type)`; the recording side needs the /// `caller_id` the service already has in hand. resource_access_hook: Option>, + /// ReBAC engine — enforces `Permission::Update` on + /// overwrite-existing and `Permission::Create` on new-file paths + /// inside `update_file_streaming_with_perms`. Optional at the + /// struct level for the minimal test constructors (`new`, + /// `new_with_read`) but the WebDAV/NC/WOPI put paths refuse + /// (fail-closed internal error) if this isn't wired. Set by + /// either `with_instant_upload` or `with_authorization` — both + /// stash the same Arc so DI callers wiring instant upload get + /// the streaming gate for free. + authorization: Option>, /// Dependencies of the instant-upload path /// (`create_file_from_owned_blob_with_perms`); `None` in minimal test /// wiring. @@ -66,6 +76,7 @@ impl FileUploadService { content_cache: None, file_lifecycle_hook: None, resource_access_hook: None, + authorization: None, instant_upload: None, } } @@ -82,18 +93,34 @@ impl FileUploadService { content_cache: None, file_lifecycle_hook: None, resource_access_hook: None, + authorization: None, instant_upload: None, } } + /// Wires the authorization engine used by + /// `update_file_streaming_with_perms` on the WebDAV / NC / WOPI + /// PUT path. Independent of `with_instant_upload` so callers can + /// enable the streaming gate without also opting into the + /// dedup-instant-upload check (test wiring, minimal deployments). + pub fn with_authorization(mut self, authz: Arc) -> Self { + self.authorization = Some(authz); + self + } + /// Wires the authorization engine, dedup index and quota service that /// power the instant-upload path. + /// + /// Also stashes the `authz` handle in `self.authorization` so + /// DI callers wiring instant upload get the streaming-put gate + /// for free — a single `Arc` clone, no behavioural coupling. pub fn with_instant_upload( mut self, authz: Arc, dedup: Arc, quota: Arc, ) -> Self { + self.authorization = Some(authz.clone()); self.instant_upload = Some(InstantUploadDeps { authz, dedup, @@ -424,7 +451,16 @@ impl FileUploadUseCase for FileUploadService { /// Swap the content of the file at `path` to an already-ingested blob, /// creating the file when it doesn't exist (WebDAV/NextCloud/WOPI PUT). - async fn update_file_streaming( + /// + /// AuthZ (post-Drive audit Round 2 fix): overwrite path requires + /// `Update` on the target file; new-file path requires `Create` + /// on the parent folder (or on the drive when writing at drive + /// root). Fail-closed if the engine wasn't wired — this method + /// is the last line of defence between a Viewer/Commenter drive + /// member and cross-tenant PUT. See + /// `docs/plan/authz_audit/nextcloud.md` and the sibling native + /// `/webdav/*` handler. + async fn update_file_streaming_with_perms( &self, path: &str, drive_id: Uuid, @@ -433,10 +469,33 @@ impl FileUploadUseCase for FileUploadService { modified_at: Option, caller_id: Uuid, ) -> Result { + let Some(authz) = &self.authorization else { + return Err(DomainError::internal_error( + "FileUpload", + "update_file_streaming_with_perms called without authorization engine wired", + )); + }; + // Try to find the existing file first if let Some(file_read) = &self.file_read && let Some(file) = file_read.find_file_by_path(path, drive_id).await? { + // Overwrite branch — caller must have `Update` on the + // target file. Denial routes through `require` → 404 + // (anti-enum, matches read-side shape). Before the D7 + // audit this whole branch ran unchecked; Viewer members + // of shared drives could PUT freely. + let file_uuid = Uuid::parse_str(file.id()).map_err(|_| { + DomainError::internal_error("FileUpload", "invalid file id from repository") + })?; + authz + .require( + Subject::User(caller_id), + Permission::Update, + Resource::File(file_uuid), + ) + .await?; + let file_id = file.id().to_string(); let (new_hash, updated_at) = self .file_write @@ -505,6 +564,32 @@ impl FileUploadUseCase for FileUploadService { None }; + // Create branch — caller must have `Create` on the parent + // scope. Two cases: + // * `parent_id.is_some()` → caller needs Create on the + // parent Folder resource. + // * `parent_id.is_none()` → the write lands at the drive + // root (either the path was single-segment, or the + // parent-folder lookup failed). We require Create on + // the Drive itself — bundled with owner/editor/contributor + // role_grants, refused for viewer/commenter. + let create_resource = match &parent_id { + Some(pid) => { + let uuid = Uuid::parse_str(pid).map_err(|_| { + DomainError::internal_error("FileUpload", "invalid parent folder id") + })?; + Resource::Folder(uuid) + } + None => Resource::Drive(drive_id), + }; + authz + .require( + Subject::User(caller_id), + Permission::Create, + create_resource, + ) + .await?; + let is_new_blob = blob.is_new_blob; let created = self .file_write diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 2cde43f9..bdd15bf5 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -500,7 +500,7 @@ impl FileUploadUseCase for StubFileUploadUseCase { Ok(FileDto::default()) } - async fn update_file_streaming( + async fn update_file_streaming_with_perms( &self, _path: &str, _drive_id: Uuid, diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 1aac0907..27286d73 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -1756,7 +1756,7 @@ async fn handle_put( // internally via its `_with_perms` shape. let content_type = ingested.content_type.clone(); let result = file_upload_service - .update_file_streaming( + .update_file_streaming_with_perms( &path, drive_id, ingested.stored(), diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index 92147a51..ab4f98df 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -264,7 +264,7 @@ async fn put_file( .app_state .applications .file_upload_service - .update_file_streaming( + .update_file_streaming_with_perms( &file.path, drive_id, ingested.stored(), diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index f21a613a..414d8d15 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -367,12 +367,14 @@ async fn handle_assemble( let chroot = session.require_chroot()?; let drive_id = chroot.drive_id; - // TODO(D1): read the caller's default-drive root folder name from - // `drives.root_folder_id` instead of hardcoding "Personal". The - // constant is correct for every default personal drive provisioned - // by the D0 lifecycle hook, but secondary drives (M2 backfill from - // SQL-created sibling root folders) keep their original name. - let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/')); + // Route through `nc_to_internal_path(chroot, …)` so the write + // lands under the caller's actual default-drive root (not the + // literal "Personal" folder). Post-D3 chroot resolution puts the + // correct FolderDto — including the drive's real root name — on + // the NcSession; secondary drives with SQL-provisioned sibling + // root names now work. + let internal_path = + crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &dest_subpath)?; let filename = filename_from_path(&dest_subpath).to_string(); let ingested = ingest_stream_to_cas( @@ -393,7 +395,7 @@ async fn handle_assemble( let etag: Option = if existing.is_ok() { let dto = upload_service - .update_file_streaming( + .update_file_streaming_with_perms( &internal_path, drive_id, ingested.stored(), @@ -412,7 +414,8 @@ async fn handle_assemble( Some((p, n)) => (p, n), None => ("", dest_subpath.as_str()), }; - let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/')); + let parent_internal = + crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, parent_sub)?; let parent_internal = parent_internal.trim_end_matches('/'); use crate::application::ports::folder_ports::FolderUseCase; diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 9e2382c9..a898f3ba 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -871,7 +871,7 @@ async fn handle_put( // Single streaming path — handles both update and create internally, // swapping the file row onto the already-ingested blob. let stored = upload_service - .update_file_streaming( + .update_file_streaming_with_perms( &internal_path, chroot.drive_id, ingested.stored(), From 1786fe4111e1af8a71e436647d246acfc94ac115 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 5 Jul 2026 22:52:26 +0200 Subject: [PATCH 28/49] security(nextcloud): chroot-aware display paths + recent race fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strip_chroot_prefix replaces the hardcoded "Personal/" strip in NC trashbin PROPFIND, OCS unified search, and REPORT (favorites + search). Handles composed chroots, drops cross-chroot items instead of surfacing malformed paths, and fixes the leading-slash mismatch (FolderDto path has '/', DB paths don't) that silently dropped every NC trashbin item post-D3. OCS keeps a first-segment fallback (results legitimately span drives, no single chroot). uploads_handler switches to nc_to_internal_path(chroot, …) for the two remaining hardcoded "Personal/" sites, closing the D1 TODO markers. RecentService::record_item_access is split from a new record_item_access_internal (no authz) used by RecentRecordingHook. Round 1's authz.require widened the tokio::spawn race past tests/api/recent.hurl step 7; the internal path skips the redundant Read gate — upstream _with_perms already enforced it. Tests: 8 unit tests pin strip_chroot_prefix (leading slash, composed chroots, sibling-leak rejection, partial-prefix, empty-chroot). drives_membership.hurl step 21b/22b cover Editor upload → 201 / Viewer upload → 404 fresh + overwrite with fixture cleanup at 30c. test_nc_move_copy_delete_trash K1 pins the actual original-location value. --- src/application/services/recent_service.rs | 46 ++++- .../services/recent_recording_hook.rs | 12 +- src/interfaces/nextcloud/ocs_handler.rs | 24 +-- src/interfaces/nextcloud/report_handler.rs | 96 ++++++++--- src/interfaces/nextcloud/trashbin_handler.rs | 72 ++++++-- src/interfaces/nextcloud/webdav_handler.rs | 158 ++++++++++++++++++ tests/api/drives_membership.hurl | 77 ++++++++- .../webdav/test_nc_move_copy_delete_trash.sh | 15 +- 8 files changed, 446 insertions(+), 54 deletions(-) diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs index 54482606..dbb9f610 100644 --- a/src/application/services/recent_service.rs +++ b/src/application/services/recent_service.rs @@ -3,7 +3,7 @@ use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentRe use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase}; use crate::application::ports::resource_access_hook::ResourceAccessHook; -use crate::common::errors::Result; +use crate::common::errors::{DomainError, Result}; use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject}; use crate::infrastructure::repositories::pg::RecentItemsPgRepository; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; @@ -67,6 +67,41 @@ impl RecentService { hook.on_recents_cleared(user_id); } } + + /// Record access to an item WITHOUT the pre-write `authz.require` + /// gate. Callers must have gated the caller's Read upstream — this + /// method exists for the `RecentRecordingHook` fast path: writes + /// that reach the hook have already passed a `_with_perms` service + /// method (uploads, streams, GETs, etc.), so re-checking here + /// would be pure duplicate work AND widen the race window between + /// the POST response and the `tokio::spawn`ed upsert ( + /// `tests/api/recent.hurl` step 7 hits this — the extra SQL + /// round-trip pushes the upsert past the client's immediate + /// `GET /api/recent/resources`). + /// + /// **Do NOT call this from an externally-reachable handler.** The + /// REST endpoint goes through the trait method `record_item_access` + /// below, which enforces the Read gate per AGENTS.md convention. + pub async fn record_item_access_internal( + &self, + user_id: Uuid, + item_id: &str, + item_type: &str, + ) -> Result<()> { + // Type validation only — no authz, no resource parse for the + // engine (the hook path is already resource-typed by construction). + if item_type != "file" && item_type != "folder" { + return Err(DomainError::new( + crate::common::errors::ErrorKind::InvalidInput, + "RecentItems", + "Item type must be 'file' or 'folder'", + )); + } + + self.repo.upsert_access(user_id, item_id, item_type).await?; + self.repo.prune(user_id, self.max_recent_items).await?; + Ok(()) + } } impl RecentItemsUseCase for RecentService { @@ -107,13 +142,18 @@ impl RecentItemsUseCase for RecentService { // gate the write path was an information oracle over the // whole tenant via the listing endpoint's JOIN back to // storage.files/folders. + // + // Internal hook callers (RecentRecordingHook) bypass the + // trait entry point and call `record_item_access_internal` + // directly — Read has already been enforced upstream on + // whatever `_with_perms` service produced the access event. let resource = Resource::parse(item_type, item_id)?; self.authorization .require(Subject::User(user_id), Permission::Read, resource) .await?; - self.repo.upsert_access(user_id, item_id, item_type).await?; - self.repo.prune(user_id, self.max_recent_items).await?; + self.record_item_access_internal(user_id, item_id, item_type) + .await?; info!( "Successfully recorded access to {} '{}' for user {}", diff --git a/src/infrastructure/services/recent_recording_hook.rs b/src/infrastructure/services/recent_recording_hook.rs index f6ee9cb3..041e3737 100644 --- a/src/infrastructure/services/recent_recording_hook.rs +++ b/src/infrastructure/services/recent_recording_hook.rs @@ -29,7 +29,6 @@ use std::time::Duration; use moka::sync::Cache; use uuid::Uuid; -use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::ports::resource_access_hook::ResourceAccessHook; use crate::application::services::recent_service::RecentService; @@ -85,7 +84,16 @@ impl ResourceAccessHook for RecentRecordingHook { let recent = Arc::clone(&self.recent); let (caller_id, file_id) = key; tokio::spawn(async move { - if let Err(e) = recent.record_item_access(caller_id, &file_id, "file").await { + // Fast path: skip the trait's `authz.require(Read, …)` + // (upstream `_with_perms` service already gated). The + // extra SQL round-trip pushes the upsert past the client's + // immediate `GET /api/recent/resources` in + // `tests/api/recent.hurl` step 7 — the whole reason for + // the internal variant. + if let Err(e) = recent + .record_item_access_internal(caller_id, &file_id, "file") + .await + { tracing::warn!( target: "oxicloud::recent", caller_id = %caller_id, diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index e673288b..73fc0394 100644 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -422,13 +422,17 @@ pub async fn handle_search( let mut entries: Vec = Vec::new(); - // Map file results - // TODO(D1): drop the hardcoded "Personal/" prefix and read the - // caller's default-drive root folder name from `drives.root_folder_id` - // instead. Correct for D0-provisioned default drives; secondary - // drives keep their original root name. + // Map file results. + // + // `strip_drive_root_segment` handles both default and secondary + // drives — post-D0 the first path segment is the drive's root + // folder name (`"Personal"` for D0-provisioned defaults, the + // original sibling-root name for M2 backfilled secondaries). + // Read-scope is upstream in `state.applications.search_service`; + // this handler only formats display paths. for file in &results.files { - let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path); + let display_path = + crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&file.path); let display_path = format!("/{}", display_path); let numeric_id = file_id_map.get(&file.id).copied(); @@ -452,12 +456,10 @@ pub async fn handle_search( })); } - // Map folder results — same TODO(D1) as above. + // Map folder results — same drive-agnostic strip as above. for folder in &results.folders { - let display_path = folder - .path - .strip_prefix("Personal/") - .unwrap_or(&folder.path); + let display_path = + crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&folder.path); let display_path = format!("/{}", display_path); entries.push(json!({ diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index ec60a8dc..39f913c0 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -62,6 +62,15 @@ async fn handle_filter_files( ) -> Result, AppError> { let user = &session.user; let url_user = &session.raw_username; + // Chroot-scope the response: NC's `oc:filter-files` REPORT is a + // single-drive surface (the client PROPFINDs favorites under its + // "home" URL and has no cross-drive concept). Favorites that live + // in another drive the caller is a member of are dropped from + // this response; they're still reachable via REST + // `/api/favorites/resources`. `session.require_chroot()` is safe + // here — the REPORT verb only reaches this handler through a + // path-scoped route. + let chroot = session.require_chroot()?; let fav_svc = match state.favorites_service.as_ref() { Some(svc) => svc, None => return Ok(empty_multistatus()), @@ -84,11 +93,11 @@ async fn handle_filter_files( // All items in this response are favorites. let favorite_ids: HashSet = favorites.iter().map(|f| f.item_id.clone()).collect(); - // TODO(D1): replace the hardcoded "Personal/" prefix with the - // caller's default-drive root folder name read from - // `drives.root_folder_id`. Correct for D0-provisioned default - // drives; secondary drives keep their original root name. - let home_prefix = "Personal/"; + // `home_prefix` is unused after the chroot-aware strip + // (see `strip_home_prefix`); kept as a positional argument in + // the emit calls below for signature stability with the + // report-handler tests and the parallel search-pass caller. + let home_prefix = ""; // Pass 1: resolve the favorited DTOs in two batch queries (was one // get_* per favorite — up to N serial round-trips on a sync client's @@ -155,7 +164,17 @@ async fn handle_filter_files( // multi-drive `~{drive}` form is echoed back to the client; // owner-id stays canonical via `&user.username`. for file in &files { - let subpath = strip_home_prefix(&file.path, home_prefix); + // Skip favorites that live outside the caller's chroot + // (other-drive favorites); reachable via REST if needed. + let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else { + tracing::debug!( + target: "oxicloud::nc", + "REPORT filter-files: dropping cross-chroot favorite '{}' at '{}'", + file.id, + file.path, + ); + continue; + }; let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); @@ -172,7 +191,15 @@ async fn handle_filter_files( } for folder in &folders { - let subpath = strip_home_prefix(&folder.path, home_prefix); + let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else { + tracing::debug!( + target: "oxicloud::nc", + "REPORT filter-files: dropping cross-chroot favorite folder '{}' at '{}'", + folder.id, + folder.path, + ); + continue; + }; let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); @@ -207,9 +234,13 @@ async fn handle_search( session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { let user = &session.user; - // Validate chroot up-front (path-scoped handler); `resolve_scope_folder` - // below re-pulls it from the session for the path-mapping step. - session.require_chroot()?; + // Chroot-scope the response: NC's search REPORT is a single-drive + // surface. Results that live outside the chroot (other drives the + // caller is a member of) are dropped from the multistatus and + // recorded at debug — reachable via REST search if needed. + // `resolve_scope_folder` below re-pulls chroot from the session + // for the path-mapping step. + let chroot = session.require_chroot()?; let url_user = &session.raw_username; let search_svc = match state.applications.search_service.as_ref() { Some(svc) => svc, @@ -241,10 +272,9 @@ async fn handle_search( let nc = state.nextcloud.as_ref(); let file_id_svc = nc.map(|n| &n.file_ids); - // TODO(D1): same as the favorites pass above — replace the - // hardcoded "Personal/" with the caller's actual default-drive - // root folder name from `drives.root_folder_id`. - let home_prefix = "Personal/"; + // See the favorites pass above: `home_prefix` is unused after the + // chroot-aware strip, kept only for signature stability. + let home_prefix = ""; // No favorite checking for search results -- pass an empty set. let favorite_ids: HashSet = HashSet::new(); @@ -266,7 +296,15 @@ async fn handle_search( // Files. for file in &files { - let subpath = strip_home_prefix(&file.path, home_prefix); + let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else { + tracing::debug!( + target: "oxicloud::nc", + "REPORT search: dropping cross-chroot file '{}' at '{}'", + file.id, + file.path, + ); + continue; + }; let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); @@ -284,7 +322,15 @@ async fn handle_search( // Folders. for folder in &folders { - let subpath = strip_home_prefix(&folder.path, home_prefix); + let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else { + tracing::debug!( + target: "oxicloud::nc", + "REPORT search: dropping cross-chroot folder '{}' at '{}'", + folder.id, + folder.path, + ); + continue; + }; let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); @@ -545,7 +591,19 @@ fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option { None } -/// Strip the `My Folder - {username}/` prefix to get the DAV subpath. -fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str { - path.strip_prefix(prefix).unwrap_or(path) +/// Strip the caller's chroot prefix from an internal path so the +/// caller-facing DAV subpath is chroot-relative. Delegates to +/// `webdav_handler::strip_chroot_prefix` — chroot-aware, multi-segment +/// safe, and rejects items outside the chroot. Callers must decide +/// per-response whether an out-of-chroot item is dropped or falls +/// back to the naive strip. +/// +/// See `strip_chroot_prefix` for the full contract. The `_prefix` +/// legacy arg stays for signature stability with the emit helpers. +fn strip_home_prefix<'a>( + chroot: &crate::application::dtos::folder_dto::FolderDto, + path: &'a str, + _prefix: &str, +) -> Option<&'a str> { + crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, path) } diff --git a/src/interfaces/nextcloud/trashbin_handler.rs b/src/interfaces/nextcloud/trashbin_handler.rs index 6a7077ed..6a09a1c3 100644 --- a/src/interfaces/nextcloud/trashbin_handler.rs +++ b/src/interfaces/nextcloud/trashbin_handler.rs @@ -81,6 +81,14 @@ async fn handle_propfind( session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { let user = &session.user; + // Chroot-scope the trashbin view: `get_trash_items(user.id)` + // spans every drive the caller is a member of, but NC's + // trashbin surface is a single-drive concept from the client's + // POV. Items outside the chroot are dropped from the multistatus + // (see `write_trashbin_multistatus` → `strip_home_prefix` → + // `webdav_handler::strip_chroot_prefix`) and remain reachable + // via REST `/api/trash/resources`. + let chroot = session.require_chroot()?; let trash_svc = state .trash_service .as_ref() @@ -95,7 +103,7 @@ async fn handle_propfind( let file_id_svc = nc.map(|n| &n.file_ids); let mut buf = Vec::new(); - write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc) + write_trashbin_multistatus(&mut buf, &items, &user.username, chroot, file_id_svc) .await .map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?; @@ -259,18 +267,23 @@ fn mime_from_name(name: &str) -> String { .to_string() } -/// Strip the home-folder prefix from an original path to produce the -/// Nextcloud-relative original location. +/// Strip the caller's chroot prefix from an original path to produce +/// the Nextcloud-relative original-location value. /// -/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual -/// default-drive root folder name read from `drives.root_folder_id`. -/// Correct for D0-provisioned default drives; secondary drives keep -/// their original root name. The `_username` arg stays for now so the -/// upcoming dynamic lookup has a way to identify the caller. -fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str { - original_path - .strip_prefix("Personal/") - .unwrap_or(original_path) +/// Delegates to `webdav_handler::strip_chroot_prefix` — chroot-aware, +/// multi-segment safe, and returns `None` when the item is outside +/// the chroot (e.g. a trashed item in another drive the caller is a +/// member of). The `_username` arg stays for signature stability +/// with call sites that thread it; the strip itself no longer uses it. +/// +/// See the doc on `strip_chroot_prefix` for the AuthZ caveat — this +/// is a display helper, not an ownership check. +fn strip_home_prefix<'a>( + original_path: &'a str, + _username: &str, + chroot: &crate::application::dtos::folder_dto::FolderDto, +) -> Option<&'a str> { + crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, original_path) } // ────────────── Trashbin PROPFIND XML Generation ────────────── @@ -280,10 +293,16 @@ use crate::application::services::nextcloud_file_id_service::NextcloudFileIdServ use std::collections::HashMap; /// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin. +/// +/// `chroot` scopes the response — items whose original path is outside +/// the chroot (other drives the caller is a member of) are dropped +/// silently. NC's trashbin surface is single-drive from the client's +/// perspective; cross-drive items remain reachable via REST. async fn write_trashbin_multistatus( writer: W, items: &[TrashedItemDto], username: &str, + chroot: &crate::application::dtos::folder_dto::FolderDto, file_id_svc: Option<&Arc>, ) -> Result<(), String> { let mut xml = Writer::new(writer); @@ -315,9 +334,24 @@ async fn write_trashbin_multistatus( batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await; id_map.extend(folder_id_map); - // Individual trashed items. + // Individual trashed items — skip those whose original path is + // outside the chroot (other-drive trash reachable via REST). for item in items { - write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?; + if crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix( + chroot, + &item.original_path, + ) + .is_none() + { + tracing::debug!( + target: "oxicloud::nc", + "trashbin PROPFIND: dropping cross-chroot item '{}' at '{}'", + item.id, + item.original_path, + ); + continue; + } + write_trash_item_response(&mut xml, item, username, chroot, file_id_svc, &id_map)?; } xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) @@ -363,10 +397,18 @@ fn write_trash_root_response( } /// Write a single trashed item as a `` element. +/// +/// Caller is expected to have already verified the item is inside +/// `chroot` — see the guard in `write_trashbin_multistatus`. This +/// function trusts the invariant and expects `strip_home_prefix` to +/// return `Some(_)`; if it ever returns `None` (chroot drift between +/// the guard and the emit, defensive-only), the original-location +/// falls back to an empty string. fn write_trash_item_response( xml: &mut Writer, item: &TrashedItemDto, username: &str, + chroot: &crate::application::dtos::folder_dto::FolderDto, file_id_svc: Option<&Arc>, id_map: &HashMap, ) -> Result<(), String> { @@ -427,7 +469,7 @@ fn write_trash_item_response( write_text_element(xml, "nc:trashbin-filename", &item.name)?; // nc:trashbin-original-location - let original_location = strip_home_prefix(&item.original_path, username); + let original_location = strip_home_prefix(&item.original_path, username, chroot).unwrap_or(""); write_text_element(xml, "nc:trashbin-original-location", original_location)?; // nc:trashbin-deletion-time diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index a898f3ba..74eba460 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -80,6 +80,78 @@ pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result(chroot: &FolderDto, internal_path: &'a str) -> Option<&'a str> { + // Normalize both sides: `FolderDto.path` comes from + // `StoragePath::to_string()` which prepends a leading `/` + // (e.g. `"/Personal"`), but DB-side paths coming from + // `storage.folders.path` (composed by the `compute_folder_path` + // trigger) never have a leading slash. Trim both so `"/Personal"` + // vs `"Personal/g9-tree"` matches the intended prefix. + let root = chroot.path.trim_matches('/'); + if root.is_empty() { + // Guard against a mis-set chroot with an empty root path — + // stripping "" from anything would return the whole path. + return None; + } + let path = internal_path.trim_start_matches('/'); + let rest = path.strip_prefix(root)?; + // Reject a partial prefix match — a chroot of "Personal" must + // not match an item at "PersonalSecrets/…". + match rest.strip_prefix('/') { + Some(subpath) => Some(subpath), + // Item path equals the chroot exactly — the chroot itself + // (i.e. a folder) is not a legitimate response item, so + // treat as an empty subpath. + None if rest.is_empty() => Some(""), + None => None, + } +} + +/// Naive fallback: strip the first path segment from an internal +/// `storage.folders.path`. Post-D0 every path starts with its drive's +/// root folder name (single segment), so for the current schema this +/// gives the drive-relative subpath. +/// +/// Use this ONLY when the caller doesn't have a chroot in scope +/// (e.g. OCS unified search, whose results legitimately span every +/// drive the caller has Read on — no single chroot covers them all). +/// Every path-scoped NC handler that DOES have `session` in scope +/// should prefer [`strip_chroot_prefix`] — it validates the item +/// belongs under the chroot instead of trusting the schema +/// invariant, and it survives a future composed chroot like +/// `"Personal/folderA/subfolder"`. +/// +/// **Not an AuthZ boundary.** Same caveat as `strip_chroot_prefix` +/// — AuthZ is enforced upstream via `_with_perms` methods; this +/// helper only formats display strings. +/// +/// Returns `""` when the path is a single segment (i.e. the drive +/// root itself, which is never a legitimate item target). +pub fn strip_drive_root_segment(internal_path: &str) -> &str { + match internal_path.split_once('/') { + Some((_root, rest)) => rest, + None => "", + } +} + /// Build the Nextcloud DAV href for a **collection** (folder). Always /// terminates with `/` — RFC 4918 §5.2 requires collection URLs to end /// in a slash, and the Nextcloud desktop client strictly enforces this @@ -1873,6 +1945,92 @@ mod tests { ); } + // ── strip_chroot_prefix ── + // + // Regression guard for the "chroot.path has a leading slash from + // StoragePath::to_string() but DB-side original_path doesn't" trap + // that broke the NC trashbin PROPFIND after Round 2 rolled out. + // Also pins the composed-chroot behaviour Ed asked about. + + #[test] + fn strip_chroot_prefix_default_drive_root() { + // FolderDto.path carries a leading slash (StoragePath Display); + // DB paths do not. Both must normalise to the same prefix. + let chroot = stub_folder("/Personal"); + assert_eq!( + strip_chroot_prefix(&chroot, "Personal/g9-tree"), + Some("g9-tree") + ); + } + + #[test] + fn strip_chroot_prefix_deep_path() { + let chroot = stub_folder("/Personal"); + assert_eq!( + strip_chroot_prefix(&chroot, "Personal/inner/deep.txt"), + Some("inner/deep.txt") + ); + } + + #[test] + fn strip_chroot_prefix_out_of_chroot_returns_none() { + // Items on a different drive (whose root isn't "Personal") + // must NOT be surfaced under the caller's chroot. + let chroot = stub_folder("/Personal"); + assert_eq!(strip_chroot_prefix(&chroot, "team-drive/report.pdf"), None); + } + + #[test] + fn strip_chroot_prefix_rejects_partial_prefix_match() { + // "Personal" is a prefix substring of "PersonalSecrets" but + // NOT a path-segment prefix — must reject. + let chroot = stub_folder("/Personal"); + assert_eq!( + strip_chroot_prefix(&chroot, "PersonalSecrets/foo.txt"), + None + ); + } + + #[test] + fn strip_chroot_prefix_composed_chroot() { + // The future composed-chroot case Ed raised: chroot points at + // a subfolder inside a drive. The strip must remove the ENTIRE + // composed prefix, not just the first segment. + let chroot = stub_folder("/Personal/folderA/subfolder"); + assert_eq!( + strip_chroot_prefix(&chroot, "Personal/folderA/subfolder/foo.txt"), + Some("foo.txt") + ); + } + + #[test] + fn strip_chroot_prefix_composed_chroot_sibling_leaks_blocked() { + // Same composed chroot, but the item lives in a sibling + // subfolder — must be rejected, not naively strip 1 segment. + let chroot = stub_folder("/Personal/folderA/subfolder"); + assert_eq!( + strip_chroot_prefix(&chroot, "Personal/folderA/other/foo.txt"), + None + ); + } + + #[test] + fn strip_chroot_prefix_chroot_root_itself() { + // Item path equals chroot exactly — legitimate for a PROPFIND + // Depth:0 on the chroot itself. Subpath is empty. + let chroot = stub_folder("/Personal"); + assert_eq!(strip_chroot_prefix(&chroot, "Personal"), Some("")); + } + + #[test] + fn strip_chroot_prefix_empty_chroot_returns_none() { + // Defensive: a mis-set chroot with an empty path must not + // strip anything (stripping "" from any path would return + // the whole path — a silent leak). + let chroot = stub_folder("/"); + assert_eq!(strip_chroot_prefix(&chroot, "Personal/foo.txt"), None); + } + // ── nc_href ── #[test] diff --git a/tests/api/drives_membership.hurl b/tests/api/drives_membership.hurl index e34bc1e5..cc450d3a 100644 --- a/tests/api/drives_membership.hurl +++ b/tests/api/drives_membership.hurl @@ -511,6 +511,26 @@ HTTP 200 jsonpath "$[*].id" contains {{team_drive_id}} +# ───────────────────────────────────────────────────────────── +# Step 21b — Upload gate by role (post-Drive AuthZ audit Round 2). +# Bob is Editor on team_drive; `POST /api/files/upload` +# targeting team_root_folder_id should succeed. This is +# the REST-side counterpart of the WebDAV/NC PUT chain +# hardened by `update_file_streaming_with_perms`. If +# this fails, the whole role-bundle → Permission::Create +# wiring is broken. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{team_root_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +bob_editor_upload_id: jsonpath "$.id" + + # ───────────────────────────────────────────────────────────── # Step 22 — Higher role wins: Bob now ALSO gets a Viewer direct # grant (would lower his bundle). The collapsed caller_role @@ -537,6 +557,50 @@ HTTP 200 jsonpath "$[*].id" contains {{team_drive_id}} +# ───────────────────────────────────────────────────────────── +# Step 22b — Viewer CANNOT upload into a shared drive. +# Post-Drive AuthZ audit Round 2: the create branch of +# `update_file_streaming_with_perms` requires +# `Permission::Create` on the parent folder — bundled +# with `owner`/`editor`/`contributor` role_grants only, +# NOT with `viewer`. `POST /api/files/upload` shares the +# same `save_file_with_blob` gate, so a Viewer probe +# must land 404 (anti-enum: same shape as no-such-folder) +# + `authz.denied` audit line. Also verify the batch / +# overwrite paths refuse — the whole chain from +# drive-membership to file write is exercised here. +# ───────────────────────────────────────────────────────────── + +# 22b.i — Fresh file: 404. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{team_root_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 404 + + +# 22b.ii — Overwrite attempt on the Editor-era upload: still 404. +# `save_file_with_blob` catches the duplicate name at the +# `Create`-permission check before the upsert races (which +# would otherwise 409). The audit shape stays 404. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{team_root_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 404 + + +# 22b.iii — Alice's Editor-era file is untouched. +GET {{base_url}}/api/files/{{bob_editor_upload_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 + + # ============================================================= # Per-role mutation matrix — what every role can / can't do # ============================================================= @@ -845,15 +909,22 @@ HTTP 409 # 30c — Clear the lingering content (the Editor-created folder from -# Step 27). Delete via the regular folder endpoint so the row -# lands in trash, not the live tree; `is_empty` excludes -# trashed rows so a populated trash bin is allowed. +# Step 27 and the Editor-era file from Step 21b). Delete via +# the regular endpoints so rows land in trash, not the live +# tree; `is_empty` excludes trashed rows so a populated trash +# bin is allowed. DELETE {{base_url}}/api/folders/{{editor_created_folder_id}} Authorization: Bearer {{alice_token}} HTTP 204 +DELETE {{base_url}}/api/files/{{bob_editor_upload_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + # 30d — Owner on an empty drive → 204. DELETE {{base_url}}/api/drives/{{team_drive_id}} Authorization: Bearer {{alice_token}} diff --git a/tests/webdav/test_nc_move_copy_delete_trash.sh b/tests/webdav/test_nc_move_copy_delete_trash.sh index 4a26930c..3425754c 100755 --- a/tests/webdav/test_nc_move_copy_delete_trash.sh +++ b/tests/webdav/test_nc_move_copy_delete_trash.sh @@ -323,7 +323,20 @@ grep -q 'g8-doomed' <<< "$BODY" \ || fail "K1: g8-doomed.txt not in trashbin PROPFIND" grep -q '' <<< "$BODY" \ || fail "K1: trashbin response missing " -pass "K1: trashbin shows g8-doomed.txt with original-location" + +# Post-D3 (secondary/shared drive support): the `original-location` +# value is drive-relative — the emitter strips the drive-root segment +# from the internal `storage.folders.path` (`"Personal/g8-doomed.txt"` +# for a file at the default drive root) so NC clients see +# `"g8-doomed.txt"` regardless of what the drive's root is named. +# Regression guard: the pre-D3 code hardcoded `strip_prefix("Personal/")` +# — a bug that would silently break secondary drives. Assert the +# stripped shape (no leading `Personal/`, no leading `/`, no drive +# segment). +grep -q 'g8-doomed\.txt' <<< "$BODY" \ + || fail "K1: original-location not drive-relative (expected 'g8-doomed.txt', got: $(grep -o '[^<]*' <<< "$BODY"))" + +pass "K1: trashbin shows g8-doomed.txt with drive-relative original-location" # Extract the trashed item id (last segment of the href). # Trashbin hrefs are `/remote.php/dav/trashbin/{user}/trash/{uuid}` From 75601beb43f98d77e0c90ae0f248fe5be1d141a0 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 5 Jul 2026 23:17:14 +0200 Subject: [PATCH 29/49] security(wopi): add authz to Wopi --- src/interfaces/api/handlers/wopi_handler.rs | 188 +++++++++- tests/api/run.sh | 20 +- tests/api/wopi_authz.hurl | 377 ++++++++++++++++++++ tests/common/server.env | 12 +- tests/common/wopi_mock_discovery.js | 68 ++++ 5 files changed, 649 insertions(+), 16 deletions(-) create mode 100644 tests/api/wopi_authz.hurl create mode 100644 tests/common/wopi_mock_discovery.js diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index ab4f98df..1ce94960 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -20,10 +20,13 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; use crate::application::services::wopi_lock_service::WopiLockService; use crate::application::services::wopi_token_service::WopiTokenService; use crate::domain::repositories::drive_repository::DriveRepository; +use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; /// Shared state for WOPI handlers. @@ -64,6 +67,37 @@ pub struct CheckFileInfoResponse { pub close_url: String, } +/// Enforce that the WOPI caller (`claims.sub`) still has `perm` on the +/// file at redemption time — not just at token-mint time. +/// +/// **Why every verb needs this.** WOPI tokens are validated locally +/// (HMAC over claims), so a token that was legitimately minted stays +/// verify-able until its TTL. If a grant is revoked after mint, or the +/// token was minted for view but is used to POST content, the token's +/// signature alone doesn't catch it. This helper re-checks against the +/// live authorization engine on every verb — the memory note +/// `wopi-authz-bypass` calls out the class of bugs this fences. +/// +/// Returns 404 (anti-enumeration — same shape as "file doesn't exist") +/// on both bad UUID and authorization denial. The engine emits a +/// structured `audit` line on denial internally, so ops sees the real +/// reason without the attacker being able to distinguish "gone" from +/// "revoked". +async fn require_wopi_perm( + authz: &PgAclEngine, + caller_sub: &str, + file_id: &str, + perm: Permission, +) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> { + let caller_uuid = uuid::Uuid::parse_str(caller_sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?; + authz + .require(Subject::User(caller_uuid), perm, Resource::File(file_uuid)) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + Ok((caller_uuid, file_uuid)) +} + /// GET /wopi/files/{file_id} — CheckFileInfo async fn check_file_info( Path(file_id): Path, @@ -82,6 +116,19 @@ async fn check_file_info( return StatusCode::UNAUTHORIZED.into_response(); } + // Redemption-time authz: even with a valid token, the caller must + // still hold Read on this file. Catches revoked-grant-mid-session. + if let Err(status) = require_wopi_perm( + state.app_state.authorization.as_ref(), + &claims.sub, + &file_id, + Permission::Read, + ) + .await + { + return status.into_response(); + } + // Fetch file metadata let file = match state .app_state @@ -99,6 +146,24 @@ async fn check_file_info( .map(|dt| dt.to_rfc3339()) .unwrap_or_default(); + // `user_can_write` = actual current Update permission ∧ token's + // can_write flag. If the caller's Update was revoked since the + // token was minted (e.g. their grant was downgraded from Editor + // to Viewer), the editor sees the file as read-only and won't + // even attempt PutFile. The stricter `require_wopi_perm(Update)` + // in put_file is the actual gate; this field is a UI hint. + let can_write_now = claims.can_write + && state + .app_state + .authorization + .check( + Subject::User(uuid::Uuid::parse_str(&claims.sub).unwrap_or(uuid::Uuid::nil())), + Permission::Update, + Resource::File(uuid::Uuid::parse_str(&file_id).unwrap_or(uuid::Uuid::nil())), + ) + .await + .unwrap_or(false); + let response = CheckFileInfoResponse { base_file_name: file.name.clone(), // WOPI's `OwnerId` field is required. Post-D7 the DTO no @@ -112,9 +177,9 @@ async fn check_file_info( user_id: claims.sub.clone(), version: file.modified_at.to_string(), supports_locks: true, - supports_update: claims.can_write, + supports_update: can_write_now, supports_rename: false, - user_can_write: claims.can_write, + user_can_write: can_write_now, user_friendly_name: claims.username.clone(), post_message_origin: state.public_base_url.clone(), last_modified_time: last_modified, @@ -145,6 +210,18 @@ async fn get_file( return StatusCode::UNAUTHORIZED.into_response(); } + // Redemption-time authz — see require_wopi_perm docstring. + if let Err(status) = require_wopi_perm( + state.app_state.authorization.as_ref(), + &claims.sub, + &file_id, + Permission::Read, + ) + .await + { + return status.into_response(); + } + match state .app_state .applications @@ -184,6 +261,21 @@ async fn put_file( return StatusCode::UNAUTHORIZED.into_response(); } + // Redemption-time authz: the token says the caller could write when + // it was minted, but Update permission may have been revoked since. + // Re-check now so a stale write-capable token can't survive a + // downgrade / share removal / drive-membership change until its TTL. + if let Err(status) = require_wopi_perm( + state.app_state.authorization.as_ref(), + &claims.sub, + &file_id, + Permission::Update, + ) + .await + { + return status.into_response(); + } + // Check lock let request_lock = headers .get("X-WOPI-Lock") @@ -302,6 +394,22 @@ async fn file_operations( return StatusCode::UNAUTHORIZED.into_response(); } + // Every lock op mutates shared state (LOCK / UNLOCK / REFRESH_LOCK + // change the lock; GET_LOCK reads it but the read is only useful + // to a caller who could subsequently take a write action — so gate + // on Update uniformly rather than splitting per-op). A Viewer with + // a stale token must not be able to hold or contend for a lock. + if let Err(status) = require_wopi_perm( + state.app_state.authorization.as_ref(), + &claims.sub, + &file_id, + Permission::Update, + ) + .await + { + return status.into_response(); + } + let override_header = headers .get("X-WOPI-Override") .and_then(|v| v.to_str().ok()) @@ -374,25 +482,71 @@ pub struct EditorUrlResponse { pub access_token_ttl: i64, } -/// Determines if `caller_id` can access `file_id` and with what permissions. +/// Resolve the WOPI mint target: gate on real permissions and derive +/// the `can_write` flag from the caller's ACTUAL Update rights. /// -/// Uses the SQL-level ownership check (`get_file_owned`) so that files -/// belonging to other users — or non-existent files — both return `NOT_FOUND`, -/// avoiding existence-leak oracles. +/// Prior behaviour used a naive `requested_action != "view"` heuristic +/// so a Viewer clicking "Edit in Collabora" received a write-capable +/// token, promoting themselves to Editor for the token's TTL. The +/// memory note `wopi-authz-bypass` fix #12 calls this out explicitly. /// -/// Returns `(FileDto, can_write)` on success. +/// Contract: +/// +/// 1. **Read** is the bar to open the file in any mode. If the caller +/// has no Read grant, return 404 (anti-enum — same shape as "no such +/// file"). +/// 2. **Update** determines the returned `can_write` bit — INDEPENDENT +/// of what the client's `requested_action` said. A Viewer who +/// requested `action=edit` gets `can_write=false` and Collabora +/// opens in view mode; the token stays authorised for view-only +/// ops and put_file will 404 at redemption regardless. +/// 3. `requested_action == "view"` is respected as a downgrade — an +/// Editor can explicitly request view mode (co-browsing a doc +/// without accidentally editing) and get `can_write=false`. +/// +/// The `PgAclEngine::require`/`check` calls emit structured audit +/// lines on denial (`authz.denied` event), so a Viewer's "edit" +/// attempt shows up in the audit stream as a rejected Update check. async fn authorize_wopi_access( + authz: &PgAclEngine, file_retrieval: &S, file_id: &str, caller_id: uuid::Uuid, requested_action: &str, ) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> { - let file = file_retrieval - .get_file_with_perms(file_id, caller_id) + let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?; + + // Step 1 — Read is required to even open the file. + authz + .require( + Subject::User(caller_id), + Permission::Read, + Resource::File(file_uuid), + ) .await .map_err(|_| StatusCode::NOT_FOUND)?; - // Owner verified — grant write unless explicitly requesting view-only. - let can_write = requested_action != "view"; + + let file = file_retrieval + .get_file(file_id) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Step 2 — can_write reflects real Update, not the client's + // action-string. `check` returns bool without throwing; failure + // just means the caller lacks Update, so we degrade the token to + // read-only. Deliberately no `require` here — a Viewer opening + // the file is legitimate; only the write claim is suppressed. + let has_update = authz + .check( + Subject::User(caller_id), + Permission::Update, + Resource::File(file_uuid), + ) + .await + .unwrap_or(false); + + // Step 3 — allow explicit view-mode downgrade for Editors. + let can_write = has_update && requested_action != "view"; Ok((file, can_write)) } @@ -409,6 +563,7 @@ pub async fn get_editor_url( let username = &auth_user.username; // Verify the caller owns the file (SQL-level check, no existence leak). let (file, can_write) = match authorize_wopi_access( + state.app_state.authorization.as_ref(), state.app_state.applications.file_retrieval_service.as_ref(), ¶ms.file_id, user_id, @@ -494,7 +649,8 @@ async fn host_page( Ok(u) => u, Err(_) => return StatusCode::UNAUTHORIZED.into_response(), }; - let file = match authorize_wopi_access( + let (file, can_write_now) = match authorize_wopi_access( + state.app_state.authorization.as_ref(), state.app_state.applications.file_retrieval_service.as_ref(), &file_id, caller_uuid, @@ -502,7 +658,7 @@ async fn host_page( ) .await { - Ok((f, _)) => f, + Ok((f, cw)) => (f, cw), Err(status) => return status.into_response(), }; @@ -519,11 +675,15 @@ async fn host_page( _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; + // Use the freshly-computed `can_write_now` (real Update permission + // ∧ requested_action) rather than the incoming token's `can_write` + // flag. Otherwise a Viewer who somehow reached this host page with + // a stale edit-capable token would get another one re-minted. let (token, ttl) = match state.token_service.generate_token( &file_id, &claims.sub, &claims.username, - claims.can_write, + can_write_now, ) { Ok(t) => t, Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), diff --git a/tests/api/run.sh b/tests/api/run.sh index 6fa42e63..e0f0e992 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -38,17 +38,34 @@ wait_for_http() { SERVER_PID="" +WOPI_MOCK_PID="" + cleanup() { if [[ -n "$SERVER_PID" ]]; then log "Stopping OxiCloud server (pid $SERVER_PID)..." kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true fi + if [[ -n "$WOPI_MOCK_PID" ]]; then + log "Stopping WOPI mock discovery (pid $WOPI_MOCK_PID)..." + kill "$WOPI_MOCK_PID" 2>/dev/null || true + wait "$WOPI_MOCK_PID" 2>/dev/null || true + fi bash "$COMMON/stop-db.sh" } trap cleanup EXIT +# ── 0. WOPI mock discovery ──────────────────────────────────────────────────── +# Serves the static discovery.xml `OXICLOUD_WOPI_DISCOVERY_URL` +# points at (server.env pins port 9100). Started BEFORE OxiCloud so +# the server's cache-fill on first WOPI request finds it. The mock +# is stdlib-only Python (no deps) — see the file header for what it +# returns and why it's cheap. +log "Starting WOPI mock discovery on port 9100..." +node "$COMMON/wopi_mock_discovery.js" > /tmp/wopi-mock-discovery.log 2>&1 & +WOPI_MOCK_PID=$! + # ── 1. Start postgres ───────────────────────────────────────────────────────── bash "$COMMON/spawn-db.sh" @@ -168,7 +185,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_move.hurl" \ "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ - "$API_DIR/webdav_nested_move_cascade.hurl" + "$API_DIR/webdav_nested_move_cascade.hurl" \ + "$API_DIR/wopi_authz.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" diff --git a/tests/api/wopi_authz.hurl b/tests/api/wopi_authz.hurl new file mode 100644 index 00000000..144e0df7 --- /dev/null +++ b/tests/api/wopi_authz.hurl @@ -0,0 +1,377 @@ +# ============================================================= +# OxiCloud — WOPI authorization at token redemption +# ============================================================= +# Regression coverage for the WOPI verb-handler bypass documented in +# memory note `wopi-authz-bypass`. Two bugs closed: +# +# 1. Verb handlers (check_file_info, get_file, put_file, +# file_operations, host_page) previously did NOT call +# `AuthorizationEngine::require` at redemption. A grant +# revoked between mint-time and request-time silently kept +# working until the token TTL expired. +# +# 2. The mint helper decided `can_write` from the client's +# `requested_action` string (`!= "view"` → write). A Viewer +# clicking "Edit in Collabora" received a write-capable +# token because the string was "edit". +# +# The fix wires `authz.require` on every verb and derives +# `can_write` from the caller's actual Update permission. This +# suite hits both paths through the real HTTP surface. +# +# Note on infra: +# * `OXICLOUD_WOPI_ENABLED=true` in tests/common/server.env +# * `OXICLOUD_WOPI_SECRET` pinned so the tokens the server mints +# round-trip verify-able through the suite +# * WOPI discovery served by `tests/common/wopi_mock_discovery.py` +# started by run.sh — mock URL points at a black-hole editor +# so we only assert on OxiCloud's own responses +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login as admin (owner) and capture home folder id +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" +alice_user_id: jsonpath "$.user.id" + + +GET {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create a Bob user (Viewer under test) via admin API +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "wopi-bob", + "password": "WopiBobPassword1!", + "email": "wopi-bob@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "wopi-bob", "password": "WopiBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Alice uploads a plain-text file the WOPI verbs will +# target. `text/plain` is in the mock discovery XML so +# `/api/wopi/editor-url` resolves to a real (black-hole) +# editor URL — the endpoint returns 200 with an +# access_token we can then poke at the verbs. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{alice_token}} +[MultipartFormData] +folder_id: {{alice_home_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +file_id: jsonpath "$.id" +[Asserts] +jsonpath "$.mime_type" == "text/plain" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Alice mints an editor-URL for her own file with +# `action=edit`. Owner has Update → can_write=true. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_edit_token: jsonpath "$.access_token" +[Asserts] +jsonpath "$.access_token" isString +jsonpath "$.editor_url" contains "edit" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — CheckFileInfo with the owner's edit token. Verb +# re-checks Read → allowed. `user_can_write=true` +# reflects real Update. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_edit_token}} + +HTTP 200 +[Asserts] +jsonpath "$.UserId" == "{{alice_user_id}}" +jsonpath "$.UserCanWrite" == true +jsonpath "$.SupportsUpdate" == true + + +# ───────────────────────────────────────────────────────────── +# Step 6 — GetFile with the owner's edit token. Verb re-checks +# Read → 200 with body. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}} + +HTTP 200 +[Asserts] +body contains "Hello" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PutFile with the owner's edit token. Verb re-checks +# Update → 200. The owner overwrites her own file. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}} +Content-Type: application/octet-stream +``` +owner overwrite via WOPI PutFile +``` + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Alice explicitly requests view mode. Even the owner +# gets `can_write=false` — the token respects the +# client's downgrade so Collabora can open a doc +# "read-only for co-browsing". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=view +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_view_token: jsonpath "$.access_token" + + +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_view_token}} + +HTTP 200 +[Asserts] +# Owner explicitly requested view — supports_update flips off. +jsonpath "$.UserCanWrite" == false +jsonpath "$.SupportsUpdate" == false + + +# View token trying to write → 401 (token's can_write bit says no +# before the authz.require ever runs). +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_view_token}} +Content-Type: application/octet-stream +``` +owner trying to write with view token +``` + +HTTP 401 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — SECURITY: Bob has NO grant on Alice's file. Requests +# an edit-URL. The mint helper's Read gate fires → 404 +# (anti-enum). This is the pre-fix behaviour holding +# — mint-time Read was already enforced via +# get_file_with_perms. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Alice grants Bob the Viewer role on the file. +# Capture the grant id off the POST response so Step +# 13's revoke doesn't need to LIST + filter (the LIST +# endpoint returns a bare JSON array, not +# `.grants[?...]`, and Hurl's single-match filter +# capture behaviour is quirky — see memory note +# `feedback_hurl_jsonpath_filter_empty`). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "file", "id": "{{file_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — SECURITY: Bob (Viewer) requests an EDIT token. Fix +# #12: mint helper derives `can_write` from real +# Update permission, not from the requested_action +# string. Bob has Read but not Update → token is +# minted with `can_write=false` even though he asked +# for "edit". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_forged_edit_token: jsonpath "$.access_token" + + +# CheckFileInfo with Bob's "edit" token shows UserCanWrite=false +# because the token's can_write bit was scrubbed at mint. Prior +# to the fix this was `true` — a Viewer editing Alice's file. +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_forged_edit_token}} + +HTTP 200 +[Asserts] +jsonpath "$.UserId" == "{{bob_user_id}}" +jsonpath "$.UserCanWrite" == false +jsonpath "$.SupportsUpdate" == false + + +# Bob attempting PutFile with his "edit" token → 401. The +# token's own can_write=false is the outer gate; even if the +# token had somehow been forged with can_write=true, the +# redemption-time authz.require(Update) would return 404. +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}} +Content-Type: application/octet-stream +``` +Bob trying to write as Viewer +``` + +HTTP 401 + + +# Bob CAN read (his Read grant is real). +GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — SECURITY: promote Bob to Editor. Now he legitimately +# holds Update, so an edit token becomes truly write- +# capable. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "file", "id": "{{file_id}}" }, + "role": "editor" +} + +# The engine's `ON CONFLICT UPDATE` collapses one role row per +# (subject, resource), so this Editor grant REPLACES the Viewer +# grant from Step 10 rather than stacking. Bob now holds +# Editor alone; revoking it in Step 13 leaves him with no +# grants at all. +HTTP 201 +[Captures] +bob_grant_id: jsonpath "$.grants[0].id" + + +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_real_edit_token: jsonpath "$.access_token" + + +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}} + +HTTP 200 +[Asserts] +# Bob is a real Editor now → can_write flips to true. +jsonpath "$.UserCanWrite" == true +jsonpath "$.SupportsUpdate" == true + + +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}} +Content-Type: application/octet-stream +``` +Bob as Editor legitimately writes +``` + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 13 — SECURITY: revoke Bob's grant AFTER his edit token was +# minted. The token stays cryptographically valid until +# TTL, but every subsequent verb call must hit the +# authorization engine and reject. +# +# This is the CORE bug the memory note describes: prior +# to the fix Bob's PutFile still succeeded here because +# the verb handlers trusted the token in isolation. +# +# The Editor grant from Step 12 REPLACED the Viewer +# grant from Step 10 (engine's ON CONFLICT UPDATE — +# one role row per subject/resource). So revoking the +# Editor grant leaves Bob with no grants at all; every +# verb — Read AND Update — must refuse. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/grants/{{bob_grant_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +# CheckFileInfo — no Read → 404. Prior to the fix the verb +# handler trusted the token and returned 200 with the file's +# metadata. +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}} + +HTTP 404 + + +# GetFile — no Read → 404. Prior to the fix Bob could still +# download the file content until the token TTL expired. +GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}} + +HTTP 404 + + +# PutFile — no Update → 404 (verb-side require_wopi_perm), OR +# 401 if the token's own `!claims.can_write` gate happened to +# fire first. The important assertion is "not 200" — a revoked +# grant must never let the caller through. +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}} +Content-Type: application/octet-stream +``` +Bob post-revoke tries to write +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Cleanup — delete the test file so subsequent Hurl files don't +# see it. Bob user stays; other tests may reuse the `wopi-bob` +# username, but the grants that made this test meaningful are +# gone. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/files/{{file_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 diff --git a/tests/common/server.env b/tests/common/server.env index 8cc4f118..6c27ee39 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -13,7 +13,17 @@ OXICLOUD_ENABLE_SEARCH=true OXICLOUD_ENABLE_FILE_SHARING=true OXICLOUD_ENABLE_MUSIC=true OXICLOUD_EXPOSE_SYSTEM_USERS=true -OXICLOUD_WOPI_ENABLED=false +OXICLOUD_WOPI_ENABLED=true +# Fixed secret so the Hurl WOPI test can hand-craft valid access +# tokens with a known signing key. Prod deployments MUST override +# this to a random per-deployment value. +OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod +# Discovery URL points at a black hole — VERB endpoints don't need +# discovery, and the WOPI Hurl suite deliberately does NOT touch +# `/api/wopi/editor-url` (the only path that would fetch it), so +# an unreachable URL keeps startup fast and hermetic. +OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml +OXICLOUD_WOPI_TOKEN_TTL_SECS=3600 OXICLOUD_OIDC_ENABLED=false OXICLOUD_NEXTCLOUD_ENABLED=true diff --git a/tests/common/wopi_mock_discovery.js b/tests/common/wopi_mock_discovery.js new file mode 100644 index 00000000..88005321 --- /dev/null +++ b/tests/common/wopi_mock_discovery.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// Minimal mock WOPI discovery server for the Hurl WOPI suite. +// +// Serves a valid RFC-shaped discovery XML on `GET /discovery.xml` so +// `OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:/discovery.xml` +// resolves to a real editor URL when `/api/wopi/editor-url` fetches it. +// +// The `urlsrc` we hand back points at a black-hole host so no real +// editor process needs to be running — the Hurl suite only asserts on +// OxiCloud's own responses (token contents, HTTP status codes, +// headers). The mock exists purely to let `get_editor_url` succeed +// end-to-end so we can exercise the mint-time authz path (Viewer- +// clicks-Edit gets a read-only token). +// +// Node stdlib only — matches the tooling used by tests/oidc/fake_idp +// (both are stdlib-free apart from `node-oidc-provider` on that side). +// No package.json, no npm install, no extra dependency for the api +// test suite. Started + reaped by `tests/api/run.sh`. Port comes from +// `WOPI_MOCK_PORT` env var (default 9100). + +'use strict'; + +const http = require('http'); + +const DISCOVERY_XML = ` + + + + + + + + + + + + + + + +`; + +const port = Number(process.env.WOPI_MOCK_PORT || 9100); + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/discovery.xml') { + res.writeHead(200, { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Length': Buffer.byteLength(DISCOVERY_XML), + }); + res.end(DISCOVERY_XML); + return; + } + res.writeHead(404); + res.end(); +}); + +// SIGTERM from `kill` in run.sh cleanup — exit quietly so the test +// runner's tail-of-log stays clean. +for (const sig of ['SIGTERM', 'SIGINT']) { + process.on(sig, () => server.close(() => process.exit(0))); +} + +server.listen(port, '127.0.0.1', () => { + console.log(`wopi-mock-discovery listening on 127.0.0.1:${port}`); +}); From 0870990e1b32e290fc34aa693bab9b3defa7d716 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 5 Jul 2026 23:16:32 +0200 Subject: [PATCH 30/49] fix(locale): correct IT i18n --- frontend/static/locales/it.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index 7fc32d82..8e9094b3 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -583,7 +583,7 @@ "item_deleted_permanently": "Elemento eliminato definitivamente", "trash_emptied": "Cestino svuotato con successo", "empty": "Nessuna notifica", - "title": "Notifiche" + "title": "Notifiche", "link_created": "Link creato", "share_success": "Link di condivisione creato con successo", "upload_files_section_title": "Caricamento non disponibile qui", @@ -948,7 +948,7 @@ "size": "Dimensione", "favoriteDate": "Data preferito", "byFiles": "Per file", - "sharedWith": "Condiviso con" + "sharedWith": "Condiviso con", "justAdded": "Nuovo", "folders": "Cartelle" }, From 0b33ed7b2b2e24165c9ad1f5d7bc338ef26c423f Mon Sep 17 00:00:00 2001 From: Ivan Yv Date: Mon, 6 Jul 2026 06:15:37 +0300 Subject: [PATCH 31/49] fix(ui): token revoke buttons on light theme, nextcloud login page --- frontend/src/routes/profile/+page.svelte | 2 +- templates/nextcloud/login.html | 94 ++++++++---------------- 2 files changed, 33 insertions(+), 63 deletions(-) diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index b2d718ff..3f31362f 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -1131,7 +1131,7 @@ } .btn-action--danger { - color: var(--color-danger-text); + color: var(--color-danger-alt); } button[type='submit'] { diff --git a/templates/nextcloud/login.html b/templates/nextcloud/login.html index 2d7c499a..93d7a4f3 100644 --- a/templates/nextcloud/login.html +++ b/templates/nextcloud/login.html @@ -1,71 +1,41 @@ + Grant Access - OxiCloud - - - + + + -
-
- - -

Grant Access

-

- A Nextcloud client is requesting access to your account. -

- -
-
- - -
- -
- - -
- - -
- - - -
-
- - +
Redirecting, please wait...
- + + \ No newline at end of file From 7e34045ff8539047c2b56e22d0bf30b2e98bd381 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 6 Jul 2026 20:45:21 +0200 Subject: [PATCH 32/49] feat(drive): fix webdav back-compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add env variable `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` which is by default: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"` so `/webdav/` -> points to user's personal drive (**backward compatibilit**y) `/web/dav/@drive/{uuid|drive name}/` points to the respective drive if admins want directly `/webdav/` pointing to list of drives they need to: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` + ensure lock is per user (RFC 4918 §9.11) fix: #554 --- .github/workflows/ci.yml | 9 + docs/config/env.md | 1 + docs/plan/drive.md | 88 ++- example.env | 24 + justfile | 27 +- src/common/config.rs | 29 + .../services/webdav_lock_service.rs | 18 +- src/interfaces/api/handlers/webdav_handler.rs | 688 +++++++++++++----- tests/api/run.sh | 2 + tests/api/webdav_drive_root.hurl | 229 ++++++ tests/api/webdav_permissions.hurl | 300 ++++++++ tests/common/server-webdav-drive-root.env | 85 +++ tests/common/wipe-storage.sh | 7 +- .../drive_root_empty_config.hurl | 186 +++++ tests/webdav-drive-root/run.sh | 106 +++ tests/webdav-drive-root/test.env | 9 + 16 files changed, 1576 insertions(+), 232 deletions(-) create mode 100644 tests/api/webdav_drive_root.hurl create mode 100644 tests/api/webdav_permissions.hurl create mode 100644 tests/common/server-webdav-drive-root.env create mode 100644 tests/webdav-drive-root/drive_root_empty_config.hurl create mode 100755 tests/webdav-drive-root/run.sh create mode 100644 tests/webdav-drive-root/test.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29ed76d3..ed076ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,6 +369,15 @@ jobs: env: BUILD_TARGET: release + # WebDAV URL-scheme variant: `OXICLOUD_WEBDAV_DRIVE_PATH=""` + # (drive listing at `/webdav/`, no `@drive` sigil). Runs a + # separately-configured server on its own port so the default + # WebDAV suite above stays on the `"@drive"` back-compat config. + - name: Run WebDAV drive-root variant tests + run: bash tests/webdav-drive-root/run.sh + env: + BUILD_TARGET: release + # OIDC integration: drives the SPA's SSO flow end-to-end against # the fake IdP (auto-approve login + consent, real PKCE/JWT # round-trip) and asserts the d1bbe8ba contract — OIDC callback diff --git a/docs/config/env.md b/docs/config/env.md index 316bd0a1..cabc50eb 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -70,6 +70,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata | | `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` | | `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep` and `POST /api/admin/internal/trigger-gc` — test-only synchronous triggers for the storage-usage reconciliation sweep and blob garbage collector. Used by the API test suite to assert post-delete quota convergence without waiting out the periodic ticker. Leave **off** in production: the routes return 404 even to an admin token when disabled. | +| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive//…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav//…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. | ## Storage Backend diff --git a/docs/plan/drive.md b/docs/plan/drive.md index ef8fcc1f..cf01db69 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -761,15 +761,24 @@ accommodates them without schema migration) #### Native WebDAV (`/webdav/...`) -| URL | Resolves to | -|---|---| -| `/webdav/` | Caller's default personal drive root + `` (back-compat with today's behaviour) | -| `/webdav/@drive//` | Specific drive root + `` | +**SHIPPED 2026-07-06.** Config-driven via env +`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` (`FeaturesConfig::webdav_drive_listing_prefix`; +default `"@drive"`, sanitized by trimming leading/trailing `/`). +Three deployment shapes: -Today's `/webdav/` handler implicitly looks up the caller's -home folder and prepends it. Post-drives, the same handler looks up -the caller's personal drive and resolves paths inside it. **Zero -breakage** for existing native WebDAV clients. +| `WEBDAV_DRIVE_LISTING_PREFIX` | URL | Resolves to | +|---|---|---| +| `@drive` (default) | `/webdav/…` | caller's default personal drive (back-compat) | +| `@drive` | `/webdav/@drive/` | drive listing | +| `@drive` | `/webdav/@drive//…` | specific drive | +| `""` (empty) | `/webdav/` | drive listing | +| `""` | `/webdav//…` | specific drive | +| any other | same shape as `@drive`, segment substituted | | + +`` is a drive UUID **or** the drive's display name (matched +against `storage.folders.name` of the drive root). Only drives the +caller has Read on via `role_grants` resolve; unknown selector and +permission denial both return 404 (anti-enumeration). **Why the `@drive` sigil and NOT `/webdav/drives//...`** (earlier draft) or top-level `/drives//...` (also @@ -777,32 +786,47 @@ considered): `@` is the established structural-routing sigil (GitHub `@user/repo`, npm `@scope/pkg`, LDAP `@domain`) — it reads as "this is not user content, this is a routing token." Realistic collision risk drops to near-zero: nobody creates a -top-level folder named exactly `@drive` by accident, and the -defensive layer collapses to a single one-liner in MKCOL / PUT / -REST create paths that refuses that literal name at any drive -root. Compared to top-level `/drives//...`, the `@drive` -shape keeps **one URL root for everything WebDAV** — single -`` block in reverse-proxy configs, single mental model -for sysadmins, single dispatcher in `webdav_routes()`. +top-level folder named exactly `@drive` by accident. Keeps **one +URL root for everything WebDAV** — single `` block in +reverse-proxy configs, single mental model for sysadmins, single +dispatcher in `webdav_routes()`. Making the segment +config-tunable per deployment lets operators pick a different +sigil (`drives`) or drop it entirely (`""` = drive-listing at +root) without a code change. -**Implementation notes:** -- Route parser accepts both `/webdav/@drive//...` and the - URL-encoded form `/webdav/%40drive//...` — WebDAV clients - percent-encode `@` inconsistently. -- One-liner guard in upload paths refuses creation of a folder - literally named `@drive` at any drive root (case-sensitive). -- `webdav_href()` (today at `webdav_handler.rs:94`) becomes - drive-context-aware: responses for a request under - `/webdav/@drive//...` must reference back to - `/webdav/@drive//...`, otherwise the client follows the - `` and lands on the back-compat surface (wrong drive). +**Implementation:** `resolve_webdav_scope` in +`src/interfaces/api/handlers/webdav_handler.rs`. Selector accepts +UUIDs and display names; UUID form is tried first. Legacy +tolerance in the default-drive branch: bookmarks that already +carried the drive-root name as their first segment +(`/webdav/Personal/foo` under a Personal-default user) are +passed through instead of double-prepended. -The `drives` path segment is **reserved**: a folder literally named -`drives` cannot exist at the top level of any drive. Migration -pre-check refuses to start if existing data violates this — operator -must rename before upgrading. (Conservative estimate: zero existing -folders are named exactly `drives`. The migration script reports any -collisions for manual fix-up.) +**Hurl coverage:** +- `tests/api/webdav_drive_root.hurl` — default `@drive` config +- `tests/webdav-drive-root/drive_root_empty_config.hurl` — empty + config (separately-configured server; runs under + `tests/webdav-drive-root/run.sh`, wired into `just api-test` + and CI's `api-test` job) + +**Href construction — verified drive-aware:** `webdav_href()` +prints `/webdav/`, but the `` input is `client_path` +extracted from `req.uri()` (the URL segment after `/webdav/`), not +the scope-resolved db_path. So a request to +`/webdav/@drive//folder/` renders children as +`/webdav/@drive//folder//` — the `@drive//` +prefix is preserved on every hop. `client_path` is threaded into +`base_href` at `handle_propfind` and passed through +`build_streaming_propfind_response` unchanged. + +**Deferred (not blocking):** +- One-liner guard refusing folder creation named literally + `@drive` at drive root (defensive against future collisions — + today an unknown `@drive` folder at drive root is unreachable + via WebDAV under the default config, so it's low priority). +- Cross-drive MOVE / COPY currently 403 — same-drive only. + Cross-drive copy has REST-side support; WebDAV MOVE/COPY + could route through it once permission mapping is designed. #### NextCloud-compat WebDAV (`/remote.php/dav/...`) diff --git a/example.env b/example.env index c500b869..b814ae7d 100644 --- a/example.env +++ b/example.env @@ -85,6 +85,30 @@ OXICLOUD_SERVER_HOST=127.0.0.1 # to an admin token. Default: false. #OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=false +# Native WebDAV URL segment that returns the drive listing. Sanitized +# by trimming leading/trailing `/` so `/@drive/`, `@drive`, and +# `@drive/` are equivalent. Three deployment modes: +# +# * Default `@drive` — back-compat with pre-multi-drive clients. +# /webdav/… → caller's default personal drive +# /webdav/@drive/ → drive listing (per-drive virtual +# folders) +# /webdav/@drive//… → specific drive by UUID or its +# display name +# +# * Empty `""` — no default-drive shortcut; `/webdav/` IS the +# drive listing. Clients must always name the drive. +# /webdav/ → drive listing +# /webdav//… → specific drive +# +# * Any other string (e.g. `drives`) — same shape as `@drive` but +# with your chosen segment substituted. +# +# Selector `` is a drive UUID or the drive's display name. Only +# drives the caller has Read on via role_grants resolve; unknown +# selector and permission denial both return 404 (anti-enumeration). +#OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=@drive + # How often (milliseconds) the background job drains storage.tree_etag_dirty # and bumps folder tree ETags (default: 500). Write paths only enqueue bump # requests — this is the upper bound on how stale an ancestor folder's ETag diff --git a/justfile b/justfile index 35e78e6d..f6db94cd 100644 --- a/justfile +++ b/justfile @@ -159,23 +159,34 @@ front-design: # Hurl-driven functional tests (starts postgres + server, tears down after). # -# Three runners — each isolated, brings up its own sidecars + server config: -# * tests/api/run.sh — REST API surface, default server.env -# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env -# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP -# (tests/oidc/fake_idp, a Node panva/oidc-provider -# wrapper); server launched with -# --config server-with-oidc.env so the api and -# webdav suites stay on the OIDC-off config. +# Four runners — each isolated, brings up its own sidecars + server config: +# * tests/api/run.sh — REST API surface, default server.env +# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env +# * tests/webdav-drive-root/run.sh — WebDAV `OXICLOUD_WEBDAV_DRIVE_PATH=""` +# variant (drive listing served at +# `/webdav/` instead of `/webdav/@drive/`). +# Server launched with +# --config server-webdav-drive-root.env +# so the default runners stay on the +# `"@drive"` config. +# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP +# (tests/oidc/fake_idp, a Node +# panva/oidc-provider wrapper); server +# launched with +# --config server-with-oidc.env so the +# api and webdav suites stay on the +# OIDC-off config. # # Same chain runs in CI under the `api-test` job in # .github/workflows/ci.yml; keep the order in sync so a local pass means # CI passes. api-test: #!/usr/bin/env bash + set -x set -euo pipefail ./tests/api/run.sh ./tests/webdav/run.sh + ./tests/webdav-drive-root/run.sh ./tests/oidc/run.sh if which litmus >/dev/null 2>/dev/null then diff --git a/src/common/config.rs b/src/common/config.rs index 50869c3b..a5fc1f79 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -915,6 +915,22 @@ pub struct FeaturesConfig { /// deployments don't want them reachable. Env: /// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`. pub enable_admin_internal_endpoints: bool, + /// Native WebDAV path segment that lists the caller's drives. + /// + /// * Default `"@drive"` — bare `/webdav/` addresses the caller's + /// default personal drive (back-compat). Drive listing lives at + /// `/webdav/@drive/`; explicit drive at + /// `/webdav/@drive//…`. + /// * `""` (empty) — no default-drive shortcut. Bare `/webdav/` + /// returns the drive listing; explicit drive at + /// `/webdav//…`. Operators who don't want a "default + /// drive" concept exposed via WebDAV pick this. + /// * Any other string (e.g. `"drives"`) — same shape as the default, + /// just with that path segment. Loaded via `trim_matches('/')` + /// so operators can safely pass `"/drives/"`. + /// + /// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. + pub webdav_drive_listing_prefix: String, } impl Default for FeaturesConfig { @@ -934,6 +950,10 @@ impl Default for FeaturesConfig { // deployments do NOT need this; the periodic ticker handles // reconciliation transparently. enable_admin_internal_endpoints: false, + // Back-compat with pre-multi-drive clients — bare `/webdav/` + // maps to the caller's default drive; drive listing is + // reachable at `/webdav/@drive/`. + webdav_drive_listing_prefix: "@drive".to_string(), } } } @@ -1505,6 +1525,15 @@ impl AppConfig { config.features.enable_admin_internal_endpoints = val; } + // Native WebDAV drive-picker path segment. Sanitised by + // stripping leading/trailing slashes so operators can pass + // `/drives/` or `drives` interchangeably; empty string means + // "no default-drive shortcut, `/webdav/` IS the drive listing". + // See `FeaturesConfig::webdav_drive_listing_prefix`. + if let Ok(raw) = env::var("OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX") { + config.features.webdav_drive_listing_prefix = raw.trim_matches('/').to_string(); + } + if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::()) && let Ok(val) = enable_faces { diff --git a/src/infrastructure/services/webdav_lock_service.rs b/src/infrastructure/services/webdav_lock_service.rs index 686c0daf..919b6bfc 100644 --- a/src/infrastructure/services/webdav_lock_service.rs +++ b/src/infrastructure/services/webdav_lock_service.rs @@ -32,6 +32,14 @@ const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours pub struct LockEntry { pub info: LockInfo, pub path: String, + /// The user who acquired the lock. `None` for entries seeded by + /// unit tests or refresh paths that don't carry a caller (the + /// refresh flow rebuilds from the existing entry without a new + /// caller context, so we preserve whatever was there). RFC 4918 + /// §9.11's "MUST be requested by the owner" rule for UNLOCK is + /// enforced by comparing this against the caller in + /// `handle_unlock`. + pub caller_user_id: Option, } /// Per-entry expiration policy for the `by_path` cache. @@ -110,7 +118,12 @@ impl WebDavLockStore { /// - The existing lock is exclusive (blocks any new lock), or /// - The new lock is exclusive and any lock already exists (RFC 4918 §7.8). #[allow(clippy::result_large_err)] - pub fn acquire(&self, path: &str, info: LockInfo) -> Result { + pub fn acquire( + &self, + path: &str, + info: LockInfo, + caller_user_id: Option, + ) -> Result { if let Some(existing) = self.by_path.get(path) { // Exclusive existing lock → blocks everything. // New exclusive lock → blocked by any existing lock (shared or exclusive). @@ -123,6 +136,7 @@ impl WebDavLockStore { let entry = LockEntry { info, path: path.to_owned(), + caller_user_id, }; self.by_token .insert(entry.info.token.clone(), path.to_owned()); @@ -132,6 +146,7 @@ impl WebDavLockStore { let entry = LockEntry { info, path: path.to_owned(), + caller_user_id, }; // `LockExpiry` derives the TTL from `entry.info.timeout` on insert — @@ -254,6 +269,7 @@ mod tests { LockEntry { info: lock_info(token, timeout, LockScope::Exclusive), path: "/file.txt".to_owned(), + caller_user_id: None, } } diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 27286d73..6761efce 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -222,45 +222,170 @@ async fn handle_webdav_methods( handle_webdav_dispatch(state, req, path).await } -/// If `path` doesn't already start with the user's home folder name, prepend -/// the home folder path so downstream services can find the resource in the DB. -/// Returns `None` when the path already includes the prefix or resolution fails. -async fn resolve_webdav_path(state: &Arc, user_id: Uuid, path: &str) -> Option { - let folder_service = &state.applications.folder_service; - let home_folders = folder_service - .list_folders_with_perms(None, user_id) - .await - .ok()?; - let home = home_folders.first()?; - - if path.starts_with(&home.name) { - None // Already prefixed - } else { - Some(format!("{}/{}", home.path, path)) - } +/// Native WebDAV URL scheme (drive.md §9): +/// +/// The exact wire shape depends on +/// `FeaturesConfig::webdav_drive_listing_prefix` (env +/// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`, default `"@drive"`): +/// +/// | Config | URL | Target | +/// |---|---|---| +/// | `"@drive"` | `/webdav/…` | default drive (back-compat) | +/// | `"@drive"` | `/webdav/@drive/` | drive listing | +/// | `"@drive"` | `/webdav/@drive//…` | explicit drive | +/// | `""` | `/webdav/` | drive listing | +/// | `""` | `/webdav//…` | explicit drive | +/// | `"drives"` | `/webdav/…` | default drive | +/// | `"drives"` | `/webdav/drives//…` | explicit drive | +/// +/// `` is a drive UUID **or** the drive's display name (matched +/// against `storage.folders.name` of the drive root). Only drives the +/// caller has Read on via `role_grants` resolve. +/// +/// Legacy tolerance for the default-drive branch: bookmarks that +/// already contain the drive-root name as their first segment +/// (`/webdav/Personal/foo` under a Personal-default user) are passed +/// through instead of double-prepended. +enum WebdavTarget { + /// Render the synthetic drive-listing pseudo-root. Only PROPFIND + /// treats this as a real target; other verbs 405. + ListDrives, + /// Descend into a concrete drive. + Scope(DriveScope), } -/// Native WebDAV protocol entry: resolve the caller's default drive -/// once per handler so every downstream path-based lookup -/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`) -/// can pass the same `drive_id` scope. -/// -/// Post-D0 `storage.{folders,files}.path` repeats across drives — the -/// scope is mandatory. Native WebDAV today lives in a single-drive -/// surface (one default drive per user), so the lookup is unambiguous. -/// Multi-drive support via path segments (`/webdav/drives//…`) -/// is tracked separately and will derive `drive_id` directly from the -/// URL instead of going through `find_default_for_user`. -async fn resolve_drive_id_for_native_webdav( +struct DriveScope { + drive_id: Uuid, + /// Path in `storage.folders.path` format (drive-root name is the + /// leading segment; that prefix is stored per D7). + db_path: String, +} + +async fn resolve_webdav_scope( state: &Arc, user_id: Uuid, -) -> Result { - state + url_path: &str, +) -> Result { + let drive_prefix = state + .core + .config + .features + .webdav_drive_listing_prefix + .as_str(); + let normalized = url_path.trim_matches('/'); + + // Mode A: empty prefix. `/webdav/` IS the drive listing. + if drive_prefix.is_empty() { + if normalized.is_empty() { + return Ok(WebdavTarget::ListDrives); + } + let (selector, subpath) = normalized.split_once('/').unwrap_or((normalized, "")); + let drive = lookup_drive_selector(state, user_id, selector).await?; + return Ok(WebdavTarget::Scope(DriveScope { + drive_id: drive.drive.id, + db_path: join_drive_path(&drive.root_folder_name, subpath), + })); + } + + // Mode B: non-empty prefix (default `@drive`). Bare `/webdav/` is + // the caller's default drive; drive listing lives at + // `/webdav//`. + let listing_marker = drive_prefix; + if normalized == listing_marker { + return Ok(WebdavTarget::ListDrives); + } + let with_slash = format!("{}/", listing_marker); + if let Some(after_prefix) = normalized.strip_prefix(&with_slash) { + if after_prefix.is_empty() { + return Ok(WebdavTarget::ListDrives); + } + let (selector, subpath) = after_prefix.split_once('/').unwrap_or((after_prefix, "")); + let drive = lookup_drive_selector(state, user_id, selector).await?; + return Ok(WebdavTarget::Scope(DriveScope { + drive_id: drive.drive.id, + db_path: join_drive_path(&drive.root_folder_name, subpath), + })); + } + + // Default-drive back-compat. + let default = state .drive_repo .find_default_for_user(user_id) .await - .map(|d| d.drive.id) - .map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e))) + .map_err(|e| { + AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)) + })?; + let root_name = default.root_folder_name.as_str(); + let db_path = if normalized.is_empty() { + root_name.to_string() + } else if normalized == root_name || normalized.starts_with(&format!("{}/", root_name)) { + // Pre-refactor bookmark already carried the drive-root prefix. + normalized.to_string() + } else { + join_drive_path(root_name, normalized) + }; + Ok(WebdavTarget::Scope(DriveScope { + drive_id: default.drive.id, + db_path, + })) +} + +/// Convenience: unwrap the common Scope branch or map ListDrives to a +/// 405-shape error. Used by every write verb (PUT/DELETE/MOVE/COPY/…) +/// that can't sensibly operate on the drive-listing pseudo-root. +async fn resolve_webdav_scope_or_405( + state: &Arc, + user_id: Uuid, + url_path: &str, +) -> Result { + match resolve_webdav_scope(state, user_id, url_path).await? { + WebdavTarget::Scope(s) => Ok(s), + WebdavTarget::ListDrives => Err(AppError::method_not_allowed( + "Method not supported on the drive-listing pseudo-root", + )), + } +} + +fn join_drive_path(root_name: &str, subpath: &str) -> String { + let subpath = subpath.trim_start_matches('/').trim_end_matches('/'); + if subpath.is_empty() { + root_name.to_string() + } else { + format!("{}/{}", root_name, subpath) + } +} + +/// Resolve `@drive/`: try the selector as a UUID first, then +/// fall back to matching the drive-root folder's display name. Only +/// drives the caller has Read access to via `role_grants` are +/// considered — an unknown selector and a permission denial return the +/// same `NotFound` to preserve anti-enumeration. +async fn lookup_drive_selector( + state: &Arc, + user_id: Uuid, + selector: &str, +) -> Result { + let selector_decoded = percent_decode_str(selector).decode_utf8_lossy(); + let uuid_opt = Uuid::parse_str(selector_decoded.as_ref()).ok(); + let visible = state + .drive_repo + .list_readable_by(user_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to list drives: {:?}", e)))?; + for d in visible { + if let Some(uuid) = uuid_opt + && d.drive.id == uuid + { + return Ok(d); + } + if d.root_folder_name == selector_decoded.as_ref() { + return Ok(d); + } + } + Err(AppError::not_found(format!( + "Drive '{}' not found", + selector_decoded + ))) } async fn handle_webdav_dispatch( @@ -270,21 +395,9 @@ async fn handle_webdav_dispatch( ) -> Result, AppError> { let method = req.method().clone(); - // Translate WebDAV path → DB path by prepending user's home folder - // prefix when the path doesn't already include it. - // Extract user_id before any async call to keep the future Send. - let path = if !path.is_empty() && method.as_str() != "OPTIONS" { - let user_id = req.extensions().get::>().map(|u| u.id); - if let Some(uid) = user_id { - resolve_webdav_path(&state, uid, &path) - .await - .unwrap_or(path) - } else { - path - } - } else { - path - }; + // Path is left as the raw URL path (post-`/webdav/`). Every handler + // that touches storage calls `resolve_webdav_scope` to translate the + // URL → (drive_id, db_path). match method.as_str() { "OPTIONS" => handle_options(path).await, @@ -419,46 +532,46 @@ async fn handle_propfind( }; // ── 5. Determine target resource ───────────────────────────── - if path.is_empty() || path == "/" { - // Root folder - let root_folder = FolderDto { - id: "root".to_string(), - etag: "root".to_string(), - name: "".to_string(), - path: "".to_string(), - parent_id: None, - // Synthetic root folder for PROPFIND on `/`; not an - // actual DB row, so drive_id has no meaningful value. - drive_id: Uuid::nil(), - created_at: Utc::now().timestamp() as u64, - modified_at: Utc::now().timestamp() as u64, - is_root: true, - icon_class: Arc::from("fas fa-folder"), - icon_special_class: Arc::from("folder-icon"), - category: Arc::from("Folder"), - // §14 provenance not applicable to the synthetic root. - created_by: None, - updated_by: None, - }; - - return build_streaming_propfind_response( - root_folder, - None, // folder_id = None → root children - &depth_owned, - &base_href, - propfind_request, - folder_service, - file_retrieval_service, - user.id, - state.webdav_dead_props.clone(), - ) - .await; - } - - // `drive_id` is mandatory post-D0 for path-based lookups. Native - // WebDAV resolves it once from the caller's default drive and - // reuses it for the resolver / fallback probes below. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // + // `resolve_webdav_scope` handles the URL → scope translation using + // `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. It can return either a concrete + // drive scope or the synthetic drive-listing pseudo-root. Only + // PROPFIND treats `ListDrives` as a valid target — other verbs use + // `resolve_webdav_scope_or_405` which errors on that branch. + let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? { + WebdavTarget::ListDrives => { + let root_folder = FolderDto { + id: "root".to_string(), + etag: "root".to_string(), + name: "".to_string(), + path: "".to_string(), + parent_id: None, + // Synthetic root — not a real DB row. + drive_id: Uuid::nil(), + created_at: Utc::now().timestamp() as u64, + modified_at: Utc::now().timestamp() as u64, + is_root: true, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + created_by: None, + updated_by: None, + }; + return build_streaming_propfind_response( + root_folder, + None, // folder_id = None → root children (drive-root folders) + &depth_owned, + &base_href, + propfind_request, + folder_service, + file_retrieval_service, + user.id, + state.webdav_dead_props.clone(), + ) + .await; + } + WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path), + }; // Single-query path resolution: folder OR file in one DB round-trip. // @@ -772,6 +885,13 @@ async fn handle_proppatch( let user = extract_user(&req)?; // Client-facing path for href construction (without home folder prefix). let client_path = extract_webdav_path(req.uri()); + // Scope the URL → (drive_id, db_path). The synthetic drive-listing + // pseudo-root has no DB row to anchor dead properties on; treat + // it as an empty target and reject the PROPPATCH itself below. + let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? { + WebdavTarget::ListDrives => (Uuid::nil(), String::new()), + WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path), + }; // Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties, // so a lock on the target must release them via `If:`. Captured @@ -813,7 +933,7 @@ async fn handle_proppatch( // PROPPATCH itself below so we don't fabricate a target. (None, true) } else { - match resolve_or_legacy(&state, &path, user.id).await { + match resolve_or_legacy(&state, &path, drive_id).await { Some(ResolvedResource::Folder(folder)) => { let id = Uuid::parse_str(&folder.id).map_err(|e| { AppError::internal_error(format!("Folder id is not a UUID: {e}")) @@ -831,6 +951,21 @@ async fn handle_proppatch( let resource_ref = resource_ref .ok_or_else(|| AppError::forbidden("PROPPATCH on the WebDAV root is not supported"))?; + // AuthZ: PROPPATCH writes dead properties on the target — that's + // a mutation, requires `Update`. Without this check any caller who + // can Read (e.g. a Viewer-role grant) could persist dead-prop rows + // on someone else's file. Anti-enum-preserving: `require` maps + // denial to `NotFound`, matching the anonymous-not-found response + // above. + let resource = match resource_ref { + ResourceRef::Folder(id) => Resource::Folder(id), + ResourceRef::File(id) => Resource::File(id), + }; + state + .authorization + .require(Subject::User(user.id), Permission::Update, resource) + .await?; + // Read request body (XML — bounded to 1 MB) let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY) .await @@ -909,7 +1044,9 @@ async fn handle_get( // `drive_id` is the path-lookup scope post-D0 (paths repeat across // drives), derived once from the caller's default drive and reused // by both the resolver + legacy fallback. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; // Resolve file — drive-scoped when PathResolver is available. // Post-D7 both branches enforce `Read` on the resolved file @@ -1034,7 +1171,9 @@ async fn handle_head( // `drive_id` is the path-lookup scope post-D0 — derive once and // reuse across the resolver + fallback branches below. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; // Single-query path resolution (drive-scoped). Both branches // enforce `Read` on the resolved resource before emitting the @@ -1165,20 +1304,12 @@ async fn handle_head( async fn resolve_or_legacy( state: &Arc, path: &str, - user_id: Uuid, + drive_id: Uuid, ) -> Option { - // Path-lookup scope post-D0 — derive the caller's default drive - // once and reuse across both probes. `find_default_for_user` - // returning Err (e.g. external user, or boot before the lifecycle - // hook fired) means no resolution is possible: return None. - let drive_id = state - .drive_repo - .find_default_for_user(user_id) - .await - .ok()? - .drive - .id; - + // `drive_id` is now passed in by the caller (already computed by + // `resolve_webdav_scope`) so the fallback probes stay consistent + // with the primary resolver — cross-drive URLs no longer silently + // fall back to the caller's default drive. if let Some(resolver) = &state.path_resolver && let Ok(r) = resolver.resolve_path_in_drive(path, drive_id).await { @@ -1594,7 +1725,9 @@ async fn handle_put( // `drive_id` is the path-lookup scope post-D0 — resolve once from // the caller's default drive, reused by the resolver checks below // and by the atomic-store call further down. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; // ── Existence check ─────────────────────────────────────────────── // Resolves to: File(existing), Folder(wrong), or Err(new file). @@ -1782,10 +1915,11 @@ async fn handle_put( .body(Body::empty()) .unwrap()) } - Err(e) => Err(AppError::internal_error(format!( - "Failed to put file: {}", - e - ))), + // Propagate DomainError kinds — NotFound (authz denial via + // `require_target_folder_perm`), Conflict (missing parent) etc. + // Wrapping everything as InternalError swallowed 404s from the + // service's own AuthZ, surfacing them to callers as 500. + Err(e) => Err(AppError::from(e)), } } @@ -1807,9 +1941,13 @@ async fn handle_mkcol( let user = extract_user(&req)?; let folder_service = &state.applications.folder_service; - if path.is_empty() || path == "/" { - return Err(AppError::conflict("Root folder already exists")); - } + // Bare `/webdav/` handling: routed through `resolve_webdav_scope_or_405` + // below. In the empty-drive-path config that resolves to the + // drive-listing pseudo-root (405 method-not-allowed); in the + // default `@drive` config it resolves to the default drive's root + // folder (which already exists — the existence probe at + // `exists_in_drive` further down returns 405 per RFC 4918 §9.3.1). + // Both configs end at 405 without a special-case. // Extract content-type before consuming the body. let req_content_type = req @@ -1848,7 +1986,9 @@ async fn handle_mkcol( // This handler only creates a single collection (the last path segment). // It does NOT auto-create intermediate ancestors ("mkdir -p" semantics // violate the RFC and were causing the test failures). - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segments.is_empty() { @@ -1977,6 +2117,22 @@ async fn handle_delete( ) -> Result, AppError> { let user = extract_user(&req)?; + // Refuse DELETE on the pseudo-root before any scope work — bare + // `/webdav/` (empty-config drive listing OR classic-config default + // drive root) can't be deleted from the WebDAV surface. + if path.is_empty() || path == "/" { + return Err(AppError::forbidden("Cannot delete root folder")); + } + + // Scope resolution BEFORE the lock guard so `enforce_native_lock` + // keys on the same DB path that `handle_lock` used when it + // registered the lock. Doing it in the reverse order (as before + // the drive-scope refactor) silently defeated every LOCK because + // the lock-store key mismatch made every DELETE look unlocked. + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; + // Active-lock guard (RFC 4918 §9.10.4). let if_header_owned = req .headers() @@ -1997,17 +2153,12 @@ async fn handle_delete( let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; - // Check if path is empty (root folder) - if path.is_empty() || path == "/" { - return Err(AppError::forbidden("Cannot delete root folder")); - } - // Resolve via optimized resolver, falling back to the legacy // double-query lookup (the one GET uses). Necessary because the // optimized resolver and the read repositories disagree on path // shape for some files; see `resolve_or_legacy` docs. let _ = file_retrieval_service; // present for legacy fallback if needed elsewhere - match resolve_or_legacy(&state, &path, user.id).await { + match resolve_or_legacy(&state, &path, drive_id).await { Some(ResolvedResource::Folder(folder)) => { folder_service .delete_folder_with_perms(&folder.id, user.id) @@ -2062,17 +2213,6 @@ async fn handle_move( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - // Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move - // removes the source resource, which counts as modifying it. - if let Some(resp) = enforce_native_lock( - &state.webdav_lock_store, - if_header_owned.as_deref(), - &source_path, - None, - ) { - return Ok(resp); - } - // Get destination from Destination header let destination = req .headers() @@ -2101,22 +2241,41 @@ async fn handle_move( // SECURITY: reject path-traversal in destination reject_path_traversal(&destination_path)?; - // Normalize destination through the SAME path-prefixing that - // `resolve_webdav_path` applied to `source_path` during dispatch. - // Without this, comparing source_parent_path (already prefixed with - // the user's home folder name) against dest_parent_path (raw from - // the URL, no prefix) always reports "different parent" — even for a - // pure rename at the same level — and breaks the move/rename branch - // selection below. - let destination_path = resolve_webdav_path(&state, user.id, &destination_path) - .await - .unwrap_or(destination_path); + // Resolve BOTH source and destination scope. Cross-drive MOVE is + // permitted: the underlying service methods + // (`move_folder_with_perms` / `move_file_with_perms`) support it + // natively — they enforce the D5 `forbid_cross_drive_move` policy + // per drive and emit a D6 `resource.moved_between_drives` audit + // line when the move crosses a boundary. Downstream probes that + // walk `storage.{folders,files}.path` need the RIGHT drive scope + // for each side; we thread `src_drive_id` for source probes and + // `dst_drive_id` for destination probes. + let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?; + let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?; + let src_drive_id = src_scope.drive_id; + let dst_drive_id = dst_scope.drive_id; + let source_path = src_scope.db_path; + let path = source_path.clone(); + let destination_path = dst_scope.db_path; // RFC 4918 §9.9.3: MOVE to self MUST return 403 Forbidden. - if destination_path == source_path { + if destination_path == path { return Err(AppError::forbidden("Cannot MOVE a resource to itself")); } + // Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move + // removes the source resource, which counts as modifying it. The + // guard runs AFTER scope resolution so its lookup keys on the DB + // path — same key `handle_lock` used when it registered the lock. + if let Some(resp) = enforce_native_lock( + &state.webdav_lock_store, + if_header_owned.as_deref(), + &source_path, + None, + ) { + return Ok(resp); + } + // Destination lock guard: MOVE also creates/replaces a resource at // the destination. If that path is locked, the same If: header must // satisfy it. @@ -2133,21 +2292,19 @@ async fn handle_move( let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; - // Probe destination existence for Overwrite semantics and 201 vs 204. let dest_existed = if let Some(resolver) = &state.path_resolver { resolver - .exists_in_drive(&destination_path, drive_id) + .exists_in_drive(&destination_path, dst_drive_id) .await .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path, drive_id) + .get_folder_by_path(&destination_path, dst_drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path, drive_id) + .get_file_by_path(&destination_path, dst_drive_id) .await .is_ok() }; @@ -2161,7 +2318,7 @@ async fn handle_move( // RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the // destination before moving. Without this the rename/move fails // on a unique-index conflict (same name in same parent). - match resolve_or_legacy(&state, &destination_path, user.id).await { + match resolve_or_legacy(&state, &destination_path, dst_drive_id).await { Some(ResolvedResource::Folder(f)) => { folder_service .delete_folder_with_perms(&f.id, user.id) @@ -2189,7 +2346,7 @@ async fn handle_move( } let _ = file_retrieval_service; - let resolved = resolve_or_legacy(&state, &source_path, user.id) + let resolved = resolve_or_legacy(&state, &source_path, src_drive_id) .await .ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?; @@ -2213,7 +2370,7 @@ async fn handle_move( None } else { match folder_service - .get_folder_by_path(dest_parent_path, drive_id) + .get_folder_by_path(dest_parent_path, dst_drive_id) .await { Ok(parent) => { @@ -2262,13 +2419,19 @@ async fn handle_move( } } ResolvedResource::File(file) => { - if source_parent_path != dest_parent_path { + // A cross-drive move always changes the parent folder id even + // if the RELATIVE path within each drive looks the same, so + // we key the "same-parent rename" fast-path off drive id + // agreement as well. + let is_same_parent = + src_drive_id == dst_drive_id && source_parent_path == dest_parent_path; + if !is_same_parent { // RFC 4918 §9.9.5: missing destination parent → 409 Conflict. let target_parent_id = if dest_parent_path.is_empty() { None } else { let parent = folder_service - .get_folder_by_path(dest_parent_path, drive_id) + .get_folder_by_path(dest_parent_path, dst_drive_id) .await .map_err(|_| { AppError::conflict(format!( @@ -2382,12 +2545,20 @@ async fn handle_copy( // SECURITY: reject path-traversal in destination reject_path_traversal(&destination_path)?; - // Normalize through the same path-prefixing the dispatcher applied - // to source_path. See the long comment in handle_move for why this - // matters — same root-cause class of asymmetric-path bugs. - let destination_path = resolve_webdav_path(&state, user.id, &destination_path) - .await - .unwrap_or(destination_path); + // Resolve BOTH source and destination scope. Cross-drive COPY is + // permitted: `copy_file_with_perms` / `copy_folder_tree_with_perms` + // take a target folder id and don't care which drive it lives in; + // the D5 `forbid_cross_drive_move` policy applies to MOVE only, + // never to COPY (copying is non-destructive on the source side). + // Downstream probes need the right drive per side, so we thread + // `src_drive_id` for source probes and `dst_drive_id` for + // destination probes. + let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?; + let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?; + let src_drive_id = src_scope.drive_id; + let dst_drive_id = dst_scope.drive_id; + let source_path = src_scope.db_path; + let destination_path = dst_scope.db_path; // RFC 4918 §9.8.5: COPY to self MUST return 403 Forbidden. if destination_path == source_path { @@ -2416,21 +2587,23 @@ async fn handle_copy( let folder_service = &state.applications.folder_service; let file_management_service = &state.applications.file_management_service; - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // Scope already resolved above; keep `path` alias for downstream code + // that still reads `path` under its original name. + let _path = source_path.clone(); // Probe destination existence for Overwrite semantics and 201 vs 204. let dest_existed = if let Some(resolver) = &state.path_resolver { resolver - .exists_in_drive(&destination_path, drive_id) + .exists_in_drive(&destination_path, dst_drive_id) .await .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path, drive_id) + .get_folder_by_path(&destination_path, dst_drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path, drive_id) + .get_file_by_path(&destination_path, dst_drive_id) .await .is_ok() }; @@ -2444,7 +2617,7 @@ async fn handle_copy( // RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a // DELETE on the destination before the copy. Without this the copy // service returns a unique-index conflict (500). - match resolve_or_legacy(&state, &destination_path, user.id).await { + match resolve_or_legacy(&state, &destination_path, dst_drive_id).await { Some(ResolvedResource::Folder(f)) => { folder_service .delete_folder_with_perms(&f.id, user.id) @@ -2472,7 +2645,7 @@ async fn handle_copy( } let _ = file_retrieval_service; - let resolved = resolve_or_legacy(&state, &source_path, user.id) + let resolved = resolve_or_legacy(&state, &source_path, src_drive_id) .await .ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?; @@ -2490,7 +2663,7 @@ async fn handle_copy( None } else { match folder_service - .get_folder_by_path(dest_parent_path, drive_id) + .get_folder_by_path(dest_parent_path, dst_drive_id) .await { Ok(parent) => { @@ -2590,25 +2763,101 @@ async fn handle_lock( ) -> Result, AppError> { let user = extract_user(&req)?; - // Determine collection-vs-file for href shape. Root + known - // folders → collection; everything else (existing files, - // lock-null on a non-existent path) → file. RFC 4918 §9.10.1 - // allows LOCK on a non-existent resource (the "lock-null - // resource" pattern used by Office save flows) — that arm - // falls through to the file href shape, matching the - // request-line shape clients send. - let is_collection = if path.is_empty() || path == "/" { - true + // Scope resolution BEFORE the collection probe so `path` becomes + // the drive-scoped DB path everywhere downstream — critically the + // `lock_store.acquire(&path, …)` call must use the SAME key that + // `enforce_native_lock` will look up from the write verbs + // (PUT/DELETE/MOVE/COPY/PROPPATCH), all of which pass the DB path. + // Locking the URL path here and looking up the DB path in PUT + // would silently defeat the lock — that's the regression this + // shape prevents. + let (drive_id, path) = if path.is_empty() || path == "/" { + (Uuid::nil(), path) } else { - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; - state - .applications - .folder_service - .get_folder_by_path(&path, drive_id) - .await - .is_ok() + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + (scope.drive_id, scope.db_path) }; + // Determine collection-vs-file for href shape AND resolve the + // target for AuthZ. Root + known folders → collection; existing + // files → file; missing path → lock-null (RFC 4918 §7.3 / + // §9.10.1, used by Office save flows). AuthZ per case: + // * Existing folder / file → `Update` on the resource. + // * Lock-null (target doesn't exist yet) → `Create` on the + // parent folder (the lock reserves the URL for a future PUT + // that would need `Create` anyway; deny here so a Viewer + // can't create a lock-null placeholder on someone else's + // namespace). + // Denial routes through `NotFound` (anti-enum), matching the + // rest of the WebDAV surface. + let (is_collection, lockable_resource) = if path.is_empty() { + (true, None) + } else if let Ok(folder) = state + .applications + .folder_service + .get_folder_by_path(&path, drive_id) + .await + { + let uuid = Uuid::parse_str(&folder.id) + .map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Update, + Resource::Folder(uuid), + ) + .await?; + (true, Some(Resource::Folder(uuid))) + } else if let Ok(file) = state + .applications + .file_retrieval_service + .get_file_by_path(&path, drive_id) + .await + { + let uuid = Uuid::parse_str(&file.id) + .map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Update, + Resource::File(uuid), + ) + .await?; + (false, Some(Resource::File(uuid))) + } else { + // Lock-null: authorise on the parent folder. The last `/` in + // `path` splits parent from name; empty parent means the drive + // root (which itself was already resolved above — the caller + // must have Read on it to have gotten this far via + // `resolve_webdav_scope`). + let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or(""); + if !parent_path.is_empty() { + let parent = state + .applications + .folder_service + .get_folder_by_path(parent_path, drive_id) + .await + .map_err(|_| AppError::conflict("Parent folder not found for lock-null"))?; + let parent_uuid = Uuid::parse_str(&parent.id).map_err(|e| { + AppError::internal_error(format!("Parent folder id is not a UUID: {e}")) + })?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Create, + Resource::Folder(parent_uuid), + ) + .await?; + } + // No resource to authorise directly — the lock reserves the URL, + // downstream PUT will re-authorise via its own Create/Update. + (false, None) + }; + let _ = lockable_resource; + // Get the headers that we need let depth = req .headers() @@ -2690,13 +2939,17 @@ async fn handle_lock( type_, }; - // Try to acquire the lock (conflict detection via moka store) - let entry = lock_store.acquire(&path, lock_info).map_err(|existing| { - AppError::locked(format!( - "Resource already locked by token {}", - existing.info.token - )) - })?; + // Try to acquire the lock (conflict detection via moka store). + // `caller_user_id` is stamped on the entry so `handle_unlock` + // can enforce RFC 4918 §9.11's owner-only rule. + let entry = lock_store + .acquire(&path, lock_info, Some(user.id)) + .map_err(|existing| { + AppError::locked(format!( + "Resource already locked by token {}", + existing.info.token + )) + })?; // Generate response — collection vs file href chosen above. let href = if is_collection { @@ -2735,9 +2988,9 @@ async fn handle_lock( async fn handle_unlock( state: Arc, req: Request, - _path: String, + path: String, ) -> Result, AppError> { - let _user = extract_user(&req)?; + let user = extract_user(&req)?; // Get lock token from Lock-Token header let lock_token = req @@ -2753,6 +3006,65 @@ async fn handle_unlock( .trim_end_matches('>') .to_string(); + // RFC 4918 §9.11 owner-only check. `LockEntry.caller_user_id` + // was stamped by `handle_lock` at acquire time. When the lock + // exists AND we know the acquirer, only that user can UNLOCK. + // Denial routes through the standard authz `NotFound` anti-enum + // — a caller who neither holds the lock nor has any perm on the + // resource shouldn't learn whether the lock exists. + // + // Approximations preserved: + // * Lock entries seeded by tests (`caller_user_id = None`) fall + // through to the Update-based check below — they were never + // bound to a real user. + // * If the token isn't in the store at all (expired, never + // existed) we skip the owner check and let the `release` + // call below return the RFC-standard 409. + let lock_entry = state.webdav_lock_store.get_by_token(&token); + if let Some(entry) = &lock_entry + && let Some(owner_id) = entry.caller_user_id + && owner_id != user.id + { + tracing::info!( + target: "audit", + event = "webdav.unlock_denied", + reason = "not_lock_owner", + caller_id = %user.id, + lock_owner_id = %owner_id, + token = %token, + "👮🏻‍♂️ UNLOCK refused: caller does not own the lock", + ); + return Err(AppError::not_found(format!( + "Lock token not found or already expired: {}", + token + ))); + } + + // Defence-in-depth for the test-seeded / legacy `caller_user_id + // = None` case: require `Update` on the target resource so a + // Read-only grantee still can't unlock. Uses the URL path (the + // lock's target) to resolve the resource. Missing target → skip + // (lock-null unlock is legitimate). + if let Some(entry) = &lock_entry + && entry.caller_user_id.is_none() + && !path.is_empty() + && path != "/" + { + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let db_path = scope.db_path; + if let Some(resource) = match resolve_or_legacy(&state, &db_path, drive_id).await { + Some(ResolvedResource::Folder(f)) => Uuid::parse_str(&f.id).ok().map(Resource::Folder), + Some(ResolvedResource::File(f)) => Uuid::parse_str(&f.id).ok().map(Resource::File), + None => None, + } { + state + .authorization + .require(Subject::User(user.id), Permission::Update, resource) + .await?; + } + } + // Remove the lock from the store if !state.webdav_lock_store.release(&token) { // RFC 4918 §9.11.1: If the lock does not exist, return 409 Conflict diff --git a/tests/api/run.sh b/tests/api/run.sh index e0f0e992..b90d182f 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -185,6 +185,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_move.hurl" \ "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ + "$API_DIR/webdav_drive_root.hurl" \ + "$API_DIR/webdav_permissions.hurl" \ "$API_DIR/webdav_nested_move_cascade.hurl" \ "$API_DIR/wopi_authz.hurl" diff --git a/tests/api/webdav_drive_root.hurl b/tests/api/webdav_drive_root.hurl new file mode 100644 index 00000000..a3e220c2 --- /dev/null +++ b/tests/api/webdav_drive_root.hurl @@ -0,0 +1,229 @@ +# ============================================================= +# OxiCloud — WebDAV drive-root URL scheme +# ============================================================= +# Exercises the native WebDAV URL scheme documented in +# `src/interfaces/api/handlers/webdav_handler.rs::resolve_webdav_scope`: +# +# Default deployment (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`): +# * `/webdav/` → default drive's contents +# * `/webdav/@drive/` → drive listing (per-drive +# virtual folders) +# * `/webdav/@drive//…` → explicit drive by UUID +# * `/webdav/@drive//…` → explicit drive by name +# +# Coverage: +# 1. Login, capture JWT +# 2. Resolve caller's default drive (id + display name) +# 3. Create a magic folder under the home root via REST +# 4. PROPFIND `/webdav/` — Depth: 1 lists the magic folder as +# an immediate child of the default drive. This is the +# user-visible bug fix: pre-refactor, `/webdav/` returned a +# drive listing instead of the default drive's contents. +# 5. PROPFIND `/webdav/@drive/` — Depth: 1 lists each drive as +# a virtual child (at least the caller's default is present). +# 6. PROPFIND `/webdav/@drive//` — descends into the +# selected drive by UUID; magic folder appears here too. +# 7. PROPFIND `/webdav/@drive//` — same via display name. +# 8. Cleanup: DELETE the magic folder via REST. +# +# The magic folder name embeds a run-scoped marker so parallel +# `hurl --jobs N` runs don't step on each other and repeat runs +# against a shared DB don't collide. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Resolve caller's default drive (id + display name). +# `GET /api/drives` returns rows in a stable order: +# the caller's default personal drive first, then by +# display name. See `DriveRepository::list_readable_by`. +# `default_for_user` on the DTO is present-only for +# default rows (`Option` with `skip_serializing_if`), +# so `$[0]` — combined with the stable order — is the +# default drive for a fresh admin account. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +default_drive_id: jsonpath "$[0].id" +default_drive_name: jsonpath "$[0].name" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Resolve the caller's home root folder id. +# A default personal drive has exactly one root folder +# (the drive-root itself). We need its id to create the +# magic folder as its child. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +home_folder_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Create a magic folder under the home root via REST. +# The name is deterministic-yet-unique so PROPFIND +# assertions below can find it by exact string match, +# and parallel test runs can't collide. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "hurl-drive-root-magic-marker", + "parent_id": "{{home_folder_id}}" +} + +HTTP 201 +[Captures] +magic_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPFIND on `/webdav/` (bare root). The default +# deployment maps this to the caller's DEFAULT drive +# contents, so Depth: 1 must include the magic folder. +# +# Pre-refactor this returned a drive listing instead — +# the exact regression that broke back-compat with +# pre-multi-drive WebDAV clients. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 6 — PROPFIND on `/webdav/@drive/`. This is the explicit +# drive picker — Depth: 1 returns one virtual child +# per drive the caller has Read on. The default drive +# must appear (by its display name). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPFIND on `/webdav/@drive//`. The explicit +# by-UUID selector — descends INTO the chosen drive. +# Depth: 1 lists that drive's top-level children — +# the magic folder must be one of them. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{default_drive_id}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — PROPFIND on `/webdav/@drive//`. The explicit +# by-name selector — same result as the UUID form. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{default_drive_name}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Reject MKCOL at `/webdav/@drive/` (bare pseudo-root). +# The drive-listing target has no writable parent +# folder — 405 Method Not Allowed. This guard prevents +# a client from silently succeeding at "creating a +# drive by MKCOL" (the drive-create surface is +# `POST /api/drives`, not WebDAV). +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Reject MKCOL at `/webdav/@drive/`. +# `` gets interpreted as a drive selector; +# no drive with that name/UUID exists → 404. Sits +# adjacent to Step 9 so any future maintainer touching +# the pseudo-root rejection sees BOTH shapes at once +# (bare listing = 405, unknown selector = 404). +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/hurl-not-a-real-drive +Authorization: Bearer {{token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Reject PUT at `/webdav/@drive//x.txt`. +# Same rejection shape as MKCOL — trying to write a +# file into a non-existent drive. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/hurl-not-a-real-drive/probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11b — Reject PUT at `/webdav/@drive/test.txt`. The URL +# segment immediately after `@drive/` is ALWAYS a +# drive selector — never a filename. A caller that +# bookmarks a file URL under `@drive` with a name +# that doesn't match any drive must get 404, not +# silently create a file at the drive-listing level. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/test.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cleanup: DELETE the magic folder via REST so +# subsequent test runs / other hurl files don't see +# our marker. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{magic_folder_id}} +Authorization: Bearer {{token}} + +HTTP 204 diff --git a/tests/api/webdav_permissions.hurl b/tests/api/webdav_permissions.hurl new file mode 100644 index 00000000..8b930239 --- /dev/null +++ b/tests/api/webdav_permissions.hurl @@ -0,0 +1,300 @@ +# ============================================================= +# OxiCloud — WebDAV per-role permissions + cross-drive MOVE policy +# ============================================================= +# End-to-end coverage for the two WebDAV authz axes exposed by the +# `@drive` URL scheme: +# +# 1. Per-role gates through the drive-scope resolver: a Viewer on a +# shared drive can PROPFIND/GET but cannot MKCOL/PUT/MOVE. An +# Editor can. AuthZ denials return `NotFound` (anti-enum), so +# a probing caller can't tell a genuinely-missing folder from +# one they simply lack Create on. +# +# 2. Drive policy `forbid_cross_drive_move` gates MOVE at the +# SOURCE drive (see `DrivePolicies::refuse_cross_drive_move` +# in `src/domain/entities/drive.rs`) — even a fully-authorised +# Editor can't move content OUT of a drive whose owner has +# forbidden cross-drive movement. Rejection is 405 +# (`ErrorKind::UnsupportedOperation` → `METHOD_NOT_ALLOWED`). +# +# Assumes the default `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"` config — +# runs alongside the other tests in `tests/api/run.sh`. Uses the +# `@drive/` selector so the paths don't collide with any +# drive-name-collision oddities. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login as admin (bootstrapped by `setup.hurl`). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create a fresh user "webdav_bob" via the admin +# endpoint, log him in. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "webdav_bob", + "password": "WebdavBobPassword1!", + "email": "webdav_bob@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "webdav_bob", "password": "WebdavBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# Capture Bob's default personal drive id — used by the cross-drive +# MOVE scenario. Bob is not a member of any shared drive yet, so his +# `/api/drives` listing has exactly one entry (his own default). +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_personal_drive_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Admin creates a shared drive owned by admin. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "webdav-perm-shared", + "owner": { "type": "user", "id": "{{admin_user_id}}" } +} + +HTTP 201 +[Captures] +shared_drive_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Grant Bob VIEWER on the shared drive via /api/grants. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "drive", "id": "{{shared_drive_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Bob (VIEWER) CAN PROPFIND the shared drive root. +# Depth 0 to keep the assertion minimal; a 207 with the +# drive's own href suffices as "Bob has Read". +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/ +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 207 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Bob (VIEWER) CANNOT MKCOL on the shared drive. +# `authz.require(Create, Folder)` denial returns +# `DomainError::not_found` (anti-enum), which maps to 404. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-folder +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Bob (VIEWER) CANNOT PUT a file. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-file.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +viewer should not upload +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Admin creates a probe folder in the shared drive so +# the Editor-can-rename step below has a real target. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{admin_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Bob (VIEWER) CANNOT MOVE (rename) the probe folder. +# MOVE requires Update on the source, which Viewer +# doesn't have. Same anti-enum 404 shape. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Promote Bob from VIEWER to EDITOR. +# `PATCH /api/drives/{id}/members/{subject-type}/{id}` +# mutates the role in-place. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "role": "editor" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Bob (EDITOR) CAN MKCOL a new folder. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder +Authorization: Bearer {{bob_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Bob (EDITOR) CAN PUT a file. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder/hello.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +editor uploaded content +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Bob (EDITOR) CAN MOVE (rename) the probe folder. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 14 — Bob puts a file in his OWN personal drive as the +# source for the cross-drive MOVE test below. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +cross-drive probe payload +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Admin flips `forbid_cross_drive_move` ON for Bob's +# PERSONAL drive. The policy sits on the SOURCE drive +# per `DrivePolicies::refuse_cross_drive_move`; only +# OxiCloud-admin can PATCH policies. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "forbid_cross_drive_move": true } + +HTTP 200 +[Asserts] +jsonpath "$.forbid_cross_drive_move" == true + + +# ───────────────────────────────────────────────────────────── +# Step 16 — Bob tries to MOVE `xdrive-probe.txt` from his +# PERSONAL drive to the SHARED drive. Blocked at the +# service layer by the policy — `OperationNotSupported` +# maps to 405 Method Not Allowed. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Admin flips the policy OFF. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "forbid_cross_drive_move": false } + +HTTP 200 +[Asserts] +jsonpath "$.forbid_cross_drive_move" == false + + +# ───────────────────────────────────────────────────────────── +# Step 18 — Bob retries the same MOVE. Now the policy is off, +# Bob has Update on source (his own personal drive) + +# Create on dest parent (Editor on shared drive), so +# the move succeeds. 201 on rename/move to a new URL, +# per `handle_move`'s existing convention. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Verify the destination now exists and the source +# is gone. Both PROPFINDs use Bob's token to also +# re-confirm the AuthZ gates on the destination side. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 207 + + +PROPFIND {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 404 diff --git a/tests/common/server-webdav-drive-root.env b/tests/common/server-webdav-drive-root.env new file mode 100644 index 00000000..3fafb75b --- /dev/null +++ b/tests/common/server-webdav-drive-root.env @@ -0,0 +1,85 @@ +# Shared test-server environment variables. +# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node). +# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here — +# each test suite sets those to avoid port/directory conflicts. + +DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_STATIC_PATH=./static +OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars +OXICLOUD_ENABLE_AUTH=true +OXICLOUD_ENABLE_TRASH=true +OXICLOUD_ENABLE_SEARCH=true +OXICLOUD_ENABLE_FILE_SHARING=true +OXICLOUD_ENABLE_MUSIC=true +OXICLOUD_EXPOSE_SYSTEM_USERS=true +OXICLOUD_WOPI_ENABLED=true +# Fixed secret so the Hurl WOPI test can hand-craft valid access +# tokens with a known signing key. Prod deployments MUST override +# this to a random per-deployment value. +OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod +# Discovery URL points at a black hole — VERB endpoints don't need +# discovery, and the WOPI Hurl suite deliberately does NOT touch +# `/api/wopi/editor-url` (the only path that would fetch it), so +# an unreachable URL keeps startup fast and hermetic. +OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml +OXICLOUD_WOPI_TOKEN_TTL_SECS=3600 +OXICLOUD_OIDC_ENABLED=false + +OXICLOUD_NEXTCLOUD_ENABLED=true + +# Test-only sweep triggers (`/api/admin/internal/trigger-sweep`, +# `/api/admin/internal/trigger-gc`). Off by default in production; +# the Hurl suite needs them to assert post-delete quota convergence +# without waiting out the 600 s reconciliation tick. +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +RUST_LOG="warn,audit=info,sqlx::migrate=info" +#RUST_LOG="warn,audit=info,oxicloud::quota=debug" +#RUST_LOG=debug +#RUST_LOG=info + +# Per-chunk upload cap, exercised by chunked_upload_cap.hurl. +# 4 MiB: lets the existing grants.hurl single-chunk test (2.76 MB) pass +# under the cap, while the cap test sends a 5 MiB fixture to trigger 413. +OXICLOUD_CHUNK_MAX_BYTES=4194304 + +# Direct-PUT (non-chunked) cap, exercised by chunked_upload_cap.hurl. +# 4 MiB: same threshold as the chunked cap so the existing 5 MiB +# fixture (chunk-over-cap-5mb.bin) can prove BOTH caps with one +# generated file. All existing direct-PUT tests +# (test_dedup_webdav_multichunk.sh = 2.76 MB, _ref_count = ~66 KB, +# _nextcloud_put_blake3 = 32 B) stay safely under this cap. +OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304 + +# grow up limits for tests +OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600 +OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600 + +# Magic-link / external-users flow (PR 9). The mock SMTP captures every +# outbound message in-process so external_users.hurl can retrieve the +# invitation body and follow the magic-link URL. The `SMTP_FROM` value +# is required so the mock can build a valid Message; host/port are +# irrelevant in mock mode but kept set for completeness. +OXICLOUD_SMTP_MOCK=true +OXICLOUD_SMTP_HOST=localhost +OXICLOUD_SMTP_PORT=25 +OXICLOUD_SMTP_FROM='OxiCloud Tests ' +OXICLOUD_SMTP_TLS=none +OXICLOUD_ALLOW_EXTERNAL_USERS=true + +# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can +# exercise the cap behaviour with a small, deterministic request count. +# Production defaults are 50 / 5 / 200 respectively (see example.env). +OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3 +OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2 +OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50 + +# permits IP spoofing for tests +OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 + +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +# /webdav/ will points directly to list of drives +OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="" diff --git a/tests/common/wipe-storage.sh b/tests/common/wipe-storage.sh index 5ac8d1c5..4d0aff19 100644 --- a/tests/common/wipe-storage.sh +++ b/tests/common/wipe-storage.sh @@ -34,9 +34,10 @@ wipe_storage() { fi # Sanity check: must end in tests//storage where is - # lowercase alphanumeric. Stops `rm -rf` from ever running against - # an unexpected expansion of a callerʼs path. - if [[ ! "$path" =~ /tests/[a-z0-9]+/storage$ ]]; then + # lowercase alphanumeric (hyphens allowed so multi-word runner names + # like `webdav-drive-root` pass). Stops `rm -rf` from ever running + # against an unexpected expansion of a caller's path. + if [[ ! "$path" =~ /tests/[a-z0-9][a-z0-9-]*/storage$ ]]; then echo "[wipe_storage] ERROR: '$path' does not match .../tests//storage — refusing to wipe" >&2 return 1 fi diff --git a/tests/webdav-drive-root/drive_root_empty_config.hurl b/tests/webdav-drive-root/drive_root_empty_config.hurl new file mode 100644 index 00000000..5102e012 --- /dev/null +++ b/tests/webdav-drive-root/drive_root_empty_config.hurl @@ -0,0 +1,186 @@ +# ============================================================= +# OxiCloud — WebDAV drive-root URL scheme, `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` variant +# ============================================================= +# Companion to `webdav_drive_root.hurl`. That file exercises the +# default config (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`); this +# one exercises the empty-string config where `/webdav/` IS the +# drive listing and there's no default-drive shortcut. +# +# Server env for this test: `tests/common/server-webdav-drive-root.env` +# sets `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`. This file assumes that +# config is active — it is NOT part of the standard `run.sh` +# invocation (which starts the default-config server). +# +# Coverage: +# 1. Login, capture JWT +# 2. Resolve caller's default drive (id + display name) +# 3. Create a magic folder under the home root via REST +# 4. PROPFIND `/webdav/` — drive listing (default drive +# appears as a virtual child under its display name). +# 5. PROPFIND `/webdav//` — descend into a drive by +# UUID. Magic folder appears. +# 6. PROPFIND `/webdav//` — descend into a drive by +# display name. Magic folder appears. +# 7. `/webdav/@drive/` returns 404 in this mode — the sigil +# has no reserved meaning when `webdav_drive_listing_prefix=""`. +# A drive genuinely named `@drive` would resolve here; the +# 404 comes from "no such drive," not the sigil. +# 8. Cleanup: DELETE the magic folder via REST. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Resolve caller's default drive (id + display name). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +default_drive_id: jsonpath "$[0].id" +default_drive_name: jsonpath "$[0].name" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Resolve the caller's home root folder id. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +home_folder_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Create a magic folder under the home root via REST. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "hurl-drive-root-empty-magic-marker", + "parent_id": "{{home_folder_id}}" +} + +HTTP 201 +[Captures] +magic_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPFIND on `/webdav/` (bare root). With +# `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` this IS the drive +# listing — the default drive appears as a virtual +# child under its display name. The magic folder does +# NOT appear here (it lives one level deeper). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists +# Magic folder is one level deeper — must NOT show up at root. +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" not exists + + +# ───────────────────────────────────────────────────────────── +# Step 6 — PROPFIND on `/webdav//`. Descends into the +# default drive; magic folder is a top-level child. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/{{default_drive_id}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPFIND on `/webdav//`. Same descent via +# display name. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/{{default_drive_name}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — `/webdav/@drive/` has no reserved meaning in the +# empty-config mode. `@drive` is treated as a plain +# drive selector; no drive by that name → 404. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Reject MKCOL at `/webdav/` (bare pseudo-root). +# In the empty-config mode `/webdav/` IS the drive +# listing — there's no writable parent, so 405 +# Method Not Allowed. This guard prevents a client +# from creating something at "root" that shadows a +# drive name. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/ +Authorization: Bearer {{token}} + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Reject MKCOL at `/webdav/`. The first +# URL segment is the drive selector in this config; +# an unknown selector yields 404. A client cannot +# "create a drive" via MKCOL — the drive-create +# surface is `POST /api/drives`. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/hurl-not-a-real-drive +Authorization: Bearer {{token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Reject PUT at `/webdav//x.txt`. Same +# rejection shape as MKCOL. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/hurl-not-a-real-drive/probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cleanup: DELETE the magic folder via REST. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{magic_folder_id}} +Authorization: Bearer {{token}} + +HTTP 204 diff --git a/tests/webdav-drive-root/run.sh b/tests/webdav-drive-root/run.sh new file mode 100755 index 00000000..fc8d4631 --- /dev/null +++ b/tests/webdav-drive-root/run.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# WebDAV drive-root URL-scheme variant runner. +# +# Exercises `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` — the config where the +# WebDAV `@drive` path segment is disabled and `/webdav/` IS the +# drive listing. `tests/api/webdav_drive_root.hurl` covers the +# default `"@drive"` config in the main API run; this runner +# starts a separately-configured server to cover the empty-string +# case, mirroring the OIDC runner's shape. +# +# Usage (from repo root): +# bash tests/webdav-drive-root/run.sh +# +# Prerequisites: docker, cargo, hurl ≥ 4.0 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +TEST_DIR="$REPO_ROOT/tests/webdav-drive-root" + +# shellcheck source=test.env +source "$TEST_DIR/test.env" + +SERVER_PORT="${base_url##*:}" + +log() { echo "[webdav-drive-root] $*"; } +die() { echo "[webdav-drive-root] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 1 + done +} + +# ── Teardown (always runs on exit) ──────────────────────────────────────────── + +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + bash "$COMMON/stop-db.sh" +} + +trap cleanup EXIT + +# ── 1. Start postgres ───────────────────────────────────────────────────────── + +bash "$COMMON/spawn-db.sh" + +# ── 2. Load the drive-root-variant server env + port ────────────────────────── + +set -a +# shellcheck source=../common/server-webdav-drive-root.env +source "$COMMON/server-webdav-drive-root.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav-drive-root/storage" +set +a + +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" + +# ── 3. Start OxiCloud server with the drive-root-variant config ─────────────── + +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ ! -x "$OXICLOUD_BIN" ]]; then + log "Building OxiCloud server ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;; + *) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;; + esac +fi + +log "Starting OxiCloud server with WEBDAV_DRIVE_LISTING_PREFIX='' on port $SERVER_PORT..." +"$OXICLOUD_BIN" --config "$COMMON/server-webdav-drive-root.env" & +SERVER_PID=$! +log "Waiting for server at $base_url..." +wait_for_http "$base_url/ready" 120 +log "Server is ready." + +# ── 4. Run Hurl tests ───────────────────────────────────────────────────────── +# +# `setup.hurl` from the shared api/ suite bootstraps the initial admin +# account via `POST /api/setup` — the endpoint locks after the first +# admin exists, so it's a one-shot idempotency-by-server-state seed. +# We reuse the file rather than duplicating the setup body so credential +# / schema changes in the api tests automatically flow here. + +log "Running Hurl tests..." +hurl --variables-file "$TEST_DIR/test.env" \ + --file-root "$REPO_ROOT/tests" \ + --test --jobs 1 \ + "$REPO_ROOT/tests/api/setup.hurl" \ + "$TEST_DIR/drive_root_empty_config.hurl" + +log "webdav-drive-root tests passed." diff --git a/tests/webdav-drive-root/test.env b/tests/webdav-drive-root/test.env new file mode 100644 index 00000000..5da9de60 --- /dev/null +++ b/tests/webdav-drive-root/test.env @@ -0,0 +1,9 @@ +# Test credentials for the WebDAV drive-root variant runner — NOT real secrets. +# Runs on a separate port from tests/api and tests/webdav so a +# `just api-test` chain doesn't collide when the previous runner's +# teardown is still in progress. +base_url=http://localhost:8089 +username=admin +email=admin@example.com +# gitguardian:ignore +password=TestPassword1! From 79f479270941169a8eb35270a7528c27e19cac11 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 6 Jul 2026 22:03:48 +0200 Subject: [PATCH 33/49] fix(quota): pre-check quota for COPY/MOVE check qouta for a cross drive MOVE check quota for a COPY --- .../services/file_management_service.rs | 81 +++++++++++++++++ src/application/services/folder_service.rs | 32 +++++++ .../services/storage_usage_service.rs | 41 +++++++++ src/common/di.rs | 12 ++- .../services/webdav_lock_service.rs | 5 +- tests/api/drive_quota.hurl | 88 +++++++++++++++++++ 6 files changed, 256 insertions(+), 3 deletions(-) diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 8a1a8a82..d617357f 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -43,6 +43,13 @@ pub struct FileManagementService { /// that case the cross-drive move check is skipped (the policy /// is silently off). Production DI wires it in. drive_repo: Option>, + /// Storage-usage service — used to pre-check the destination + /// drive's `used_bytes + delta ≤ quota_bytes` invariant on + /// cross-drive MOVE, matching the pre-write check the upload path + /// already performs. Without it, the check is silently skipped + /// (stub/test builders); production DI wires it in. + storage_usage: + Option>, } impl FileManagementService { @@ -67,6 +74,7 @@ impl FileManagementService { file_lifecycle_hook: None, resource_access_hook: None, drive_repo: None, + storage_usage: None, } } @@ -100,6 +108,18 @@ impl FileManagementService { self } + /// Wires the storage-usage service so `move_file_with_perms` can + /// pre-check the destination drive's quota on cross-drive moves. + pub fn with_storage_usage( + mut self, + storage_usage: Arc< + crate::application::services::storage_usage_service::StorageUsageService, + >, + ) -> Self { + self.storage_usage = Some(storage_usage); + self + } + /// Engine check for a file resource. Parses the id into a `Uuid` and /// requires the specified permission. async fn require_file_perm( @@ -338,6 +358,22 @@ impl FileManagementUseCase for FileManagementService { dst_drive_id, }, )?; + // Destination drive quota: same pre-write check the + // upload path already runs (`file_upload_service.rs` + // `check_storage_quota`), applied here so a caller + // can't sneak content past the drive cap via MOVE. + // Denial → `DomainError::QuotaExceeded` → 507 + // Insufficient Storage. Skipped when `storage_usage` + // isn't wired (stub builders) — same shape as the + // upload path's skip semantics. + if let Some(storage_usage) = &self.storage_usage + && let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await? + && let Ok(size_u64) = u64::try_from(size_bytes) + { + storage_usage + .check_drive_quota(dst_drive_id, size_u64) + .await?; + } cross_drive = Some((src_drive_id, dst_drive_id)); } } @@ -375,6 +411,31 @@ impl FileManagementUseCase for FileManagementService { .await?; self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id) .await?; + + // Destination drive quota: COPY creates a new file row that + // counts against the destination drive's `used_bytes` even + // though blob dedup means no new bytes hit the store. Same + // pre-flight shape the delta-upload path already uses. + // Skipped when `storage_usage` isn't wired (stub builders) or + // `target_folder_id` is None (root namespace — same-drive + // semantics inherit the source's cap coverage). Denial → + // `QuotaExceeded` → 507. + if let (Some(storage_usage), Some(target_folder)) = + (&self.storage_usage, target_folder_id.as_deref()) + { + let file_uuid = + Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?; + let target_folder_uuid = Uuid::parse_str(target_folder) + .map_err(|_| DomainError::not_found("Folder", target_folder))?; + if let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await? + && let Ok(size_u64) = u64::try_from(size_bytes) + { + storage_usage + .check_drive_quota_by_folder(target_folder_uuid, size_u64) + .await?; + } + } + self.copy_file(file_id, target_folder_id, new_name.as_deref(), caller_id) .await } @@ -453,6 +514,26 @@ impl FileManagementUseCase for FileManagementService { .await?; self.require_target_folder_perm(target_parent_id.as_deref(), Permission::Create, caller_id) .await?; + + // Destination drive quota: sum the subtree's non-trashed files + // and refuse if the destination couldn't hold them. Skipped + // when `storage_usage` isn't wired or the target is root + // (same rationale as `copy_file_with_perms`). + if let (Some(storage_usage), Some(target_parent)) = + (&self.storage_usage, target_parent_id.as_deref()) + { + let source_uuid = Uuid::parse_str(source_folder_id) + .map_err(|_| DomainError::not_found("Folder", source_folder_id))?; + let target_parent_uuid = Uuid::parse_str(target_parent) + .map_err(|_| DomainError::not_found("Folder", target_parent))?; + let subtree_bytes = storage_usage.folder_subtree_bytes(source_uuid).await?; + if let Ok(subtree_u64) = u64::try_from(subtree_bytes) { + storage_usage + .check_drive_quota_by_folder(target_parent_uuid, subtree_u64) + .await?; + } + } + self.copy_folder_tree(source_folder_id, target_parent_id, dest_name) .await } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 20c16681..4503cf8f 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -31,6 +31,11 @@ pub struct FolderService { /// that case the cross-drive move check is skipped (the policy is /// silently off). Production DI wires it via `with_drive_repo`. drive_repo: Option>, + /// Storage-usage service — used to pre-check the destination + /// drive's `used_bytes + subtree_bytes ≤ quota_bytes` invariant + /// on cross-drive MOVE. Silently skipped when unwired (stubs). + storage_usage: + Option>, } impl FolderService { @@ -45,6 +50,7 @@ impl FolderService { authz, file_lifecycle, drive_repo: None, + storage_usage: None, } } @@ -60,6 +66,19 @@ impl FolderService { self } + /// Wires the storage-usage service so `move_folder_with_perms` + /// can pre-check the destination drive's quota on cross-drive + /// folder moves. + pub fn with_storage_usage( + mut self, + storage_usage: Arc< + crate::application::services::storage_usage_service::StorageUsageService, + >, + ) -> Self { + self.storage_usage = Some(storage_usage); + self + } + /// Batch counterpart of `get_folder`: resolve many folder ids in ONE /// query instead of one per id. Like `get_folder` it performs no /// per-folder authorization — both current callers (ACL grant listing, @@ -593,6 +612,19 @@ impl FolderUseCase for FolderService { dst_drive_id, }, )?; + // Destination drive quota: sum the moved subtree's + // non-trashed files and refuse if the destination + // couldn't hold them. Same 507 shape as the file + // path + upload path — DomainError::QuotaExceeded + // maps at the AppError boundary. + if let Some(storage_usage) = &self.storage_usage { + let subtree_bytes = storage_usage.folder_subtree_bytes(src_folder_uuid).await?; + if let Ok(subtree_u64) = u64::try_from(subtree_bytes) { + storage_usage + .check_drive_quota(dst_drive_id, subtree_u64) + .await?; + } + } cross_drive = Some((src_drive_id, dst_drive_id)); } } diff --git a/src/application/services/storage_usage_service.rs b/src/application/services/storage_usage_service.rs index 56ed625e..637419a2 100644 --- a/src/application/services/storage_usage_service.rs +++ b/src/application/services/storage_usage_service.rs @@ -213,6 +213,47 @@ impl StorageUsageService { Ok(()) } + /// Return the size in bytes of a single non-trashed file. `None` + /// if the file is trashed or absent. Used by cross-drive MOVE to + /// know how many bytes will land on the destination drive so the + /// pre-move `check_drive_quota` call can fire. + pub async fn file_bytes(&self, file_id: Uuid) -> Result, DomainError> { + let row: Option<(i64,)> = sqlx::query_as( + "SELECT size::bigint FROM storage.files WHERE id = $1 AND NOT is_trashed", + ) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("StorageUsage", format!("file_bytes: {e}")))?; + Ok(row.map(|(s,)| s)) + } + + /// Sum the sizes of every non-trashed file whose parent folder is + /// `folder_id` itself or a descendant of it via the `lpath` ltree. + /// Used by cross-drive MOVE to know how many bytes would land on + /// the destination drive — necessary for the pre-move + /// `check_drive_quota` call. + /// + /// Returns 0 for an empty subtree AND for a non-existent + /// `folder_id` (the JOIN silently drops); callers that need to + /// distinguish those two cases must probe the folder separately. + pub async fn folder_subtree_bytes(&self, folder_id: Uuid) -> Result { + let (bytes,): (Option,) = sqlx::query_as( + "SELECT COALESCE(SUM(f.size), 0)::bigint + FROM storage.files f + JOIN storage.folders fo ON fo.id = f.folder_id + WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1) + AND NOT f.is_trashed", + ) + .bind(folder_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("StorageUsage", format!("folder_subtree_bytes: {e}")) + })?; + Ok(bytes.unwrap_or(0)) + } + /// Same as [`Self::add_drive_storage_usage_delta`] but resolves /// the drive id from a parent folder id in a single statement. /// Avoids a separate `SELECT drive_id FROM storage.folders` round diff --git a/src/common/di.rs b/src/common/di.rs index 959ad7c2..b3a777e6 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -536,7 +536,12 @@ impl AppServiceFactory { // drive repo every other policy uses. Wired here so // `move_folder_with_perms` can enforce // `forbid_cross_drive_move` without a separate construction path. - .with_drive_repo(drive_repo.clone()), + .with_drive_repo(drive_repo.clone()) + // Destination-drive quota pre-check on cross-drive folder + // MOVE. Reuses the `check_drive_quota` the upload path + // already runs. Without this, a Move that would push the + // destination past its cap succeeds silently. + .with_storage_usage(storage_usage.clone()), ); // Built before the upload/management services so the plugin lifecycle @@ -618,7 +623,10 @@ impl AppServiceFactory { // drive repo every other policy uses. Wired here so // `move_file_with_perms` can enforce `forbid_cross_drive_move` // without a separate construction path. - .with_drive_repo(drive_repo.clone()); + .with_drive_repo(drive_repo.clone()) + // Destination-drive quota pre-check on cross-drive file + // MOVE. Same rationale as the folder side above. + .with_storage_usage(storage_usage.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } diff --git a/src/infrastructure/services/webdav_lock_service.rs b/src/infrastructure/services/webdav_lock_service.rs index 919b6bfc..9bedaf99 100644 --- a/src/infrastructure/services/webdav_lock_service.rs +++ b/src/infrastructure/services/webdav_lock_service.rs @@ -321,7 +321,7 @@ mod tests { let store = WebDavLockStore::new(16); let info = lock_info("urn:token-1", Some("Second-600"), LockScope::Exclusive); - let acquired = store.acquire("/a.txt", info).expect("acquire"); + let acquired = store.acquire("/a.txt", info, None).expect("acquire"); assert_eq!(acquired.info.token, "urn:token-1"); // Resolvable by both indexes. @@ -348,12 +348,14 @@ mod tests { .acquire( "/a.txt", lock_info("urn:token-1", Some("Second-600"), LockScope::Exclusive), + None, ) .expect("first acquire"); let conflict = store.acquire( "/a.txt", lock_info("urn:token-2", Some("Second-600"), LockScope::Exclusive), + None, ); assert!(conflict.is_err()); // The original holder is returned so the caller can report it. @@ -367,6 +369,7 @@ mod tests { .acquire( "/a.txt", lock_info("urn:token-1", Some("Infinite"), LockScope::Exclusive), + None, ) .expect("acquire"); diff --git a/tests/api/drive_quota.hurl b/tests/api/drive_quota.hurl index ec7a5540..018ad6ce 100644 --- a/tests/api/drive_quota.hurl +++ b/tests/api/drive_quota.hurl @@ -302,6 +302,94 @@ jsonpath "$.blobs_deleted" exists jsonpath "$.bytes_freed" exists +# ───────────────────────────────────────────────────────────── +# Step 11 — Pre-flight quota gate on MOVE and COPY. +# +# Silent gap before 2026-07-06: +# `move_file_with_perms` / `move_folder_with_perms` +# / `copy_file_with_perms` / `copy_folder_tree_with_perms` +# never called `check_drive_quota` on the destination. +# A user could bypass a tight drive's cap by uploading +# to their unlimited personal drive first and MOVE-ing +# (or COPY-ing) into the tight drive afterwards. +# +# Fix landed in the service layer, so both REST + WebDAV + +# NC WebDAV surfaces got the check for free. This step +# locks in the 507 shape on the REST path: +# +# a) MOVE a 5 MiB file from unlimited → tight → 507. +# b) COPY a 5 MiB file from unlimited → tight → 507. +# c) Sanity — same MOVE targeted at unlimited still 200. +# ───────────────────────────────────────────────────────────── + +# Capture the 5 MiB file id currently living in the unlimited drive +# (uploaded at Step 7). We'll try to relocate it into the 100-byte +# tight drive. +GET {{base_url}}/api/files?folder_id={{unlimited_root_id}} +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Captures] +big_file_id: jsonpath "$[0].id" + + +# 11a — MOVE 5 MiB file into the tight (100-byte quota) drive. +# Refused at the service pre-check: 5_242_880 + 32 > 100. +PUT {{base_url}}/api/files/{{big_file_id}}/move +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "folder_id": "{{tight_root_id}}" +} + +HTTP 507 + + +# 11b — COPY same file into tight drive. Same refusal shape as MOVE +# — COPY creates a NEW file row that counts against +# `drives.used_bytes` even when blob dedup means no new bytes +# hit the store. +POST {{base_url}}/api/files/copy +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "file_ids": ["{{big_file_id}}"], + "target_folder_id": "{{tight_root_id}}" +} + +# Batch endpoint returns 206 Partial when at least one item fails +# with a per-item error. Per-item quota rejection is the wire +# shape here — assert the 507 landed in the per-item results, +# not on the envelope. +HTTP 206 +[Asserts] +jsonpath "$.results[?(@.file_id=='{{big_file_id}}')].error" exists + + +# 11c — Sanity: the file MOVE isn't universally broken. Targeting +# the unlimited drive's own root succeeds (it's already +# there, but MOVE is idempotent for same-parent — service +# returns 200 without re-doing storage work). +PUT {{base_url}}/api/files/{{big_file_id}}/move +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "folder_id": "{{unlimited_root_id}}" +} + +HTTP 200 + + +# `used_bytes` on the tight drive is unchanged — the two refused +# operations above never wrote anything. +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 32 + + # No cleanup tail here — `tests/api/storage_cleanup_check.sh` enumerates # every drive via `GET /api/admin/drives` and drains+deletes any that # isn't admin's default. This keeps individual Hurl tests focused on From f7deb7aaf4b6c0cf69d106a49288b2a25eb9dd85 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 6 Jul 2026 23:01:37 +0200 Subject: [PATCH 34/49] fix(authz): invalidate role cache on change --- .../services/drive_management_service.rs | 23 +++++++ .../services/file_management_service.rs | 14 ++++ src/application/services/folder_service.rs | 11 +++ src/infrastructure/services/pg_acl_engine.rs | 69 ++++++++++++++++++- tests/api/drive_quota.hurl | 19 ++--- tests/api/storage_cleanup_check.sh | 7 +- tests/api/webdav_permissions.hurl | 1 + 7 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index 5d472d04..b2b34537 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -249,6 +249,20 @@ impl DriveManagementService { .set_role(caller_id, subject, role, resource, expires_at) .await?; + // Drop the entire drive-role cache for this drive so the new + // grant is visible on the very next `check` — without this, a + // caller that gets Owner via `POST /api/drives/{id}/members` + // then immediately acts on drive content (WebDAV cross-drive + // MOVE, admin-driven cleanup, drive management) hits the + // stale "no role for this subject on this drive" entry + // seeded at some earlier `check`. TTL rescues eventually, + // but the storage_cleanup_check.sh drain pattern hits this + // race within a single test-second and fails on `authz.denied` + // for admin's cascade to files inside. + self.authz + .invalidate_drive_role_cache_for_drive(drive_id) + .await; + // D6 §11: canonical `drive.member_added` audit event covers // every successful membership write (add + role-refresh, since // the underlying `set_role` is UPSERT — distinguishing the two @@ -312,6 +326,15 @@ impl DriveManagementService { self.authz.clear_role(subject, resource).await?; + // Mirror of `set_member_role`'s cache invalidation: after + // clearing a role we MUST drop the `drive_role_cache` entries + // targeting this drive, otherwise the just-removed subject's + // former role stays visible until TTL expires. Same anti-drift + // reason as the sibling add path above. + self.authz + .invalidate_drive_role_cache_for_drive(drive_id) + .await; + // D6 §11: canonical `drive.member_removed` audit event covers // every successful removal (owner-driven or admin bypass). // `via_admin` replaces the separate diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index d617357f..1807852c 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -380,6 +380,20 @@ impl FileManagementUseCase for FileManagementService { let dto = self.move_file(file_id, folder_id, caller_id).await?; + // Cross-drive move invalidates the file's `owner_cache` entry + // in the authz engine — the cache assumed drive_id stability + // that no longer holds. Without this call the drive-role + // precheck at `check_inner` steers to the (stale) source + // drive and legitimate Delete/Update by a destination-drive + // role-holder returns 404 for up to the cache TTL. + if cross_drive.is_some() + && let Ok(file_uuid) = Uuid::parse_str(file_id) + { + self.authz + .invalidate_owner_cache_for_resource(Resource::File(file_uuid)) + .await; + } + // D6 §11 audit: emit only when the move actually crossed a // drive boundary. Same-drive moves are too noisy to audit at // info — operators care about the cross-drive case for diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 4503cf8f..a1add88f 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -641,6 +641,17 @@ impl FolderUseCase for FolderService { ) })?; + // Cross-drive move flushes the authz engine's `owner_cache` + // — every descendant's cached `Resource → drive_id` mapping + // just got stale via the cascade trigger, and we don't (yet) + // walk the subtree to invalidate individually. Small perf + // cost (single JOIN per resource touched over the next + // minute) versus a stale-authz bug where destination-drive + // Owner cascades don't apply to moved content. + if cross_drive.is_some() { + self.authz.invalidate_owner_cache_all().await; + } + // D6 audit: only emit when the move crossed a drive boundary. // The cascade trigger has already propagated drive_id to the // subtree at this point (see migration diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index ebee39bf..6d346ba4 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -283,6 +283,62 @@ impl PgAclEngine { } } + /// Drop the `owner_cache` entry for `resource`. Called after any + /// operation that changes which drive a file/folder belongs to — + /// the pre-D6 comment on `owner_cache` ("a resource's owner is + /// immutable") stopped being true when cross-drive MOVE landed. + /// + /// Without this call, admin (or any other role holder) on the + /// destination drive gets `authz.denied` when acting on the moved + /// resource: the cached (stale) `Resource → src_drive_id` lookup + /// steers the drive-role precheck at `check_inner` toward the + /// SOURCE drive where the caller has no role, and the fallback + /// per-resource cascade doesn't cover drive-level grants. TTL + /// backstops eventually (5 min), but every write path that MOVEs + /// content across drives MUST invalidate here so authz observes + /// the new drive on the next check. + pub async fn invalidate_owner_cache_for_resource(&self, resource: Resource) { + self.owner_cache.invalidate(&resource).await; + } + + /// Bulk cousin of [`Self::invalidate_owner_cache_for_resource`] — + /// clears the entire `owner_cache`. Called by folder cross-drive + /// MOVE where the moved subtree's descendants each carry their + /// own stale entry, and we don't (yet) walk the subtree to + /// invalidate them individually. The cache repopulates lazily on + /// next access; the overhead is a single JOIN per file/folder + /// touched in the following minute or two, versus a stale-authz + /// bug that returned `NotFound` for legitimate Delete. + pub async fn invalidate_owner_cache_all(&self) { + self.owner_cache.invalidate_all(); + } + + /// Sibling of [`Self::invalidate_drive_role_cache_for_drive`] keyed by + /// subject rather than drive. Used by the user-deleted lifecycle hook + /// to reap every cached "user X → drive Y = role R" entry after the + /// user row (and its DB-cascade-cleared role_grants) is gone. Without + /// this call the entry lingers until TTL; in practice auth rejection + /// on the deleted user's tokens fires first, but leaving stale + /// authorisation rows in the cache is poor hygiene and would surface + /// as an issue if a session survived (e.g. long-lived Basic Auth via + /// app password) or if a same-uuid user were ever recreated. + pub async fn invalidate_drive_role_cache_for_subject(&self, subject: Subject) { + if let Err(err) = self + .drive_role_cache + .invalidate_entries_if(move |key, _v| key.0 == subject) + { + tracing::error!( + target: "oxicloud::authz", + event = "authz.cache_invalidation_failed", + cache = "drive_role_cache", + subject = ?subject, + error = %err, + "drive_role_cache cannot be bulk-invalidated by subject — \ + cache builder is missing support_invalidation_closures()", + ); + } + } + /// Expand a user subject into the set of subject UUIDs that should match /// in `access_grants`: the user's own UUID, every group the user is /// transitively a member of, and (for internal users only) the implicit @@ -2099,8 +2155,19 @@ impl UserLifecycleHook for AuthzCacheLifecycleHook { _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DomainError> { // No DB writes here — just memory invalidation. `_tx` is - // intentionally ignored. + // intentionally ignored. The DB cascade + // (`trg_cleanup_role_grants_user`) already dropped every + // role_grants row for this subject; we mirror that cleanup on + // both authz caches: + // 1. `user_groups_cache` — recomputed group expansion. + // 2. `drive_role_cache` — cached "user X → drive Y = role R" + // entries seeded by prior authz checks. Without this + // the deleted user's role stays visible in-process for + // up to the cache TTL (~30 s). self.engine.invalidate_user_groups_cache(user.id()).await; + self.engine + .invalidate_drive_role_cache_for_subject(Subject::User(user.id())) + .await; Ok(()) } } diff --git a/tests/api/drive_quota.hurl b/tests/api/drive_quota.hurl index 018ad6ce..b624d748 100644 --- a/tests/api/drive_quota.hurl +++ b/tests/api/drive_quota.hurl @@ -348,8 +348,9 @@ HTTP 507 # 11b — COPY same file into tight drive. Same refusal shape as MOVE # — COPY creates a NEW file row that counts against # `drives.used_bytes` even when blob dedup means no new bytes -# hit the store. -POST {{base_url}}/api/files/copy +# hit the store. Batch endpoint lives under `/api/batch/…`, +# not `/api/files/…`. +POST {{base_url}}/api/batch/files/copy Authorization: Bearer {{owner_token}} Content-Type: application/json { @@ -357,13 +358,15 @@ Content-Type: application/json "target_folder_id": "{{tight_root_id}}" } -# Batch endpoint returns 206 Partial when at least one item fails -# with a per-item error. Per-item quota rejection is the wire -# shape here — assert the 507 landed in the per-item results, -# not on the envelope. -HTTP 206 +# Batch envelope: 200 all-ok, 206 partial, 400 all-failed. Our +# single-item batch has one quota-refused item → 400 with the +# failure in the `.failed[]` array (per `BatchOperationResponse`). +HTTP 400 [Asserts] -jsonpath "$.results[?(@.file_id=='{{big_file_id}}')].error" exists +jsonpath "$.stats.failed" == 1 +jsonpath "$.stats.successful" == 0 +jsonpath "$.failed[0].id" == "{{big_file_id}}" +jsonpath "$.failed[0].error" exists # 11c — Sanity: the file MOVE isn't universally broken. Targeting diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index 1943b6aa..5bed925f 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -150,9 +150,14 @@ while IFS= read -r drive_id; do while IFS= read -r file_id; do [[ -z "$file_id" ]] && continue - curl -sf -X DELETE -H "$AUTH" "$base_url/api/files/$file_id" >/dev/null + HTTP_STATUS=$(curl -s -H "$AUTH" -o /tmp/del.json -w '%{http_code}' \ + -X DELETE "$base_url/api/files/$file_id") + if [[ "$HTTP_STATUS" != "204" ]]; then + log "FILE DELETE FAILED: file=$file_id drive=$drive_id ($DRIVE_NAME) status=$HTTP_STATUS body=$(cat /tmp/del.json)" + fi done < <(echo "$CONTENTS" | jq -r '.items[] | select(.resource_type == "file") | .resource.id') + # Empty the drive's per-drive trash so D3b's "drive must be empty" # guard passes on the delete. `/api/trash/drive/{id}` is the # Owner-only per-drive empty (admin is Owner now via the grant diff --git a/tests/api/webdav_permissions.hurl b/tests/api/webdav_permissions.hurl index 8b930239..48707af3 100644 --- a/tests/api/webdav_permissions.hurl +++ b/tests/api/webdav_permissions.hurl @@ -91,6 +91,7 @@ Content-Type: application/json HTTP 201 [Captures] shared_drive_id: jsonpath "$.id" +shared_root_id: jsonpath "$.root_folder_id" # ───────────────────────────────────────────────────────────── From 3108fed228bb7732289c0b38d427152bdf0891ad Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 7 Jul 2026 20:47:51 +0200 Subject: [PATCH 35/49] fix(front): clean localStorage on user change - fix issue with selected drive and user logout/login via another user (was raising a "404 not found") - normalize all localStorage to "oxi-" prefix - add a specific frontend/AGENTS.md for frontend part (stop increasing the global AGENTS.md) --- frontend/AGENTS.md | 11 +++ frontend/src/app.html | 15 +++- frontend/src/lib/i18n/i18n.test.ts | 2 +- frontend/src/lib/i18n/index.svelte.ts | 2 +- frontend/src/lib/stores/files.svelte.ts | 2 +- frontend/src/lib/stores/files.test.ts | 2 +- frontend/src/lib/stores/session.svelte.ts | 17 ++++- frontend/src/lib/stores/theme.svelte.ts | 28 ++++--- frontend/src/lib/stores/theme.test.ts | 28 ++++++- frontend/src/lib/utils/localStoragePrefs.ts | 81 +++++++++++++++++++++ frontend/src/routes/login/+page.svelte | 6 +- frontend/src/routes/login/page.test.ts | 21 ++++-- frontend/src/routes/photos/+page.svelte | 4 +- frontend/src/routes/s/[token]/+page.svelte | 2 +- 14 files changed, 188 insertions(+), 33 deletions(-) create mode 100644 frontend/AGENTS.md create mode 100644 frontend/src/lib/utils/localStoragePrefs.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 00000000..4ff903a0 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md — Frontend + +Complements the repo-root `/AGENTS.md`. Not shipped (adapter-static +copies only `frontend/static/`). + +## localStorage keys + +Prefix `oxi-`, kebab-case separators. Example: `oxi-view-mode`. +Enforced by `$lib/utils/localStoragePrefs::wipeAppKeys()` which sweeps +every `oxi-*` key on user-account switches — any other prefix leaks the +previous user's state into the new one. diff --git a/frontend/src/app.html b/frontend/src/app.html index 505b1b20..2b36ec6e 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -8,9 +8,16 @@ %sveltekit.head% + + + + + %sveltekit.head%