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 @@
+
+
+
+
+
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.'
)}
-
+ {
+ 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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