Merge pull request #479 from EdouardVanbelle/feat/drive-impl

feat/drive impl
This commit is contained in:
Dionisio Pozo
2026-06-19 18:48:32 +02:00
committed by GitHub
102 changed files with 6362 additions and 1078 deletions
+7 -1
View File
@@ -14,6 +14,12 @@ env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test"
# Re-enable the legacy build.rs static-dist + OUT_DIR HTML pipeline.
# Required while login_v2_handler.rs still uses
# `include_str!(concat!(env!("OUT_DIR"), "/nextcloud-login.html"))`;
# without this, every cargo job fails to compile that file.
# Remove once the Nextcloud login page moves off include_str! (askama).
OXICLOUD_RUST_ASSETS: "1"
jobs:
@@ -179,7 +185,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
+2
View File
@@ -22,6 +22,8 @@ on:
env:
CARGO_TERM_COLOR: always
# See ci.yml — required while login_v2_handler.rs uses include_str! against OUT_DIR.
OXICLOUD_RUST_ASSETS: "1"
jobs:
load:
+2
View File
@@ -18,6 +18,8 @@ on:
env:
CARGO_TERM_COLOR: always
# See ci.yml — required while login_v2_handler.rs uses include_str! against OUT_DIR.
OXICLOUD_RUST_ASSETS: "1"
jobs:
smoke:
+1 -1
View File
@@ -210,7 +210,7 @@ External users have no calendar, no address book, no home folder, and (by design
6. **`POST /api/auth/app-passwords` is closed.** App passwords are persistent credentials; the magic-link-eligibility rule (`has_login_credential`) assumes externals have **no other credential configured**. Letting an external mint an app password would break that invariant and would also be the only way to authenticate them on the NC surface. 403 + audit on rejection.
7. **`GET /api/groups/search` is closed.** Group names aren't strictly secret, but externals have no legitimate use for the share-dialog autocomplete (they can't be added to groups anyway).
Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints `users_external_not_admin` and `users_external_no_storage`, and the `HomeFolderLifecycleHook` short-circuit that skips home-folder provisioning for externals.
Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints `users_external_not_admin` and `users_external_no_storage`, and the `PersonalDriveLifecycleHook` short-circuit that skips drive provisioning for externals (they get no default drive, so `DriveRepository::home_root_folder_id_for(external_user_id)` returns `Ok(None)`).
### Why protocol-level instead of handler-level
+30 -9
View File
@@ -77,8 +77,7 @@ The asymmetry is deliberate. `on_user_created` and `on_user_login` must complete
│
┌────────────────┼────────────────┐
▼ ▼ ▼
AuditLifecycleHook HomeFolderHook AuthzCacheHook …
(PR 1 only) (PR 3) (PR 4)
AuditLifecycleHook PersonalDriveHook AuthzCacheHook …
```
## Owner-located convention
@@ -87,7 +86,7 @@ Each concrete hook impl lives **next to the service that owns the work**, not in
Examples (PR plan):
- `HomeFolderLifecycleHook` lives in `src/application/services/folder_service.rs` — same module as `FolderService`, owner of home-folder policy.
- `PersonalDriveLifecycleHook` lives in `src/application/services/folder_service.rs` — same module as `FolderService`, owner of home-drive provisioning policy. It calls `DrivePgRepository::create_personal_drive_atomic` to create the drive + root folder + Owner role-grant in one atomic transaction (see [docs/plan/drive.md §3](https://github.com/EdouardVanbelle/OxiCloud/blob/main/docs/plan/drive.md)).
- `AuthzCacheLifecycleHook` lives in `src/infrastructure/services/pg_acl_engine.rs` — same module as the Moka cache it invalidates.
- `AuditLifecycleHook` lives in `src/application/services/user_lifecycle_service.rs` (with the dispatcher) — cross-cutting, no domain owner.
@@ -120,7 +119,7 @@ These are codified in the module-level docstring of `application/ports/user_life
| Hook | Lives in | Responsibility |
|---|---|---|
| `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher) | All four events: emits one `tracing::info!(target: "audit", event = "user.*", ...)` per call, with `is_external` as a field. Co-located because audit is cross-cutting with no domain owner. |
| `HomeFolderLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision "My Folder - {username}" via `FolderService::ensure_home_folder`. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: per-mode `tracing::info!` event so audit distinguishes AdminDelete from GdprPurge — the FK CASCADE on `storage.folders.user_id` handles the actual row removal. Trash-with-retention is documented as future work. |
| `PersonalDriveLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision the user's **default personal drive** + its root folder + Owner role-grant via `DrivePgRepository::create_personal_drive_atomic` — all four DB writes in one transaction (drive INSERT, folder INSERT, `drives.root_folder_id` UPDATE, `role_grants` INSERT). Idempotency: `find_default_for_user` short-circuits if the user already has a drive. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: per-mode `tracing::info!` event so audit distinguishes AdminDelete from GdprPurge — the FK CASCADE on `storage.drives.default_for_user` handles the actual drive row removal, which cascades to folders/files via their `drive_id` FK. Trash-with-retention is documented as future work. |
| `AuthzCacheLifecycleHook` | `src/infrastructure/services/pg_acl_engine.rs` (same module as the Moka cache it invalidates) | `on_user_logout` + `on_user_deleted`: `engine.invalidate_user_groups_cache(user.id())` — drops the cached transitive-group expansion immediately so a re-login (or a re-created account with the same id) sees fresh memberships without waiting for the 30 s TTL. `on_user_created` + `on_user_login`: `Ok(())` (no stale entry could exist for these). |
| `SessionRevocationLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher; no dedicated session service module today) | `on_user_deleted`: explicit `session_storage.revoke_all_user_sessions(user.id())` + aggregate audit event (`event = "user.sessions_revoked_on_delete", count = N`). Replaces the silent FK CASCADE with an observable revocation. All other events: `Ok(())`. |
| `DeletionMode` | enum on the trait | Distinguishes admin-initiated delete (`AdminDelete` — currently identical to GDPR but reserved for a future trash-with-retention policy) from GDPR right-to-erasure purge (`GdprPurge` — for a future sweeper). PR 4 ships the variants; future PRs may add per-mode behaviour. |
@@ -138,7 +137,7 @@ BEGIN
dispatch_deleted(user, AdminDelete, &mut tx)
│
├── AuditLifecycleHook → tracing::info!(event="user.deleted", mode=...)
├── HomeFolderLifecycleHook → tracing::info!("home folder will be removed via FK CASCADE")
├── PersonalDriveLifecycleHook → tracing::info!("default drive + its tree will be removed via FK CASCADE on storage.drives.default_for_user")
├── AuthzCacheLifecycleHook → engine.invalidate_user_groups_cache(user_id)
└── SessionRevocationLifecycleHook
→ session_storage.revoke_all_user_sessions(user_id)
@@ -163,13 +162,35 @@ If any hook returns `Err`, the dispatcher propagates it; `delete_user_admin` rol
2. `AuthApplicationService::login()` validates the password against the stored Argon2 hash.
3. **Before** `user.register_login()` is called, the dispatcher fires `dispatch_login(&user)`. The user's `last_login_at` is still `None` from creation time.
4. `AuditLifecycleHook::on_user_login` runs first (registration order): emits `event = "user.login", user_id = ..., username = ..., is_external = false, first_login = true`.
5. `HomeFolderLifecycleHook::on_user_login` runs next: sees `!user.is_external()`, calls `FolderService::ensure_home_folder(uid, username)`. The service checks `list_folders_by_owner(None, uid)` — empty → creates `"My Folder - alice"`. Returns `Ok(true)` (newly created).
5. `PersonalDriveLifecycleHook::on_user_login` runs next: sees `!user.is_external()`, calls `provision_if_needed`. The hook asks `DriveRepository::find_default_for_user(uid)` — returns `NotFound`. It then calls `DrivePgRepository::create_personal_drive_atomic(uid, quota)` which runs the four-write transaction: INSERT drive, INSERT folder, UPDATE `drives.root_folder_id`, INSERT `role_grants` row. All four commit together.
6. Dispatcher finishes. `user.register_login()` is now called, stamping `last_login_at` to the current time.
7. The session row is INSERTed; access + refresh tokens generated; response returned to the client.
On the user's **second** login: same flow up through step 5, but `ensure_home_folder` finds the existing folder, returns `Ok(false)`, no-op. The `AuditLifecycleHook` still emits an event, but `first_login = false` this time.
On the user's **second** login: same flow up through step 5, but `find_default_for_user` returns `Ok(drive)` (the drive already exists). The hook re-emits the Owner `role_grant` via `set_role` (UPSERT-safe) as belt-and-suspenders against partial provisioning and returns. The `AuditLifecycleHook` still emits an event, but `first_login = false` this time.
If the home folder gets deleted manually (e.g., SQL `DELETE FROM storage.folders WHERE user_id = $1`), the user's **next** login will re-create it — that's the safety-net behaviour the lifecycle hook contractually owns.
If the drive gets deleted manually (e.g., SQL `DELETE FROM storage.drives WHERE default_for_user = $1`), the user's **next** login will re-create it — that's the safety-net behaviour the lifecycle hook contractually owns. The atomic four-write transaction ensures no partial state can leak through a half-deleted drive.
### Identifying the home — never by name
Code that needs to ask "is this the user's home?" must compare ids, not names. Users rename their home folder. Secondary personal drives keep their original sibling-root names. The single source of truth is **drive ownership**: the drive where `default_for_user = user_id` owns the user's home root folder via `root_folder_id`.
Two helpers in `src/domain/repositories/drive_repository.rs` encapsulate the lookup:
```rust
// "Give me this user's home root folder id (or None for external)."
drive_repo.home_root_folder_id_for(user_id).await
// → Result<Option<Uuid>, DriveRepositoryError>
// "Where in this list of items is the user's home?" — generic over
// the item shape; the caller passes an id-extractor closure.
position_of_user_home_root_folder(
drive_repo, user_id, &items,
|item| Uuid::parse_str(&item.id).ok(),
).await
// → Option<usize>
```
`home_root_folder_id_for` returns `Ok(None)` (not an error) for external users — they have no default drive. The position helper is a free function (not a trait method) so `DriveRepository` stays `dyn`-compatible. Use these everywhere; do not write new code that pattern-matches folder names like `"Personal"` or `"My Folder - <user>"`.
### State of the art:
@@ -177,7 +198,7 @@ If the home folder gets deleted manually (e.g., SQL `DELETE FROM storage.folders
DI builds:
UserLifecycleService
├── AuditLifecycleHook (in user_lifecycle_service.rs)
├── HomeFolderLifecycleHook (in folder_service.rs)
├── PersonalDriveLifecycleHook (in folder_service.rs)
├── AuthzCacheLifecycleHook (in pg_acl_engine.rs)
├── SessionRevocationLifecycleHook (in user_lifecycle_service.rs)
└── ExternalIdentityLifecycleHook (in external_identity_service.rs, stubbed)
+650 -178
View File
@@ -176,17 +176,35 @@ layer (`DriveService` enforces the personal-drive invariants, etc.)
- A shared drive can have **0 viewers** and **0 editors** — only
the ≥1-owner invariant matters.
### 3. Drive entity
### 3. Drive entity — pure metadata + a 1:1 root folder
A drive is a **metadata-only holder** (quota, kind, policies, default flag)
paired 1:1 with a *root folder* that owns the drive's visible identity
(name, path materialisation, ltree anchor). The drive itself has no
`name` column — every property the user thinks of as "the drive"
(its display name, its containing children, its location in the
ltree) lives on the root folder row.
This is the Unix-philosophy split: the *filesystem volume* is the drive
(quota, policies, ownership metadata); the *mount point* is the root
folder (name, hierarchy, paths). Clients interact with the root folder
through the standard folder API — no special "drive root" endpoint, no
polymorphic creation surface, no "create at drive vs in folder" duality.
```sql
storage.drives
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
name text NOT NULL -- "Personal" or user-chosen
kind text NOT NULL CHECK (kind IN ('personal','shared'))
default_for_user uuid NULL FK → auth.users(id) ON DELETE CASCADE
quota_bytes bigint NULL -- NULL = unlimited
used_bytes bigint NOT NULL DEFAULT 0
policies jsonb NOT NULL DEFAULT '{}'
-- The drive's mount-point folder. Nullable AT THE COLUMN TYPE LEVEL
-- only because the column is set mid-statement during atomic
-- creation (see "Atomic creation" below) — invariant: after any
-- successful create_personal_drive() call, this is non-NULL. Code
-- that reads drives can treat it as Uuid in Rust.
root_folder_id uuid NULL FK → storage.folders(id) ON DELETE CASCADE
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now()
-- `default_for_user` may ONLY be set on personal drives.
@@ -199,6 +217,35 @@ CREATE UNIQUE INDEX drives_default_for_user_idx
WHERE default_for_user IS NOT NULL; -- one DEFAULT personal drive per user
```
#### Drive name lives on the root folder
`storage.drives` has no `name` column. The drive's display name is
`SELECT f.name FROM storage.drives d JOIN storage.folders f
ON f.id = d.root_folder_id WHERE d.id = $drive_id`.
Why this is the right shape:
- **Single source of truth.** No duplication between `drives.name` and
`folders.name`, no drift risk, no decision about which side to
serve when they differ.
- **Renaming is the standard folder API.** `PATCH /api/folders/<root_id>`
with a new name renames the drive. No separate
`PATCH /api/drives/<id>/name` endpoint. The lifecycle / cascade
/ search-index behaviour that already exists on folder rename
applies automatically.
- **No "rename the drive but not its mount point" footgun.** They're
always in sync because they're the same column.
Identity is still carried by `kind` + `default_for_user` — *not* by
the name. UI and NC default-drive resolution query on `kind` /
`default_for_user`, never on `name = 'Personal'`. Renaming "Personal"
→ "Ed's space" preserves identity; only the label changes.
The migration-time default for personal drive root folder names is
`'Personal'`. Secondary personal drives (sibling roots from the M2
backfill — see §10) carry over whatever name the original sibling
root folder had.
#### Two orthogonal properties: `kind` and `default_for_user`
- **`kind`** = drive capability shape (see §2 for the rules):
@@ -229,24 +276,164 @@ There is no constraint to write — externals simply have no row
in `storage.drives` with `default_for_user = <their id>`, and
nothing tries to create one.
#### Drive naming — `name` is a label, identity lives in `kind` + `default_for_user`
#### Atomic creation — single transaction, four writes
`name` is owner-editable for every drive (personal or shared). A
user who renames their drive from "Personal" → "Ed's space" does
**not** stop having a personal drive, and does not stop having a
default. The `kind` flag + `default_for_user` pointer carry the
identity; the name is purely a display label.
A drive and its root folder reference each other circularly:
`storage.drives.root_folder_id` points at `storage.folders.id`, and
`storage.folders.drive_id` points at `storage.drives.id`. Creating
them naively could leave inconsistent half-state on a server crash
mid-sequence: drive without folder, folder without drive, or either
without an owner role_grant.
Why this matters:
- UI and NC default-drive resolution MUST query on `kind` /
`default_for_user`, never on `name = 'Personal'`. The latter
would silently break the moment the user renames.
- The initial migration sets `name = 'Personal'` on the default
personal drive for back-compat with the label users see today;
further renames go through the normal drive-rename endpoint
and persist on the same row. Secondary personal drives carry
whatever name the sibling root folder had (e.g. `Archive`,
`2024 Projects`).
The repo's `create_personal_drive_atomic` wraps the four writes in
a single transaction so they commit together or not at all:
1. INSERT drive (with `root_folder_id = NULL`) → returns drive id.
2. INSERT folder (with `drive_id` = the drive's id) → returns folder id.
3. UPDATE drive SET `root_folder_id` = the folder id.
4. INSERT role_grant (owner, subject = caller, resource = drive).
5. COMMIT.
Why a transaction rather than one CTE statement: PostgreSQL's CTE
sub-statements all read the target tables from the *same snapshot*
— a later sub-statement's `UPDATE storage.drives WHERE id = …`
cannot match a row inserted by an earlier sub-statement, even if
the earlier statement returned the new id via `RETURNING`. The
documented escape hatch (`DEFERRABLE INITIALLY DEFERRED` FKs +
pre-generated UUIDs) is the alternative but adds constraint
plumbing to support a single uncommon code path. A transaction is
boring and correct.
Crash safety: any failure between steps 1 and 4 rolls back — no
drive without folder, no folder without drive, no drive without
owner. Once step 5 commits, the invariant holds.
For reference, the equivalent (broken) one-CTE form looks like:
```sql
WITH new_drive AS (
INSERT INTO storage.drives
(kind, default_for_user, quota_bytes, policies)
VALUES ('personal', $user_id, $quota, '{}'::jsonb)
RETURNING id
),
new_root AS (
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
SELECT 'Personal', -- root folder name
NULL, -- parent_id (this IS the root)
$user_id,
new_drive.id, -- forward-ref to the drive's id
$user_id, $user_id
FROM new_drive
RETURNING id, drive_id
),
drive_updated AS (
UPDATE storage.drives d
SET root_folder_id = new_root.id
FROM new_root
WHERE d.id = new_root.drive_id
RETURNING d.id
),
new_grant AS (
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT 'user', $user_id, 'drive', du.id, 'owner', $user_id
FROM drive_updated du
RETURNING resource_id
)
SELECT d.id, d.root_folder_id, d.kind, d.default_for_user,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at
FROM storage.drives d
JOIN new_grant g ON g.resource_id = d.id;
```
Why the one-CTE form above does NOT work — and what we ship instead:
The shared-snapshot rule (`postgresql.org/docs/current/queries-with.html`
§7.8.2: "they cannot 'see' one another's effects on the target tables")
breaks the `drive_updated` sub-statement. Its `UPDATE storage.drives d
… WHERE d.id = new_root.drive_id` evaluates `WHERE d.id = …` against
the snapshot, which doesn't contain the drive inserted by `new_drive`.
The UPDATE matches zero rows; `RETURNING` returns zero rows;
`new_grant` (which feeds off `drive_updated`) inserts zero role_grants;
the final SELECT joins on an empty CTE branch and returns nothing.
Symptoms in tests: drives exist with `root_folder_id IS NULL`, owners
have no `role_grants` row, `/api/drives` returns `[]`.
The fix is the four-step transaction described above. Rust:
```rust
let mut tx = pool.begin().await?;
let drive_id: Uuid = sqlx::query_scalar(
r#"INSERT INTO storage.drives (kind, default_for_user, quota_bytes)
VALUES ('personal', $1, $2) RETURNING id"#,
).bind(owner).bind(quota).fetch_one(&mut *tx).await?;
let folder_id: Uuid = sqlx::query_scalar(
r#"INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, $1, $2, $1, $1) RETURNING id"#,
).bind(owner).bind(drive_id).fetch_one(&mut *tx).await?;
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
.bind(folder_id).bind(drive_id).execute(&mut *tx).await?;
sqlx::query(
r#"INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
VALUES ('user', $1, 'drive', $2, 'owner', $1)"#,
).bind(owner).bind(drive_id).execute(&mut *tx).await?;
tx.commit().await?;
```
Each statement sees the prior statements' writes (transaction-local
visibility, not the CTE shared snapshot). FK timing works without
`DEFERRABLE`: each FK is satisfied at the moment its row is written
because the referenced rows already exist.
#### DB-level invariant: no orphan root folder
`storage.drives.root_folder_id` is NULLable at the column level
(required by the four-write transaction above — the drive INSERT
can't reference a folder that doesn't exist yet). The "every drive
has a root folder" invariant is application-enforced by the atomic
creation transaction being the only mint path. But the symmetric
invariant — "every root folder belongs to a drive" — is *not* free
either: a `storage.folders` row with `parent_id IS NULL` whose
`drive_id` doesn't point at a drive whose `root_folder_id` equals
its `id` would be an orphan that the resolver can't reach (the
chroot can't land on it) but that still occupies the
`(parent_id IS NULL, name, drive_id)` unique slot.
D0 lands a DB-level guard for this case, as a defence-in-depth
layer behind the application invariant. Implementation: an
`AFTER INSERT OR UPDATE` constraint trigger on
`storage.folders` that fires when `NEW.parent_id IS NULL` and
refuses unless:
```sql
EXISTS (
SELECT 1 FROM storage.drives
WHERE id = NEW.drive_id
AND root_folder_id = NEW.id
)
```
Declared `DEFERRABLE INITIALLY DEFERRED` so the four-write
transaction's order (folder INSERTed at step 2, drive's
`root_folder_id` UPDATEd at step 3) doesn't trip the trigger
mid-transaction — the check fires at COMMIT, by which point both
sides of the cycle are wired.
Test coverage: a Hurl/integration test that hand-rolls
`INSERT INTO storage.folders (… parent_id=NULL, drive_id=<existing>)`
*without* the matching drives.root_folder_id update — asserts the
trigger raises and the row is rejected. The atomic transaction
remains the only legitimate creation path; arbitrary SQL writes
that try to bypass it now hit a DB-level wall.
#### Capabilities matrix
@@ -258,11 +445,12 @@ Why this matters:
| Rename | allowed (by the owner) | allowed (by the owner) | allowed (by any owner) |
| Delete via API | **refused** — deleting this loses all the user's files; the only path is user-delete cascade | allowed (it's just a silo) | allowed (by an owner; CASCADEs the drive's contents) |
| Default-drive lookup result | this drive | never | never |
| On user-delete | `ON DELETE CASCADE` via `default_for_user` FK (free) | application-layer cleanup: enumerate via drive_members and delete | member rows referencing the user are dropped; refuse user-delete if any shared drive would lose its last owner |
| On user-delete | `ON DELETE CASCADE` via `default_for_user` FK (free) | application-layer cleanup: enumerate via `role_grants` (`subject_id=<user> AND resource_type='drive' AND role='owner'`) and delete | role_grants rows referencing the user are dropped; refuse user-delete if any shared drive would lose its last owner |
| Group ownership | no | no | yes |
| Per-resource grant outward | yes (subject to drive policies) | yes | yes |
| Cross-drive move | yes (subject to `forbid_cross_drive_move`) | yes | yes |
| Kind conversion | no — always default-personal | yes → may be promoted to `kind='shared'` later (drops the single-user restriction, picks up members) | no |
| Change `quota_bytes` | **OxiCloud admin only** (not the drive owner — §7) | **OxiCloud admin only** | **OxiCloud admin only** |
### 4. Roles → permission bundles
@@ -273,7 +461,7 @@ expansion:
|---|---|
| `viewer` | `Read` |
| `editor` | `Read`, `Create`, `Update`, `Comment` |
| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, edit policies, manage members, change quota) |
| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, edit policies, manage members) |
### 5. Permission resolution — additive over `role_grants`
@@ -305,16 +493,16 @@ against the same table.
| Event | Behaviour |
|---|---|
| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `name='Personal'`, `default_for_user=<new_user>`, `quota_bytes=<OXICLOUD_DEFAULT_QUOTA_BYTES>`), insert the single `drive_members (drive_id, user, <user_id>, owner)` row. |
| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `default_for_user=<new_user>`, `quota_bytes=<OXICLOUD_DEFAULT_QUOTA_BYTES>`) + its root folder (`name='Personal'`, `parent_id=NULL`, drive_id pinned) + the Owner role_grant (`role_grants(subject_type='user', subject_id=<user>, resource_type='drive', resource_id=<drive>, role='owner')`) — **all four writes in one CTE statement** (§3), atomic against server crash. |
| External user invited (magic-link only) | **No personal drive created.** External users are grant-only recipients with no storage. |
| External user converts to internal (future flow) | Default personal drive created at conversion time. |
| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole `drive_members` row points at the user) are deleted by an application-layer pass in the same transaction. Member rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. |
| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole owner `role_grants` row points at the user) are deleted by an application-layer pass in the same transaction. `role_grants` rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. |
| Group deleted | Refuse if the group is a member of any shared drive that would lose its last owner. Admin must transfer or remove the group's role from those drives first. (Groups can't be members of personal drives.) |
| Add member to personal drive | Refuse. Personal drives are single-user — collaborate via per-resource grants or by moving content into a shared drive. |
| Remove sole owner of personal drive | Refuse. The only deletion path for a personal drive is user-deletion via cascade. |
| Delete personal drive | Refuse from the API. Only ON DELETE CASCADE (user deletion) drops it. |
| Rename personal or shared drive | Allowed for any owner-role caller. `name` is a label only. |
| Remove last owner of shared drive | Refuse — drive must always have ≥1 owner. App-layer check on `DELETE FROM drive_members`. |
| Rename personal or shared drive | Allowed for any owner-role caller. The drive's display name lives on its root folder (§3) — rename via `PATCH /api/folders/<root_folder_id>`, not a drive-specific endpoint. |
| Remove last owner of shared drive | Refuse — drive must always have ≥1 owner. App-layer check on `DELETE FROM role_grants WHERE resource_type='drive' AND resource_id=$drive AND role='owner'`. |
### 7. Quota model
@@ -336,6 +524,38 @@ After the cutover:
(plus a periodic reconciliation job to fix drift, similar to the
existing per-user accounting).
#### Quota mutation is OxiCloud-admin only
Changing `drives.quota_bytes` is **not** in the drive `owner` role
bundle (§4). It requires the tenant-level OxiCloud admin role
(`auth.users.role = 'admin'`), checked at
`PATCH /api/admin/drives/{id}/quota` — the only callsite that
mutates the column. Drive owners can rename, edit policies, and
manage members; they cannot self-grant capacity.
Why this seam matters:
- **Resource allocation is a tenant concern, not a drive
concern.** Storage bytes are a finite system resource the
operator pays for. The drive owner is empowered over the
drive's *use*; the admin is empowered over its *budget*. Same
separation that exists today between a user and the operator
who set `OXICLOUD_DEFAULT_QUOTA_BYTES`.
- **Privilege-escalation seam closed.** Without this carve-out,
any user with a personal drive (= every internal user) could
raise their own quota by virtue of being its sole owner —
trivially defeating the quota system.
- **Shared-drive coherence.** A shared drive's quota is set by
the operator at provisioning; subsequent capacity requests go
through the admin, not the drive's group owners. Keeps the
capacity decision auditable and out of intra-team politics.
The admin endpoint is the same surface the operator uses today to
change `auth.users.storage_quota_bytes`; D4 simply re-targets the
write at `storage.drives.quota_bytes`. Audit log emits
`drive.quota_changed` with `granted_by=<admin_user_id>` and the
old/new values, mirroring the existing user-quota change event.
**Chunk dedup vs per-drive quota.** With the CDC chunk store landed
in v0.7.0 (see `delta_upload_service`, `upload_ingest`, instant
upload by hash), a single chunk can be referenced by files in
@@ -479,34 +699,57 @@ discriminator is the literal segment (`files` vs `drives`), never
the value of `<x>`. A user happening to have a UUID-shaped username
is no longer a problem.
### 10. Storage paths — wrapper folder retired
### 10. Storage paths — wrapper folder becomes the drive's root folder
Today `storage.folders.path` is e.g. `My Folder - admin/Docs`. The
"My Folder - admin" wrapper is the user's home folder, created at
registration via `format!("My Folder - {}", username)`.
Post-drives, **the wrapper goes away**. The drive itself is the
root; folders and files that used to live inside the wrapper sit
directly under the drive with no intermediate folder:
Post-drives, **the wrapper isn't deleted — it's *adopted* as the
drive's root folder** (§3). The drive row is created alongside it
and points at it via `drives.root_folder_id`. The wrapper's row
survives the migration; only its `name` is updated.
```
Drive "Personal" (uuid=…, kind=personal, owner=admin) ← was the "My Folder - admin" wrapper
├── Docs/
└── aa.pdf
Drive (uuid=…, kind=personal, default_for_user=admin)
└── root folder (parent_id=NULL, drive_id=<drive_uuid>, name="Personal") ← was "My Folder - admin"
├── Docs/
└── aa.pdf
```
Same for shared drives — they already had no wrapper:
Shared drives follow the same shape — drive + root folder + content
underneath:
```
Drive "Engineering" (uuid=…, kind=shared, owners=group:engineering)
├── Specs/
├── Roadmap.md
└── archive/
Drive (uuid=…, kind=shared, owners=group:engineering)
└── root folder (parent_id=NULL, drive_id=<drive_uuid>, name="Engineering")
├── Specs/
├── Roadmap.md
└── archive/
```
The two surfaces share one rule: **drive root = `parent_id IS NULL`
within the drive's `drive_id`**. The "personal vs shared" branch
disappears from path resolution — both kinds resolve the same way.
One rule, one model: **every drive has exactly one folder where
`parent_id IS NULL` AND `drive_id = <the drive>`**.
#### Why this is a better model
Three things converge:
1. **No API duality.** Folder creation is always `POST /api/folders
{ name, parent_id: <id> }`. There's no polymorphic "create at
drive vs in folder" branch — the drive's root folder is just
another folder id from the client's perspective. The `parent_id`
field that exists today carries over unchanged.
2. **No path-prefix rewrite migration.** The wrapper row stays; it's
renamed to its drive's canonical name (`"Personal"` for the
default, the original sibling-root name for secondaries). The
BEFORE-UPDATE path trigger fires on the rename and the cascade
trigger automatically rewrites every descendant's `path` /
`lpath` — no per-row UPDATE in the migration. The net cost is
one UPDATE per drive plus the trigger's cascade.
3. **No "this user lost their drive" failure mode.** Migration is
safe even mid-flight — the wrapper row never disappears, just
gains a drive_id pointer above it.
#### Why this is client-safe
@@ -519,22 +762,48 @@ The wrapper was already invisible to WebDAV / NC clients pre-drive:
- Native `/webdav/<path>` was implicitly chrooted to the user's
home by `resolve_webdav_path`. Same story.
So URL-level back-compat is preserved trivially — clients keep
asking for `/remote.php/dav/files/admin/Docs/foo.pdf`, the
resolver no longer prepends the wrapper, and the storage row's
path is now `Docs/foo.pdf` instead of `My Folder - admin/Docs/foo.pdf`.
Net effect on the wire: zero.
Post-migration the resolver chroot becomes `<drive_root_folder.path>/`
instead of `My Folder - <user>/`. The drive's root folder name
(e.g. `Personal`) replaces the wrapper name in the materialised
`path` column; the client's URL still doesn't carry it
because the resolver still chroots before talking to storage. Net
effect on the wire: zero.
#### Why this is a better model
#### Uniqueness constraints become drive-scoped
The original plan kept the wrapper "for back-compat" but it has
no value beyond the storage layer (clients never see it, the
filesystem mirror is happy either way). Keeping it forced path
resolution to always know whether the caller is in a personal or
shared drive and conditionally prepend a segment. Dropping it
collapses that branch and makes the personal-vs-shared distinction
purely a metadata concern (kind, quota source, member shape) —
**not** a path-shape concern.
Pre-drive, two indexes enforce "no duplicate folder names under the
same parent for the same user":
```sql
CREATE UNIQUE INDEX idx_folders_unique_name
ON storage.folders(parent_id, name, user_id)
WHERE NOT is_trashed AND parent_id IS NOT NULL;
CREATE UNIQUE INDEX idx_folders_unique_name_root
ON storage.folders(name, user_id)
WHERE NOT is_trashed AND parent_id IS NULL;
```
Both move from `user_id`-scoped to `drive_id`-scoped:
```sql
CREATE UNIQUE INDEX idx_folders_unique_name
ON storage.folders(parent_id, name, drive_id)
WHERE NOT is_trashed AND parent_id IS NOT NULL;
CREATE UNIQUE INDEX idx_folders_unique_name_root
ON storage.folders(name, drive_id)
WHERE NOT is_trashed AND parent_id IS NULL;
```
This is a **correctness improvement**, not just a migration concession.
The semantic users expect is "no duplicate names *within a drive*"
— a folder named "Reports" in your Personal drive shouldn't preclude
another "Reports" in a shared "Team" drive. The user-scoped
constraint forbade that. The drive-scoped constraint allows it.
For the root variant: post-migration each drive has exactly one
`parent_id IS NULL` row (its root folder), so `(name, drive_id)` is
trivially unique. The index is still worth keeping as
defence-in-depth.
#### Sibling root folders also become drives
@@ -551,23 +820,26 @@ Post-migration, the model has to absorb them. Rule:
- For each user, find every row with `parent_id IS NULL AND user_id
= <this user>`.
- The one named `My Folder - <username>` becomes the **default
personal drive**: `kind='personal'`,
`default_for_user=<user>`, sole member = the user.
personal drive's root folder**: a new drive row is created with
`kind='personal'`, `default_for_user=<user>`, the existing folder
row gets a `drive_id` pointer (and the wrapper's name is updated
to `"Personal"`), and `drives.root_folder_id` points back at it.
Sole `role_grants` row: Owner, subject=`<user>`.
- Every **other** sibling becomes a fresh **secondary personal
drive**: `kind='personal'`, `default_for_user=NULL`, sole
member = the user, name carried over from the folder's `name`
column, quota initialised from the user's quota
(`auth.users.storage_quota_bytes`) — same default as the
user's primary personal drive. Membership rules from §2 apply:
the user cannot invite co-owners while the drive remains
personal. To open the silo up, the user can later **convert**
the secondary personal to `kind='shared'` (an
application-layer operation that flips the kind and lifts the
single-user restriction so the membership API can add other
users / groups).
- The folder row is deleted (the drive replaces it as the root).
Its children get their `parent_id` set to `NULL` *within their
new `drive_id`*.
drive's root folder**: a new drive row with `kind='personal'`,
`default_for_user=NULL`, the existing folder row gets its
`drive_id` set and *keeps its name* (`Archive`, `2024 Projects`,
whatever), quota initialised from the user's quota
(`auth.users.storage_quota_bytes`) — same default as the user's
primary personal drive. Membership rules from §2 apply: the user
cannot invite co-owners while the drive remains personal. To open
the silo up, the user can later **convert** the secondary
personal to `kind='shared'` (an application-layer operation that
flips the kind and lifts the single-user restriction so the
membership API can add other users / groups).
- **No folder row is deleted.** The wrapper rows survive the
migration as drive root folders; only their `drive_id` is set and
(for the default-Personal case) their `name` is updated.
The chroot POC's "pick a drive at login" picker on
`feat/nextcloud-drive` already produces the right shape for this:
@@ -575,7 +847,7 @@ users with one drive auto-select Personal silently, users with
N drives get a real picker. No POC change needed — it just sees
real drive rows instead of folder UUIDs.
### 11. Content search index — drive-aware filtering
### 11. Content search index — cross-drive with anti-enum filtering
v0.7.0 added an embedded Tantivy full-text content index (see
`infrastructure/services/search_index/tantivy_content_index.rs`
@@ -583,24 +855,98 @@ and migration `20260701000000_content_search_index.sql`). Today
every indexed document carries the owning user as a filter field
and queries restrict by that field at query time.
When ownership pivots to `drive_id`, the index has to follow —
otherwise search leaks content across drives the moment D7 drops
`user_id`:
The pivot to drives keeps **one global endpoint, `/api/search`,
that aggregates across every drive the caller can read**. There
is no per-drive search route in D0; the URL/picker UI in D1 may
add an optional `?drive_id=<uuid>` narrowing parameter, but the
default surface stays cross-drive.
The security primitive that makes cross-drive search safe is the
**`Occur::Must` clause applied at query time**, not after.
Tantivy's collector only sees documents that satisfy the Must
clause — stored fields (`preview`), counts, and pagination
cursors all reflect the filtered set. This is the same shape the
existing `user_id` filter uses today; we keep it and swap the
field.
1. **Schema update**: every indexed document gains a `drive_id`
field stamped at ingest time. Existing documents need a
one-shot reindex pass during the D0 migration (read each row,
look up its new `drive_id`, update the index entry). Cheap on
small instances; the migration script should report a progress
count for larger ones.
2. **Query path**: instead of filtering by `user_id = caller`,
expand `caller → set of drive_ids the caller can read`
(personal + every shared-drive membership) and filter by
`drive_id ∈ that set`. Expansion reuses the drive-role check
already required by `PgAclEngine`.
3. **Treat as a blocking step of D0**, not a D4/D5-era polish
item — otherwise the index is the silent leak path during the
dual-write window.
STRING field stamped at ingest time. The existing `user_id`
field is kept during the dual-write window for rollback
safety and dropped in D7.
2. **Query path**: replace the `Must user_id = caller` clause
with a `Must drive_id ∈ accessible_drives` set-membership
clause. `accessible_drives` is computed fresh per query
(personal + every shared-drive membership), reusing the
`expand_user` cache that `PgAclEngine` already maintains and
`AuthzCacheLifecycleHook` already invalidates on membership
change.
3. **Handler-side ReBAC re-verification** (defense in depth):
after Tantivy returns hits, the `/api/search` handler
re-checks each `file_id` with the engine. The re-check is
*subtractive only* — it can drop a Tantivy hit, never add
one. It catches:
- **Index staleness** — file just moved to a drive the caller
can't access; the indexer hasn't caught up so Tantivy still
returns the doc under its old drive_id (a false positive
from the caller's perspective). The re-check denies and the
hit drops.
#### Known D0 scope limit — per-resource grants invisible in search
The drive-only Must clause makes the inverse case structurally
invisible: a file or folder shared *directly* with the caller
via ReBAC, living in a drive the caller has no membership in,
never reaches the re-check because Tantivy already filtered the
doc out at index-query time. Concretely:
- Alice grants Bob a per-file Read on `report.pdf` inside her
Personal drive. Bob has no drive grant on Alice's Personal.
- Bob's `accessible_drives` set doesn't include Alice's drive.
- Tantivy's `Must drive_id ∈ accessible_drives` rejects every
doc with Alice's drive_id, including `report.pdf`.
- Bob's search misses `report.pdf` even though `authz.check(Bob,
Read, File(report))` would say yes.
This is **not a security issue** — Bob still can't access
anything he isn't entitled to. The trade-off is *discovery
only*: search doesn't surface directly-shared resources. The
"Shared with me" UI is the canonical surface for that workflow
(resources someone explicitly shared with you live in the
notification / inbox / share-listing flow, not in global search).
Closing this gap is deferred. Two future moves, in order of cost:
- **Per-file grants** (low cost): one extra `role_grants` query
for `resource_type='file' AND subject ∈ caller_set`, widen
the Must clause to `drive_id ∈ A OR file_id ∈ F`. ~1 day of
work; deliverable when the "Shared with me" search surface is
prioritised.
- **Per-folder grants with cascade** (high cost): the ltree
subtree expansion is what makes it gnarly — a grant on folder
`F` should surface every descendant in search. Two viable
shapes: (a) reindex with a multi-valued `ancestor_folder_ids`
STRING field on each doc (schema v3, full reindex), or (b)
expand each granted folder to its subtree at query time
(per-grant recursive SQL on the hot path). Probably a dedicated
PR alongside the D1 URL-routing work.
4. **Token subjects cannot search**. `/api/search` returns 401
for token-authenticated callers (anonymous link tokens have
access to one resource, not a drive — there is no meaningful
"search my drives" surface for them). The 401 is consistent;
"empty results" would leak nothing but would be operationally
confusing.
5. **Anti-enumeration response shape**: no "you have N hidden
matches" anywhere. The count, the cursor, and the snippet
list all reflect the filtered set and nothing else.
6. **Treat the reindex as a blocking step of D0**, not a D4/D5-
era polish item — otherwise the index is the silent leak
path during the dual-write window. The reindex re-runs the
existing `content_index_worker::drain_once()` loop after
`TantivyContentIndex::open_or_rebuild()` detects the schema
version bump; no separate CLI command needed (per the audit).
Hurl coverage in D0 includes a concrete anti-enumeration test:
user A indexes a file in a drive user B can't see, user B searches
the indexed term, response is empty + no hidden-count leak.
Filtering on a low-cardinality `drive_id` is something Tantivy
handles natively; this is bookkeeping, not a query-plan risk.
@@ -809,6 +1155,72 @@ ancestry but **does NOT** propagate `updated_by` — ETags are
fingerprints of structure, not authorship. Only the direct
mutation site updates `updated_by`.
### 15. Global sections — scope mapping
"Global sections" are the user-facing views that aggregate across
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.
| 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. |
| **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. |
| **Search** (`/api/search`) | Cross-drive (all accessible drives) | n/a | Discovery tool. See §11 for the Must-clause filter + handler-side ReBAC re-verification + anti-enum response shape. |
| **Trash** (`/api/trash/resources`) | Per-drive (owner-actioned) | n/a | Already specified in §12 — trash listing filters by drive(s) the caller can read; mutations require the owner role on the drive. |
#### Capability flag mechanism
Both `policies.include_in_photo_index` and
`policies.forbid_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).
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.
#### The Photos/Music asymmetry — defensible, not a smell
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.
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.
#### Verification sketch
The D0 Hurl suite (`tests/api/drives_foundation.hurl`) covers
the scope decisions concretely:
- Photos: file uploaded in Personal appears in `/api/photos`; same
file uploaded into a secondary personal drive does NOT appear
unless `include_in_photo_index` is set on that drive.
- Music: track uploaded in any accessible drive appears in the
library / sweeper output; setting `forbid_music_index` on a
drive removes its tracks from the next library response.
- Favorites: star a file in drive A and a file in drive B (both
accessible to caller); list returns both. Lose access to drive
B → next list omits the B file (no error, just absent).
- Search: see §11 anti-enum test.
## Migration strategy
A drive-id column on every resource is a database surgery touching
@@ -816,79 +1228,114 @@ every storage query. We phase it for safety:
### Phase A — additive (PR D0)
1. Create `storage.drives` and `storage.drive_members`.
1. Create `storage.drives` (no `name` column — see §3; has
`root_folder_id uuid NULL` populated in step 3). **No
`storage.drive_members` table** — membership lives in
`storage.role_grants` (created in D-Prep) as
`resource_type='drive'` rows.
2. Add `drive_id uuid NULL` to `storage.folders` and `storage.files`.
3. **Per-user root-folder sweep**: for each internal user, list
every `storage.folders` row where `parent_id IS NULL AND
3. **Per-user root-folder adoption sweep**: for each internal user,
list every `storage.folders` row where `parent_id IS NULL AND
user_id = <this user>`. Exactly one is expected to be
`My Folder - <username>`; any extras are SQL-created siblings
(see §10).
(see §10). Each row is **adopted in place** as a drive's root
folder — no row is deleted, no descendant `parent_id` changes,
no path-prefix strip across the whole tree.
- The `My Folder - <username>` row → becomes the **default
personal drive**: `INSERT INTO storage.drives (name='Personal',
kind='personal', default_for_user=<user>, quota_bytes=<user.storage_quota_bytes>)`
and insert one `(drive_id, user=<user>, role='owner')`
member row.
personal drive's root folder**. In one CTE statement per
user (same shape as §3's `create_personal_drive`):
- INSERT into `storage.drives` with `kind='personal'`,
`default_for_user=<user>`, `quota_bytes=<user.storage_quota_bytes>`
(no `name` column).
- UPDATE the wrapper folder row: set `drive_id=<new drive>`
and `name='Personal'` (renames the wrapper to the canonical
default name; the BEFORE-UPDATE `path` trigger fires and
cascades the new name down every descendant via the
existing AFTER-UPDATE cascade trigger — no per-row UPDATE
in the migration script).
- UPDATE `storage.drives` to set `root_folder_id=<wrapper row id>`.
- INSERT one `role_grants` row: subject=`<user>`,
`resource_type='drive'`, `resource_id=<new drive>`,
`role='owner'`.
- Every other sibling row → becomes a fresh **secondary
personal drive**: `kind='personal'`, `default_for_user=NULL`,
name carried over from the folder's `name`,
`quota_bytes=<user.storage_quota_bytes>`, and one
`(drive_id, user=<user>, role='owner')` member row.
Membership rules from §2 apply (single-owner, no `add_member`);
the user can later promote one to `kind='shared'` to invite
collaborators.
4. **Promote children, drop the wrapper**: for every folder/file
row that has `parent_id = <a root row from step 3>`, set
`drive_id = <that root's new drive id>` and `parent_id = NULL`
(the drive itself is the new root, not a folder). Then DELETE
the root folder rows from step 3 — they no longer exist as
folders, the drive replaces them.
5. **Cascade `drive_id` down the tree** — for every remaining
folder/file row, set `drive_id` by walking the ancestry to
whichever root the row descends from. After this step every
row has the same `drive_id` as its `parent_id`'s row, which
chains up to a drive set in step 3/4.
6. **Full path-metadata reconstruction**. The `path` column on
**every** row in `storage.folders` and `storage.files` gets
rewritten. For rows that descended from `My Folder - <username>`,
strip that prefix; for rows that descended from a sibling root,
strip that sibling's `name`. The path column now contains only
the in-drive path (e.g. `Docs/foo.pdf`, never
`My Folder - admin/Docs/foo.pdf`).
- **This is the bulk of D0's runtime cost.** Personal-drive
scope = every folder/file the user owns. A 100k-file user
gets 100k UPDATEs. Use a single `UPDATE … WHERE drive_id =
<id>` per drive, not a row-at-a-time loop. The ltree-path
change is what every downstream subsystem keys off, so doing
this in one transaction per drive (not per row) is also a
correctness boundary.
- **Downstream caches and indexes** — audit each for path or
path-derived keys:
personal drive's root folder**, same four-write CTE shape
except: `default_for_user=NULL`, no `name` change (the
sibling keeps its original name), and the Owner grant points
at the same user. Membership rules from §2 apply
(single-owner, no `add_member`); the user can later promote
one to `kind='shared'` to invite collaborators.
4. **Cascade `drive_id` down the tree** — for every folder/file
row, set `drive_id` by walking the ancestry up to whichever
adopted root the row descends from. After this step every row
has the same `drive_id` as its `parent_id`'s row, which chains
up to a root folder whose `drive_id` was set in step 3.
Reuse the existing ltree-aware recursive helper
(`storage.copy_folder_tree`-style descent) — single
`UPDATE … WHERE` per drive, not a row-at-a-time loop.
5. **No bulk path rewrite.** The `path` column on descendants is
untouched by this migration. The wrapper rename in step 3
(`My Folder - admin` → `Personal`) is the only path-affecting
change; the BEFORE-UPDATE folder trigger rewrites the wrapper
row's own `path` / `lpath`, and the AFTER-UPDATE cascade
trigger propagates the new path prefix to every descendant
automatically.
- **Downstream caches and indexes** — most are unaffected
because path *content* changes only inside the renamed
wrapper segment (descendants reflect "Personal/…" instead of
"My Folder - admin/…"). Audit:
- **Tantivy content index (§11)** — the index does NOT
store paths (see `tantivy_content_index.rs`: indexed
fields are `file_id`, `user_id`, `name` (basename only),
`content`. No `path` field, the wrapper folder name was
never a term). So the wrapper removal alone requires no
reindex. The reindex §11 calls for is driven by the
schema gaining `drive_id` and the query filter pivoting
from `user_id` to `drive_id` — NOT by the path rewrite.
Same migration window, but for a different reason.
- **Thumbnail cache** — if keyed by path rather than
file_id, invalidate; preferably switch to file_id-keyed
during this migration so the issue doesn't recur. Audit
before D0 starts.
`content`. No `path` field). Reindex IS still required —
not because of paths but because the schema gains
`drive_id` and the query filter pivots from `user_id` to
`drive_id`. Same migration window, different reason.
- **Thumbnail cache** — file_id-keyed: unaffected by the
wrapper rename. Path-keyed entries (if any) invalidate on
any path change in the wrapper; flush as a precaution and
switch to file_id keying during this migration if not
already done.
- **Folder ETag queue (`async_tree_etag_queue`,** see Open
Question 8) — flush or recompute; ETags derived from old
paths are stale.
Question 8) — recompute. The wrapper rename touches the
wrapper's own ETag at minimum; ancestors-of-ancestors
below the wrapper are structurally unchanged.
- **Recent-items / favorites** — referenced by file_id, not
path; probably fine. Verify.
path; unaffected.
- **On-disk storage mirror** — see Open Question 10. If the
filesystem layout is path-mirrored, every file moves on
disk too; if content-addressable, the FS is untouched.
Audit before D0 starts.
7. Verify: every row has `drive_id IS NOT NULL`, no row has
`parent_id` pointing at a non-existent folder, no `path` value
contains the legacy `My Folder - ` prefix.
8. Add `NOT NULL` constraint on `drive_id`.
filesystem layout mirrors `path`, the wrapper directory
itself is renamed (one `mv`) and the descendant directories
don't move; the rename is atomic on the filesystem. If
content-addressable, the FS is untouched.
6. Verify: every row has `drive_id IS NOT NULL`; every drive has
`root_folder_id IS NOT NULL` and pointing at a real folder row
whose `parent_id IS NULL` and whose `drive_id` matches the
drive's id (the 1:1 invariant from §3); no row has `parent_id`
pointing at a non-existent folder.
7. Add `NOT NULL` constraints: `drive_id` on `storage.folders`
and `storage.files`. `root_folder_id` on `storage.drives`
stays NULLable at the column level (§3 explains why — the
atomic CTE writes NULL on the drive INSERT and populates the
column with an UPDATE later in the same statement; a column-
level `NOT NULL` would refuse the initial INSERT). The
invariant "every drive has a root folder" is enforced by the
CTE being the only creation path, not by a constraint.
Verification step 6 checks the invariant on the populated
dataset; ongoing enforcement is application-layer.
8. Land the **no-orphan-root-folder constraint trigger** (§3,
"DB-level invariant"): `AFTER INSERT OR UPDATE` on
`storage.folders`, fires when `NEW.parent_id IS NULL`, refuses
unless the matching drive has its `root_folder_id` pointing at
the row. `DEFERRABLE INITIALLY DEFERRED` so the atomic
four-write transaction commits cleanly. Includes a pre-flight
`DO`-block that refuses the migration if any existing orphan
root folder is present (catches historical bad data at migration
time). **Direct test coverage is deferred to D3** — writing the
positive-path test today would require reimplementing the
atomic create flow in bash, which Ed rejected as duplication.
D0 ships with transitive coverage via `drives_foundation.hurl`
(the lifecycle hook exercises the trigger end-to-end through
the production path); the dedicated test rides alongside D3's
create-shared-drive API.
**Keep `user_id`** on resources alongside `drive_id` for the entire
Phase A release cycle. Code is updated to read `drive_id` everywhere;
@@ -1027,18 +1474,26 @@ place on `storage.files` / `storage.folders`, which already carry
not.
10. **On-disk storage mirror — does the file path under
`OXICLOUD_STORAGE_PATH` change too?** Phase A step 6 strips
the `My Folder - <username>/` prefix from `storage.folders.path`
/ `storage.files.path` columns. If the on-disk layout mirrors
these paths (`<storage>/<user_id>/My Folder - admin/Docs/foo.pdf`),
the migration also has to `mv` every file on disk. If on-disk
is content-addressable (BLAKE3-keyed), the columns can be
rewritten without touching the filesystem. **Audit the
storage adapter before starting D0** and decide whether the
migration script:
- just renames the path columns (CAS layout — cheap), or
- renames the path columns AND issues a `mv` per file
(path-mirrored layout — expensive on big instances).
`OXICLOUD_STORAGE_PATH` change too?** Phase A step 3 renames
the wrapper folder row (`My Folder - admin` → `Personal`) for
each default personal drive; the AFTER-UPDATE trigger
rewrites descendant `storage.folders.path` /
`storage.files.path` values automatically (no bulk UPDATE in
the migration script). If the on-disk layout mirrors these
paths (`<storage>/<user_id>/My Folder - admin/Docs/foo.pdf`),
the migration ALSO has to rename the wrapper directory on
disk — **but only the wrapper directory itself**, one `mv`
per drive, atomic on the filesystem; no descendant `mv`
needed. If on-disk is content-addressable (BLAKE3-keyed),
the columns can be rewritten without touching the filesystem
at all. **Audit the storage adapter before starting D0** and
decide whether the migration script:
- just lets the trigger rewrite the path columns (CAS layout
— cheap), or
- rewrites the path columns AND issues a single `mv` per
drive on disk (path-mirrored layout — still cheap; only
the wrapper directory moves, the subtree comes along for
free).
The blob store is content-addressable as of v0.7.0 so most
file content lives under `.blobs/<hash[..2]>/<hash>` and is
already wrapper-agnostic; the concern is only the
@@ -1084,11 +1539,13 @@ place on `storage.files` / `storage.folders`, which already carry
check uses this to resolve group-owner subjects.
- **`folder_service::create_home_folder`** at
`src/application/services/folder_service.rs:644` is where the
per-user wrapper folder is created today. Post-migration this
function **goes away** — there is no wrapper folder anymore. The
user-create lifecycle hook now creates a Drive row directly and
inserts the owner-role member row. The lifecycle path is the same;
the work it does shrinks.
per-user wrapper folder is created today. Post-migration the
function is **replaced** by a single `create_personal_drive`
call against `DriveRepository` that runs the §3 atomic CTE:
drive + root folder (named "Personal", `parent_id=NULL`,
`drive_id` pinned) + Owner `role_grants` row, all in one SQL
statement. The lifecycle path is the same; the work moves to
the drive repository.
- **NC path resolver `nc_to_internal_path`** at
`src/interfaces/nextcloud/webdav_handler.rs:51` and the native
resolver `resolve_webdav_path` at
@@ -1096,11 +1553,12 @@ place on `storage.files` / `storage.folders`, which already carry
callsites that learn about drives. Both gain a "drive context"
parameter resolved from the URL prefix (`/files/<u>/` or
`{user}~{uuid}` for NC; `/webdav/` or `/webdav/drives/<uuid>/`
for native). **Neither resolver prepends `My Folder - <user>/`
anymore** — the storage path IS the in-drive path. Both
functions also get simpler, not more complex, despite gaining
the drive parameter (the personal-vs-shared branch is now a
metadata lookup, not a path-shape decision).
for native). Each resolves to the drive's root folder via
`drives.root_folder_id` and prepends that folder's `path`
(after the migration this is `Personal/…` for default personal
drives, the original sibling-root name for secondaries, the
shared-drive root name for shared drives). The personal-vs-shared
branch is now a single metadata lookup, not a path-shape decision.
- **`MagicLinkInviteService`** and the share-notification pipeline
(`RecipientNotificationService`) need the new policy checks
(`forbid_external_sharing`, `forbid_sharing`) wired in at their
@@ -1156,12 +1614,19 @@ test`), **(c)** `cargo fmt && cargo clippy --all-features
cleanly with the expected `DomainError`.
- **Migration round-trip**: roll forward against a populated DB →
every existing folder/file row has `drive_id` set (no NULLs);
every user has exactly one drive with `default_for_user` set;
sibling root folders became secondary `kind='personal'` drives;
every `storage.folders.path` and `storage.files.path` value has
the `My Folder - <username>/` prefix stripped → roll back via
`sqlx migrate revert` → `drive_id` column gone, `user_id` intact
thanks to dual-write, original paths recovered.
every drive has `root_folder_id IS NOT NULL` and the row it
points at has `parent_id IS NULL AND drive_id = <self>` (the
1:1 invariant from §3); every user has exactly one drive with
`default_for_user` set; sibling root folders became secondary
`kind='personal'` drives whose root folders kept their original
names; default-personal wrapper folders were renamed from
`My Folder - <username>` to `Personal` and the AFTER-UPDATE
trigger cascaded the rename down the descendant `path` values
→ roll back via `sqlx migrate revert` → `drive_id` /
`root_folder_id` columns gone, `user_id` intact thanks to
dual-write, wrapper folder names restored to
`My Folder - <username>` (and the trigger cascade restores
descendant paths).
- **Storage check**: post-migration `bash tests/api/storage_cleanup_check.sh`
still reports a clean tree (no orphans).
- **Tantivy reindex**: every indexed doc carries a `drive_id`;
@@ -1396,6 +1861,13 @@ static/css/components/driveSwitcher.css ← D1
the `auth.app_passwords.drive_id` binding — see §9).
- **Wrapper folder** — historical name for
`My Folder - <username>`, the folder created at registration
via `format!("My Folder - {}", username)`. **Retired** in the
Drive migration: drive root replaces it. Every reference to
via `format!("My Folder - {}", username)`. **Adopted** in the
Drive migration: the same folder row is renamed to `Personal`
and reused as the default personal drive's root folder
(`drives.root_folder_id`). No row is deleted; the wrapper IS
the root folder under the new model. Every reference to
"wrapper" in older comments / docs is by definition pre-Drive.
- **Drive's root folder** — the folder row pointed at by
`storage.drives.root_folder_id`. `parent_id IS NULL`,
`drive_id` = the drive. Every drive has exactly one (§3); the
drive's display name lives on this folder's `name` column.
+1
View File
@@ -41,6 +41,7 @@ test-integration:
DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \
RUSTFLAGS='--cfg integration_tests' \
cargo test --workspace --tests
bash tests/common/stop-db.sh
test-one name:
cargo test {{name}}
@@ -0,0 +1,174 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D0 / M1 — Drive foundation: additive schema only
-- ════════════════════════════════════════════════════════════════════════════
-- First of three D0 migrations.
-- M1 (this file) additive — creates storage.drives + nullable columns.
-- M2 (next) backfill — promotes wrapper folders, fills drive_id.
-- M3 (final) constraints — NOT NULL + FKs + drive_id indexes.
--
-- This file is **safe to run on a populated database without an outage**.
-- It only ADDs structure (new table, new nullable columns, extended CHECK
-- constraints, new FK targets). No row is modified; no existing query
-- needs to be aware of the new columns yet.
--
-- The migration is reversible at this stage: dropping the new table and
-- the new columns leaves the database identical to its pre-D0 state. The
-- dual-write / data-movement phase (M2) is where rollback becomes
-- progressively harder.
-- ── 1. storage.drives — the central drive entity ────────────────────────────
CREATE TABLE IF NOT EXISTS storage.drives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Discriminant. Two kinds today; extending the set is a DROP + ADD
-- CHECK constraint pair (no separate lookup table).
-- 'personal' = single-owner, no membership API
-- 'shared' = multi-member, full role roster, group-aware
kind TEXT NOT NULL
CHECK (kind IN ('personal', 'shared')),
-- Set iff this is the user's default personal drive. The partial
-- unique index below enforces "one default drive per user" without
-- blocking secondaries (NULL means "not the default"); shared drives
-- always have NULL here.
default_for_user UUID
REFERENCES auth.users(id) ON DELETE CASCADE,
-- The drive's mount-point folder. The display name lives here (drives
-- have no `name` column — see docs/plan/drive.md §3). NULL at the
-- column type level so the atomic creation CTE can INSERT the drive
-- row before the root folder exists, then UPDATE this column from
-- a later CTE branch within the same statement (a column-level
-- NOT NULL would refuse that initial INSERT). The invariant
-- "every drive has a root folder" is enforced by the CTE being
-- the only creation path, plus the M2 backfill populating this
-- column for migrated drives. Code reading this column may treat
-- it as Uuid (not Option<Uuid>); a NULL here is a bug.
root_folder_id UUID
REFERENCES storage.folders(id) ON DELETE CASCADE,
-- Storage quota in bytes. NULL = no quota (admin override / system
-- drives). Initial value on personal-drive creation is taken from
-- the owner's `auth.users.storage_quota_bytes` at the application
-- layer. **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) —
-- not in the drive `owner` role bundle.
quota_bytes BIGINT,
-- Running total of bytes consumed. Maintained by D4's incremental
-- counters; on D0 backfilled from the per-user counters as a
-- starting baseline.
used_bytes BIGINT NOT NULL DEFAULT 0,
-- Capability flags / feature toggles bag (see docs/plan/drive.md §8
-- and §15 for the known keys: forbid_public_links,
-- forbid_external_sharing, include_in_photo_index, forbid_music_index,
-- etc.). Unknown keys preserved verbatim — the schema is
-- intentionally permissive so future flags land without migration.
policies JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE storage.drives IS
'Drive entity — pure metadata. The display name and mount point live '
'on the root folder (root_folder_id). Membership lives in '
'storage.role_grants with resource_type=''drive''. Replaces the '
'per-user My Folder wrapper at D0 (see docs/plan/drive.md §3).';
COMMENT ON COLUMN storage.drives.kind IS
'personal = single-owner (no add_member); shared = multi-member with full role roster.';
COMMENT ON COLUMN storage.drives.default_for_user IS
'Set iff this is the user''s default personal drive. NULL on secondaries and shared drives.';
COMMENT ON COLUMN storage.drives.root_folder_id IS
'Drive''s root folder. NULLable at the column level only so the '
'atomic creation CTE can write it mid-statement; populated invariant '
'enforced by application. Display name = SELECT name FROM '
'storage.folders WHERE id = root_folder_id.';
COMMENT ON COLUMN storage.drives.policies IS
'JSONB capability-flag bag; see docs/plan/drive.md §8 §15 for known keys.';
-- "One default drive per user." Partial unique index — NULLs (every
-- non-default row) are excluded from the constraint surface.
CREATE UNIQUE INDEX IF NOT EXISTS idx_drives_default_for_user_unique
ON storage.drives (default_for_user)
WHERE default_for_user IS NOT NULL;
-- Hot-path "what's this drive?" lookup by kind for admin / D3 flows
-- ("list every shared drive").
CREATE INDEX IF NOT EXISTS idx_drives_kind ON storage.drives (kind);
-- ── 2. drive_id columns on folders + files (NULL during M1) ────────────────
-- M2 fills these in for every existing row; M3 promotes them to NOT NULL
-- and adds the FK + index. Keep nullable here so the migration runs on a
-- populated DB without violating a constraint.
ALTER TABLE storage.folders ADD COLUMN IF NOT EXISTS drive_id UUID;
ALTER TABLE storage.files ADD COLUMN IF NOT EXISTS drive_id UUID;
-- ── 3. Provenance columns: created_by / updated_by ─────────────────────────
-- D0 adds these on folders + files (see docs/plan/drive.md §14). FKs use
-- ON DELETE SET NULL so deleting a user nulls these out instead of
-- cascading the resource away. M2 backfills from the existing `user_id`
-- column so pre-Drive content carries authentic provenance from day one.
ALTER TABLE storage.folders
ADD COLUMN IF NOT EXISTS created_by UUID
REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE storage.folders
ADD COLUMN IF NOT EXISTS updated_by UUID
REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE storage.files
ADD COLUMN IF NOT EXISTS created_by UUID
REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE storage.files
ADD COLUMN IF NOT EXISTS updated_by UUID
REFERENCES auth.users(id) ON DELETE SET NULL;
COMMENT ON COLUMN storage.folders.created_by IS
'Who originally created the folder. NULL when the original creator''s '
'auth.users row has since been deleted.';
COMMENT ON COLUMN storage.folders.updated_by IS
'Who last touched the folder (rename, move, metadata change). Same '
'write-path discipline as updated_at.';
COMMENT ON COLUMN storage.files.created_by IS
'Who originally uploaded the file. NULL when the original uploader''s '
'auth.users row has since been deleted.';
COMMENT ON COLUMN storage.files.updated_by IS
'Who last touched the file (rename, move, overwrite, restore). Same '
'write-path discipline as updated_at.';
-- ── 4. role_grants resource_type CHECK — admit 'drive' ─────────────────────
-- The D-Prep migration's CHECK only listed 'folder' and 'file'. Drives
-- need to be a valid resource_type so the lifecycle hook (D0-9) and the
-- membership API (D2) can write `role_grants` rows with
-- resource_type='drive'.
ALTER TABLE storage.role_grants
DROP CONSTRAINT IF EXISTS role_grants_resource_type_check;
ALTER TABLE storage.role_grants
ADD CONSTRAINT role_grants_resource_type_check
CHECK (resource_type IN ('folder', 'file', 'drive'));
-- ── 5. updated_at trigger for storage.drives ───────────────────────────────
-- Mirror the convention from auth.users / storage.folders / storage.files
-- so rename / quota-change / policy-toggle bumps updated_at automatically.
-- Drive owners shouldn't have to remember to maintain this.
CREATE OR REPLACE FUNCTION storage.drives_touch_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_drives_touch_updated_at ON storage.drives;
CREATE TRIGGER trg_drives_touch_updated_at
BEFORE UPDATE ON storage.drives
FOR EACH ROW EXECUTE FUNCTION storage.drives_touch_updated_at();
@@ -0,0 +1,438 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D0 / M2 — Drive backfill: adopt wrappers + stamp drive_id + provenance
-- ════════════════════════════════════════════════════════════════════════════
-- Second of the D0 migration trio. Implements §A of the migration plan in
-- docs/plan/drive.md — the "rename-and-adopt" model:
--
-- * For every internal user with a root folder, create a Personal drive
-- (metadata only — no `name` column; the display name lives on the
-- root folder).
-- * The folder literally named `My Folder - <username>` is **adopted
-- in place** as the user's default Personal drive's root folder:
-- `drives.root_folder_id` points at it, its `drive_id` is stamped,
-- and it is renamed to `Personal`. The wrapper row is NOT deleted;
-- descendants are NOT promoted. The AFTER-UPDATE folder cascade
-- trigger rewrites descendant `path`/`lpath` automatically when the
-- wrapper rename fires — no bulk path UPDATE in this migration.
-- * Any sibling root folders become secondary Personal drives'
-- root folders (`default_for_user = NULL`, original folder name
-- preserved). Same adoption pattern: drive_id stamped, drives.root_folder_id
-- wired, no rename.
-- * One owner role_grants row per new drive.
-- * Every existing folder/file row gets a `drive_id` (cascaded down the
-- ltree from the wrapper).
-- * Every existing folder/file row gets `created_by` and `updated_by`
-- backfilled from the existing `user_id` column.
--
-- External users (`auth.users.is_external = TRUE`) are intentionally
-- skipped — they have no root folder of their own, only role_grants
-- against other users' resources.
-- ── Pre-flight 1: refuse on sibling root literally named 'drives' ──────────
-- 'drives' is a reserved URL segment on the native WebDAV surface
-- (`/webdav/drives/<uuid>/`). A folder named 'drives' would shadow the
-- drive-listing route once D1 ships. Surface the conflict now — operator
-- renames before retrying.
DO $BODY$
DECLARE
bad_count BIGINT;
BEGIN
SELECT count(*) INTO bad_count
FROM storage.folders f
JOIN auth.users u ON u.id = f.user_id
WHERE f.parent_id IS NULL
AND NOT f.is_trashed
AND lower(f.name) = 'drives'
AND NOT u.is_external;
IF bad_count > 0 THEN
RAISE EXCEPTION
'D0 backfill refused: % root folder(s) literally named ''drives'' '
'would collide with the reserved /webdav/drives/<uuid>/ URL segment '
'once D1 ships. Rename the offending folders, then retry the '
'migration. Query to inspect: SELECT f.id, f.user_id, f.name '
'FROM storage.folders f JOIN auth.users u ON u.id = f.user_id '
'WHERE f.parent_id IS NULL AND NOT f.is_trashed AND lower(f.name) '
'= ''drives'' AND NOT u.is_external;',
bad_count;
END IF;
END $BODY$;
-- ── Pre-flight 1b: refuse on rename collision with sibling root 'Personal' ─
-- The default-wrapper rename in step 4 changes `My Folder - <username>` →
-- `Personal`. The pre-M3 folder unique index is user_id-scoped
-- (`(name, user_id) WHERE parent_id IS NULL`), so a user who already has
-- a SQL-created sibling root literally named `Personal` would trip the
-- index when M2 tries to rename the wrapper. Surface the collision now —
-- operator renames the offending sibling before retrying, then it gets
-- adopted as a secondary drive with whatever new name it carries.
DO $BODY$
DECLARE
collisions BIGINT;
BEGIN
SELECT count(*) INTO collisions
FROM auth.users u
JOIN storage.folders wrapper
ON wrapper.user_id = u.id
AND wrapper.parent_id IS NULL
AND NOT wrapper.is_trashed
AND wrapper.name = 'My Folder - ' || u.username
JOIN storage.folders sibling
ON sibling.user_id = u.id
AND sibling.parent_id IS NULL
AND NOT sibling.is_trashed
AND sibling.id != wrapper.id
AND sibling.name = 'Personal'
WHERE NOT u.is_external;
IF collisions > 0 THEN
RAISE EXCEPTION
'D0 backfill refused: % user(s) have both a `My Folder - <username>` '
'wrapper AND a sibling root named ''Personal''. The wrapper rename '
'step would collide on the user_id-scoped folder unique index. '
'Rename the offending sibling first. Query to inspect: SELECT u.id, '
'u.username FROM auth.users u JOIN storage.folders w ON w.user_id=u.id '
'AND w.parent_id IS NULL AND w.name=''My Folder - ''||u.username '
'JOIN storage.folders s ON s.user_id=u.id AND s.parent_id IS NULL '
'AND s.id!=w.id AND s.name=''Personal'' WHERE NOT u.is_external;',
collisions;
END IF;
END $BODY$;
-- ── Pre-flight 2: report sibling-root distribution (informational) ─────────
-- Most users have exactly one root (`My Folder - <username>`). Some may
-- have SQL-added siblings — those become secondary drives. Surface the
-- count so operators can sanity-check before the migration commits.
DO $BODY$
DECLARE
extras BIGINT;
BEGIN
WITH counts AS (
SELECT u.id AS user_id, count(*) AS root_count
FROM auth.users u
JOIN storage.folders f ON f.user_id = u.id
WHERE f.parent_id IS NULL
AND NOT f.is_trashed
AND NOT u.is_external
GROUP BY u.id
)
SELECT count(*) INTO extras FROM counts WHERE root_count > 1;
IF extras > 0 THEN
RAISE NOTICE
'D0 backfill: % user(s) have more than one root folder. Their '
'siblings will be promoted to secondary Personal drives. Inspect '
'with: WITH c AS (SELECT u.id, u.username, count(*) cnt FROM '
'auth.users u JOIN storage.folders f ON f.user_id=u.id WHERE '
'f.parent_id IS NULL AND NOT f.is_trashed AND NOT u.is_external '
'GROUP BY u.id, u.username) SELECT * FROM c WHERE cnt > 1;',
extras;
END IF;
END $BODY$;
-- ── 1. Plan every drive that needs to be created ──────────────────────────
-- Temp table is the cleanest way to pre-compute the new UUIDs once and
-- reuse them across the INSERT-drives, INSERT-grants, and UPDATE-folders
-- steps below. `gen_random_uuid()` in a CTE would re-evaluate on every
-- branch.
--
-- A row joins each existing root folder to its future drive_id. The
-- `is_default` flag is computed per-user as a window function so EVERY
-- internal user with at least one root folder ends up with exactly one
-- default drive — even if the user's wrapper was renamed away from
-- `My Folder - <username>` at some point. Preference order:
-- 1. The folder literally named `My Folder - <username>` if it exists.
-- 2. Otherwise the oldest root by `created_at`, tiebroken by `id`.
-- `ON COMMIT DROP` would race with the `[init-schema]` CI flow that
-- runs migrations via `psql \i` in autocommit mode: the CREATE statement
-- commits, the table drops, and the next statement (the DO block) can't
-- see it. The plain temp table survives until session end in autocommit
-- mode and until our explicit DROP at the bottom under `sqlx migrate`'s
-- single-tx mode. Works under both.
CREATE TEMPORARY TABLE _drive_plan AS
WITH root_folders AS (
SELECT
u.id AS user_id,
u.username AS username,
u.storage_quota_bytes AS quota,
f.id AS wrapper_id,
f.name AS wrapper_name,
f.created_at AS created_at,
(f.name = 'My Folder - ' || u.username) AS name_matches_default
FROM auth.users u
JOIN storage.folders f
ON f.user_id = u.id
AND f.parent_id IS NULL
AND NOT f.is_trashed
WHERE NOT u.is_external
)
SELECT
user_id,
username,
quota,
wrapper_id,
wrapper_name,
-- Rank candidates per user: name-matched root wins; otherwise oldest
-- by created_at then by id (stable, deterministic). ROW_NUMBER() = 1
-- becomes the default drive for that user.
(ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY name_matches_default DESC,
created_at ASC,
wrapper_id ASC
) = 1) AS is_default,
gen_random_uuid() AS new_drive_id
FROM root_folders;
-- ── 1b. Log which users got an auto-picked default (no name match) ────────
-- Operational nicety: if a user's default came from oldest-root fallback
-- rather than the canonical `My Folder - <username>`, surface it so an
-- operator can DM the user and confirm the migration picked the right
-- root. Not a failure — just visibility.
DO $BODY$
DECLARE
auto_picked BIGINT;
BEGIN
SELECT count(*) INTO auto_picked
FROM _drive_plan p
WHERE p.is_default
AND p.wrapper_name <> 'My Folder - ' || p.username;
IF auto_picked > 0 THEN
RAISE NOTICE
'D0 backfill: % user(s) had no `My Folder - <username>` root; '
'the oldest sibling root was auto-picked as their default '
'Personal drive. Inspect with: SELECT user_id, username, '
'wrapper_name FROM _drive_plan WHERE is_default AND '
'wrapper_name <> ''My Folder - '' || username; '
'(temp table only exists during the migration transaction.)',
auto_picked;
END IF;
END $BODY$;
-- ── 2. Insert the drive rows (metadata only — no `name` column) ───────────
-- Drives are pure metadata under the new design (docs/plan/drive.md §3).
-- The display name lives on the root folder; the wrapper is renamed in
-- step 4b for default drives and kept as-is for secondaries.
INSERT INTO storage.drives
(id, kind, default_for_user, quota_bytes)
SELECT
p.new_drive_id,
'personal',
CASE WHEN p.is_default THEN p.user_id ELSE NULL END,
p.quota
FROM _drive_plan p;
-- ── 3. Insert one owner role_grants row per drive ─────────────────────────
-- Each user is the sole owner of every drive their wrappers produced.
-- The lifecycle hook (D0-9) will do the same for users created post-D0.
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
SELECT 'user', p.user_id, 'drive', p.new_drive_id, 'owner', p.user_id
FROM _drive_plan p;
-- ── 4. Adopt the wrapper as the drive's root folder ───────────────────────
-- 4a. Stamp drive_id on each wrapper so the cascade in §5 can walk the
-- ltree subtree without a separate index.
-- 4b. Rename the default-drive wrapper from `My Folder - <username>` to
-- `Personal` (the canonical default name; renameable via the folder
-- API later). The BEFORE-UPDATE folder path trigger fires on the
-- rename and the AFTER-UPDATE cascade trigger rewrites every
-- descendant `path` / `lpath` automatically — no per-row UPDATE here.
-- 4c. Wire drives.root_folder_id to the wrapper. This is the adoption
-- step: the wrapper row IS the drive's root folder after M2 (no
-- wrapper-deletion, no descendant promotion).
UPDATE storage.folders f
SET drive_id = p.new_drive_id,
name = CASE WHEN p.is_default THEN 'Personal' ELSE f.name END
FROM _drive_plan p
WHERE f.id = p.wrapper_id;
UPDATE storage.drives d
SET root_folder_id = p.wrapper_id
FROM _drive_plan p
WHERE d.id = p.new_drive_id;
-- ── 5. Cascade drive_id down the folder tree ──────────────────────────────
-- For every folder descended from a wrapper, set drive_id to that
-- wrapper's. Uses the existing GiST index `idx_folders_lpath` for the
-- @> (ancestor-of) lookup. Trashed descendants get a drive_id too —
-- soft-deleted folders need a drive_id once M3 makes the column NOT NULL.
UPDATE storage.folders sub
SET drive_id = wrapper.drive_id
FROM storage.folders wrapper
WHERE wrapper.id IN (SELECT wrapper_id FROM _drive_plan)
AND sub.lpath <@ wrapper.lpath
AND sub.id != wrapper.id
AND sub.drive_id IS NULL;
-- ── 6. Cascade drive_id to files (via their folder) ───────────────────────
-- Files inherit drive_id from their containing folder. A NULL folder_id
-- file is an orphan — left with NULL drive_id here; M3's NOT NULL
-- constraint will refuse the migration if any such orphans remain,
-- which is the right outcome (forces operator inspection).
UPDATE storage.files fi
SET drive_id = fo.drive_id
FROM storage.folders fo
WHERE fi.folder_id = fo.id
AND fi.drive_id IS NULL
AND fo.drive_id IS NOT NULL;
-- ── 7. Provenance backfill ────────────────────────────────────────────────
-- Every pre-Drive row carries authentic provenance from day one: created_by
-- and updated_by both default to the user_id that we know created the
-- resource (that's exactly what user_id meant pre-D0). New writes during
-- the dual-write window populate both columns explicitly.
UPDATE storage.folders
SET created_by = user_id,
updated_by = user_id
WHERE created_by IS NULL;
UPDATE storage.files
SET created_by = user_id,
updated_by = user_id
WHERE created_by IS NULL;
-- ── 7b. Drop the planning temp table ──────────────────────────────────────
-- Explicit drop since we removed `ON COMMIT DROP` above. Idempotent
-- (`IF EXISTS`) so a partial re-run during development doesn't error.
DROP TABLE IF EXISTS _drive_plan;
-- ── 8. Post-flight consistency check ──────────────────────────────────────
-- The checks here REFUSE to commit if any invariant is violated, so a
-- successful migration is a verifiable migration.
--
-- 8a. Every internal user with a root folder has exactly one
-- default drive.
-- 8b. Every drive has at least one owner role_grants row.
-- 8c. No NULL drive_id remains on a folder/file row whose owner is
-- a non-external user with a root folder (i.e. every row that
-- belongs to a drive must now declare which one).
--
-- M3 turns drive_id NOT NULL; the check below is a stricter pre-flight
-- so the failure mode is "migration refuses" rather than "M3 errors
-- with a NOT NULL violation halfway through".
DO $BODY$
DECLARE
missing_default BIGINT;
grantless_drives BIGINT;
null_folder_drive_id BIGINT;
null_file_drive_id BIGINT;
rootless_drives BIGINT;
BEGIN
SELECT count(*) INTO missing_default
FROM auth.users u
WHERE NOT u.is_external
AND EXISTS (
SELECT 1 FROM storage.folders f
WHERE f.user_id = u.id AND f.parent_id IS NULL AND NOT f.is_trashed
)
AND NOT EXISTS (
SELECT 1 FROM storage.drives d
WHERE d.default_for_user = u.id
);
IF missing_default > 0 THEN
RAISE EXCEPTION
'D0 backfill consistency check failed: % internal user(s) with a '
'root folder have no default Personal drive. Investigate before '
'declaring the migration successful.',
missing_default;
END IF;
SELECT count(*) INTO grantless_drives
FROM storage.drives d
WHERE NOT EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = d.id
AND g.role = 'owner'
);
IF grantless_drives > 0 THEN
RAISE EXCEPTION
'D0 backfill consistency check failed: % drive(s) have no owner '
'role_grants row. Investigate before declaring the migration '
'successful.',
grantless_drives;
END IF;
-- Root-folder adoption invariant (docs/plan/drive.md §3): every
-- drive must point at a real folder row whose drive_id closes the
-- cycle. The column is NULLable at the type level so the atomic
-- CTE can write it mid-statement; this check enforces the data
-- invariant after the migration.
SELECT count(*) INTO rootless_drives
FROM storage.drives d
WHERE d.root_folder_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders f
WHERE f.id = d.root_folder_id
AND f.drive_id = d.id
AND f.parent_id IS NULL
);
IF rootless_drives > 0 THEN
RAISE EXCEPTION
'D0 backfill consistency check failed: % drive(s) have no '
'valid root_folder_id (NULL, or pointing at a folder that '
'isn''t a root in this drive). Investigate before declaring '
'the migration successful.',
rootless_drives;
END IF;
SELECT count(*) INTO null_folder_drive_id
FROM storage.folders f
WHERE f.drive_id IS NULL
AND EXISTS (
SELECT 1 FROM auth.users u
WHERE u.id = f.user_id AND NOT u.is_external
);
IF null_folder_drive_id > 0 THEN
RAISE EXCEPTION
'D0 backfill consistency check failed: % folder(s) belonging to '
'an internal user still have NULL drive_id. M3 will refuse to '
'add NOT NULL until these are resolved.',
null_folder_drive_id;
END IF;
SELECT count(*) INTO null_file_drive_id
FROM storage.files fi
WHERE fi.drive_id IS NULL
AND EXISTS (
SELECT 1 FROM auth.users u
WHERE u.id = fi.user_id AND NOT u.is_external
);
IF null_file_drive_id > 0 THEN
RAISE EXCEPTION
'D0 backfill consistency check failed: % file(s) belonging to an '
'internal user still have NULL drive_id (likely orphans — '
'folder_id pointing at a missing folder). Inspect with: SELECT '
'fi.id, fi.user_id, fi.folder_id FROM storage.files fi JOIN '
'auth.users u ON u.id = fi.user_id WHERE fi.drive_id IS NULL '
'AND NOT u.is_external;',
null_file_drive_id;
END IF;
END $BODY$;
@@ -0,0 +1,130 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D0 / M3 — Drive constraints: NOT NULL, FK, indexes
-- ════════════════════════════════════════════════════════════════════════════
-- Third of the D0 migration trio. Runs only after M2 (the backfill) has
-- populated every folder/file row with a `drive_id`. This migration is
-- the point of no easy rollback — once `drive_id` is NOT NULL and
-- foreign-keyed to `storage.drives`, dropping the column requires the
-- application code to first stop reading it.
--
-- What lands here:
-- * NOT NULL on `storage.folders.drive_id` and `storage.files.drive_id`.
-- * Foreign keys from both to `storage.drives(id)` with ON DELETE
-- CASCADE (deleting a drive removes its tree — matches the
-- post-D2 lifecycle plan).
-- * Indexes on `drive_id` for both tables (hot path: list-by-drive,
-- drive-aware Tantivy reseed, drive-quota counters).
--
-- The `user_id` column is intentionally left in place: dual-write during
-- the D0 release cycle is the rollback safety net. D7 drops user_id once
-- the new model has baked.
-- ── 1. NOT NULL on drive_id ────────────────────────────────────────────────
-- M2's post-flight check refused to commit if any row was missing
-- drive_id, so this should never fail. The check at column promotion
-- time is the belt; M2's pre-commit assertion was the suspenders.
ALTER TABLE storage.folders
ALTER COLUMN drive_id SET NOT NULL;
ALTER TABLE storage.files
ALTER COLUMN drive_id SET NOT NULL;
-- ── 2. Foreign keys to storage.drives ──────────────────────────────────────
-- ON DELETE CASCADE: when a drive is deleted (D3 ships the delete-drive
-- flow), every folder and file row carrying that drive_id is removed in
-- the same transaction. Trash retention does not apply — drive deletion
-- is the explicit "I'm done with this storage" gesture.
ALTER TABLE storage.folders
ADD CONSTRAINT folders_drive_id_fkey
FOREIGN KEY (drive_id) REFERENCES storage.drives(id) ON DELETE CASCADE;
ALTER TABLE storage.files
ADD CONSTRAINT files_drive_id_fkey
FOREIGN KEY (drive_id) REFERENCES storage.drives(id) ON DELETE CASCADE;
-- ── 3. Indexes on drive_id ─────────────────────────────────────────────────
-- The hot path that ranks every drive-aware query: "list folders in
-- drive X", "files in drive X for Tantivy reindex", "per-drive quota
-- aggregation". The existing `user_id` indexes are kept during dual-
-- write and dropped in D7 alongside the column.
CREATE INDEX IF NOT EXISTS idx_folders_drive_id ON storage.folders (drive_id);
CREATE INDEX IF NOT EXISTS idx_files_drive_id ON storage.files (drive_id);
-- ── 3b. Drive-scoped folder uniqueness indexes ─────────────────────────────
-- Pre-D0 the "no duplicate folder name under the same parent for the same
-- user" constraint was user_id-scoped (docs/plan/drive.md §10). The
-- semantics users actually want is "no duplicate names *within a drive*"
-- — a folder named "Reports" in your Personal drive shouldn't preclude
-- another "Reports" in a shared "Team" drive. Flip the scope here, now
-- that every row has a drive_id.
--
-- Same partial predicate as the originals (NOT is_trashed, plus the
-- root-vs-non-root split via parent_id IS NULL).
DROP INDEX IF EXISTS storage.idx_folders_unique_name;
DROP INDEX IF EXISTS storage.idx_folders_unique_name_root;
CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_unique_name
ON storage.folders(parent_id, name, drive_id)
WHERE NOT is_trashed AND parent_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_unique_name_root
ON storage.folders(name, drive_id)
WHERE NOT is_trashed AND parent_id IS NULL;
-- ── 4. Post-flight: confirm constraints landed ────────────────────────────
-- Belt-and-suspenders verification that the NOT NULL + FK actually
-- exist after the ALTERs above. Any failure here means PostgreSQL
-- silently no-op'd one of the constraint changes, which would be a
-- bug worth surfacing immediately.
DO $BODY$
DECLARE
folder_not_null BOOLEAN;
file_not_null BOOLEAN;
folder_fk_exists BOOLEAN;
file_fk_exists BOOLEAN;
BEGIN
SELECT NOT is_nullable::boolean INTO folder_not_null
FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'folders'
AND column_name = 'drive_id';
SELECT NOT is_nullable::boolean INTO file_not_null
FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'files'
AND column_name = 'drive_id';
SELECT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'storage'
AND table_name = 'folders'
AND constraint_name = 'folders_drive_id_fkey'
AND constraint_type = 'FOREIGN KEY'
) INTO folder_fk_exists;
SELECT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'storage'
AND table_name = 'files'
AND constraint_name = 'files_drive_id_fkey'
AND constraint_type = 'FOREIGN KEY'
) INTO file_fk_exists;
IF NOT folder_not_null OR NOT file_not_null
OR NOT folder_fk_exists OR NOT file_fk_exists THEN
RAISE EXCEPTION
'D0 M3 post-flight failed: '
'folder NOT NULL=%, file NOT NULL=%, folder FK=%, file FK=%. '
'All four must be true after this migration commits.',
folder_not_null, file_not_null, folder_fk_exists, file_fk_exists;
END IF;
END $BODY$;
@@ -0,0 +1,148 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D0 / M4 — tree_etag_dirty drive_id awareness
-- ════════════════════════════════════════════════════════════════════════════
-- Fourth D0 migration. The async tree-ETag queue (introduced in
-- `20260627000000_async_tree_etag_queue.sql`) walks `f.lpath @> t.lpath`
-- to bump every ancestor of a changed folder/file. Without a drive_id
-- filter, that walk can match folders in OTHER drives whose ltree
-- prefixes happen to align numerically — a silent cross-drive ETag
-- bump that nobody would notice until D2 ships shared drives.
--
-- This migration is preventive: it adds the column, teaches the
-- triggers to carry drive_id into the queue, and the Rust flush
-- service (`tree_etag_flush_service.rs`) gets the matching
-- `AND f.drive_id = t.drive_id` predicate so the cross-drive case is
-- closed end-to-end before drives can collide.
-- ── 1. drive_id column on the queue table ──────────────────────────────────
-- NULL-tolerant during the rollover: existing queue entries enqueued by
-- the old triggers have no drive_id. They drain on the next flush tick
-- with the old (no drive_id) semantics — which for D0 is still correct
-- because every personal drive's lpath is structurally disjoint from
-- every other user's. New entries enqueued by the updated triggers
-- below carry a non-NULL value.
ALTER TABLE storage.tree_etag_dirty ADD COLUMN IF NOT EXISTS drive_id UUID;
-- ── 2. File-side INSERT/DELETE trigger ─────────────────────────────────────
-- Source rows live in `storage.files` (changed_rows). Each file row
-- carries `drive_id` directly (D0-8 dual-write). Pull from the joined
-- folder row so the (lpath, folder_id, drive_id) triple is internally
-- consistent — a single source of truth per enqueued row.
CREATE OR REPLACE FUNCTION storage.bump_tree_from_files_stmt()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
SELECT DISTINCT fo.lpath, fo.id, fo.drive_id
FROM (SELECT DISTINCT folder_id
FROM changed_rows
WHERE folder_id IS NOT NULL) c
JOIN storage.folders fo ON fo.id = c.folder_id;
RETURN NULL;
END;
$$;
-- ── 3. File-side UPDATE trigger ────────────────────────────────────────────
-- Move case: the file changed parent. Bump both the old and the new
-- parent chains (each in its own drive — D0 has them equal, D2 can see
-- them diverge once cross-drive moves land).
CREATE OR REPLACE FUNCTION storage.bump_tree_from_files_stmt_upd()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
WITH changed AS (
SELECT o.folder_id AS old_folder_id, n.folder_id AS new_folder_id
FROM old_rows o
JOIN new_rows n USING (id)
WHERE (o.name, o.folder_id, o.blob_hash, o.size,
o.mime_type, o.is_trashed, o.updated_at)
IS DISTINCT FROM
(n.name, n.folder_id, n.blob_hash, n.size,
n.mime_type, n.is_trashed, n.updated_at)
)
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
SELECT DISTINCT fo.lpath, fo.id, fo.drive_id
FROM (SELECT old_folder_id AS folder_id
FROM changed WHERE old_folder_id IS NOT NULL
UNION
SELECT new_folder_id
FROM changed WHERE new_folder_id IS NOT NULL) c
JOIN storage.folders fo ON fo.id = c.folder_id;
RETURN NULL;
END;
$$;
-- ── 4. Folder-side INSERT/DELETE trigger ──────────────────────────────────
-- changed_rows are storage.folders rows, which carry drive_id directly
-- post-D0-7.
CREATE OR REPLACE FUNCTION storage.bump_tree_from_folders_stmt()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
SELECT DISTINCT subpath(lpath, 0, nlevel(lpath) - 1), parent_id, drive_id
FROM changed_rows
WHERE lpath IS NOT NULL
AND nlevel(lpath) > 1;
RETURN NULL;
END;
$$;
-- ── 5. Folder-side UPDATE trigger ──────────────────────────────────────────
-- Same shape as the INSERT/DELETE case; we union OLD and NEW parents,
-- carrying each chain's drive_id from the matching row side.
CREATE OR REPLACE FUNCTION storage.bump_tree_from_folders_stmt_upd()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NULL;
END IF;
WITH changed AS (
SELECT o.lpath AS old_lpath,
o.parent_id AS old_parent_id,
o.drive_id AS old_drive_id,
n.lpath AS new_lpath,
n.parent_id AS new_parent_id,
n.drive_id AS new_drive_id
FROM old_rows o
JOIN new_rows n USING (id)
WHERE (o.name, o.parent_id, o.is_trashed, o.updated_at)
IS DISTINCT FROM
(n.name, n.parent_id, n.is_trashed, n.updated_at)
)
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
SELECT DISTINCT subpath(c.lpath, 0, nlevel(c.lpath) - 1), c.parent_id, c.drive_id
FROM (SELECT old_lpath AS lpath,
old_parent_id AS parent_id,
old_drive_id AS drive_id
FROM changed WHERE old_lpath IS NOT NULL
UNION
SELECT new_lpath, new_parent_id, new_drive_id
FROM changed WHERE new_lpath IS NOT NULL) c
WHERE nlevel(c.lpath) > 1;
RETURN NULL;
END;
$$;
@@ -0,0 +1,133 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D0 / M5 — storage.copy_folder_tree drive_id + provenance
-- ════════════════════════════════════════════════════════════════════════════
-- The `copy_folder_tree` SQL function (initial_schema.sql) batches a
-- recursive folder copy in PL/pgSQL — its own INSERTs into
-- `storage.folders` and `storage.files`. D0's M3 made `drive_id` NOT
-- NULL on both tables; the function's pre-D0 body doesn't write it,
-- so any `/api/batch/folders/copy` call errors with "null value in
-- column drive_id" until this migration lands.
--
-- The replacement preserves every other semantic of the original:
-- - level-by-level INSERTs so the BEFORE INSERT trigger
-- (`trg_folders_path`) can resolve the parent's path/lpath from
-- rows inserted in the previous level.
-- - One batched file INSERT (zero-copy via blob hash) at the end.
-- - Returns the same shape: (new_root_id::text, folders_copied,
-- files_copied).
--
-- New columns written:
-- - drive_id: pulled from the source row (intra-drive copy — cross-
-- drive copies are a D2+ feature; the function preserves the
-- source's drive_id for both folders and files).
-- - created_by / updated_by: set to the source row's user_id,
-- matching the dual-write convention used by the Rust repos.
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
p_source_id UUID,
p_target_parent_id UUID, -- NULL = copy to root
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
DECLARE
v_root_lpath ltree;
v_root_depth INT;
v_max_depth INT;
v_level INT;
v_folders BIGINT := 0;
v_files BIGINT := 0;
v_inserted BIGINT;
v_new_root UUID;
BEGIN
-- Validate source exists
SELECT fo.lpath, nlevel(fo.lpath)
INTO v_root_lpath, v_root_depth
FROM storage.folders fo
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
IF v_root_lpath IS NULL THEN
RAISE EXCEPTION 'Source folder not found: %', p_source_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
-- Temp mapping: every folder in the subtree → new UUID
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_map;
INSERT INTO _copy_map(old_id)
SELECT fo.id
FROM storage.folders fo
WHERE NOT fo.is_trashed
AND fo.lpath <@ v_root_lpath;
-- Remember new root ID
SELECT cm.new_id INTO v_new_root
FROM _copy_map cm WHERE cm.old_id = p_source_id;
-- Max depth for level iteration
SELECT MAX(nlevel(fo.lpath))
INTO v_max_depth
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id;
-- ── Insert folders level by level ──
-- Each level is a separate INSERT so the BEFORE INSERT trigger
-- (trg_folders_path) can resolve the parent's path/lpath from rows
-- inserted in the previous level. drive_id + provenance threaded
-- through from the source row at each level.
FOR v_level IN v_root_depth .. v_max_depth LOOP
INSERT INTO storage.folders(
id, name, parent_id, user_id,
drive_id, created_by, updated_by
)
SELECT cm.new_id,
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
THEN p_dest_name ELSE fo.name END,
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
ELSE pm.new_id END,
fo.user_id,
fo.drive_id,
fo.user_id,
fo.user_id
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
WHERE NOT fo.is_trashed
AND nlevel(fo.lpath) = v_level;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
v_folders := v_folders + v_inserted;
END LOOP;
-- ── Batch copy all files (zero-copy: same blob_hash) ──
INSERT INTO storage.files(
name, folder_id, user_id, blob_hash, size, mime_type,
media_sort_date, drive_id, created_by, updated_by
)
SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type,
f.media_sort_date, f.drive_id, f.user_id, f.user_id
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
WHERE NOT f.is_trashed;
GET DIAGNOSTICS v_files = ROW_COUNT;
-- ── Batch increment blob ref_counts ──
IF v_files > 0 THEN
UPDATE storage.blobs b
SET ref_count = ref_count + hc.cnt
FROM (
SELECT f.blob_hash, COUNT(*)::int AS cnt
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.new_id
WHERE NOT f.is_trashed
GROUP BY f.blob_hash
) hc
WHERE b.hash = hc.blob_hash;
END IF;
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,164 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D0 / Step 8 — No-orphan-root-folder constraint trigger
-- ════════════════════════════════════════════════════════════════════════════
-- Closes the "every root folder must belong to a drive" invariant at the DB
-- level (docs/plan/drive.md §3 "DB-level invariant: no orphan root folder"
-- and §10 Phase A step 8).
--
-- The application-layer guarantee is the atomic four-write transaction in
-- DrivePgRepository::create_personal_drive_atomic — drive + root folder +
-- drives.root_folder_id wire-up + Owner role_grant, all-or-nothing. This
-- migration adds the DB-level belt for the suspenders: a CONSTRAINT TRIGGER
-- that refuses any `storage.folders` row with `parent_id IS NULL` unless
-- some drive's `root_folder_id` points back at it.
--
-- DEFERRABLE INITIALLY DEFERRED is mandatory because the atomic creation
-- order is folder-INSERTed → drive-UPDATEd → COMMIT; an immediate trigger
-- would fire after the folder INSERT (before the drive UPDATE) and refuse
-- the row even though the transaction would close cleanly. Deferred to
-- COMMIT, the check sees the wired state.
--
-- What this migration does NOT cover:
-- * The reverse direction — "drives.root_folder_id must point at a folder
-- whose parent_id IS NULL". A follow-up trigger on storage.drives can
-- close that seam; today the cascade FKs and the application invariant
-- keep it correct.
-- * DELETE handling — folder DELETE cascades to drive DELETE via the
-- drives.root_folder_id ON DELETE CASCADE FK from M1, so the
-- "deleting the root would leave the drive dangling" path is
-- structurally prevented.
-- ── Pre-flight: refuse if any existing orphan root folders are present ────
-- Same pattern as M2: scan current data, RAISE EXCEPTION on any violation
-- so the trigger doesn't land into an inconsistent dataset that would
-- silently break later as soon as one of those rows gets UPDATEd.
--
-- A row is an "orphan root folder" iff `parent_id IS NULL` and no drive's
-- `root_folder_id` equals its id. Trashed rows are exempt (they're soft-
-- deleted in place and the chroot resolver never lands on them).
DO $BODY$
DECLARE
orphan_count BIGINT;
BEGIN
SELECT count(*) INTO orphan_count
FROM storage.folders f
WHERE f.parent_id IS NULL
AND NOT f.is_trashed
AND NOT EXISTS (
SELECT 1 FROM storage.drives d
WHERE d.root_folder_id = f.id
);
IF orphan_count > 0 THEN
RAISE EXCEPTION
'D0 step-8 migration refused: % root folder(s) (parent_id IS NULL, '
'not trashed) have no drive pointing at them via root_folder_id. '
'These would silently fail the constraint trigger on their next '
'UPDATE. Inspect with: SELECT f.id, f.user_id, f.drive_id, f.name '
'FROM storage.folders f WHERE f.parent_id IS NULL AND NOT f.is_trashed '
'AND NOT EXISTS (SELECT 1 FROM storage.drives d WHERE d.root_folder_id '
'= f.id); — then either wire each row to a drive or trash it before '
'retrying.',
orphan_count;
END IF;
END $BODY$;
-- ── 1. The check function ─────────────────────────────────────────────────
-- AFTER trigger so the row is already in the snapshot — the lookup against
-- storage.drives correctly sees the UPDATE that closed the cycle (when
-- called from the atomic transaction, that UPDATE happens later in the
-- same tx; the DEFERRED firing time waits for COMMIT so visibility is
-- correct).
CREATE OR REPLACE FUNCTION storage.check_no_orphan_root_folder()
RETURNS trigger AS $$
BEGIN
-- The trigger fires for every INSERT/UPDATE on storage.folders. We
-- only need to enforce the invariant on root rows; non-root folders
-- are guaranteed correct by their parent_id FK.
IF NEW.parent_id IS NOT NULL THEN
RETURN NULL;
END IF;
-- Trashed root folders are soft-deleted in place — the resolver
-- never lands on them, and they were valid roots before they got
-- trashed. Skip enforcement; the row's history is preserved.
IF NEW.is_trashed THEN
RETURN NULL;
END IF;
-- The core check: some drive must be pointing at this row as its
-- root_folder_id, AND that drive must be the same one carrying our
-- drive_id (the 1:1 bidirectional invariant from §3).
IF NOT EXISTS (
SELECT 1 FROM storage.drives d
WHERE d.id = NEW.drive_id
AND d.root_folder_id = NEW.id
) THEN
RAISE EXCEPTION
'Orphan root folder rejected: storage.folders id=% has '
'parent_id IS NULL and drive_id=%, but no drive has '
'root_folder_id pointing at it. Root folders must be '
'created via the atomic four-write transaction (see '
'docs/plan/drive.md §3 and DrivePgRepository::'
'create_personal_drive_atomic); direct SQL is not '
'supported.',
NEW.id, NEW.drive_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION storage.check_no_orphan_root_folder() IS
'DB-level guard for the "every root folder belongs to a drive" '
'invariant. Wired as a DEFERRABLE INITIALLY DEFERRED constraint '
'trigger so the atomic create transaction (folder INSERTed before '
'drive UPDATEd) commits cleanly. See docs/plan/drive.md §3.';
-- ── 2. The constraint trigger ─────────────────────────────────────────────
-- CONSTRAINT TRIGGER (vs regular trigger) is what lets us declare
-- DEFERRABLE INITIALLY DEFERRED. Without it the row-level fire happens
-- immediately after the INSERT and the atomic transaction can't possibly
-- have UPDATEd drives.root_folder_id yet — every legitimate create would
-- be rejected.
--
-- CONSTRAINT TRIGGERs don't support a WHEN clause; the parent_id /
-- is_trashed filtering lives inside the function above.
DROP TRIGGER IF EXISTS trg_no_orphan_root_folder ON storage.folders;
CREATE CONSTRAINT TRIGGER trg_no_orphan_root_folder
AFTER INSERT OR UPDATE ON storage.folders
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION storage.check_no_orphan_root_folder();
-- ── 3. Post-flight: confirm the trigger landed ────────────────────────────
-- Belt-and-suspenders: PostgreSQL silently does nothing if CREATE TRIGGER
-- fails to attach (unlikely, but a cosmic-ray check). Refusing the
-- migration here surfaces the bug rather than letting it commit silently.
DO $BODY$
DECLARE
trigger_exists BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'storage'
AND c.relname = 'folders'
AND t.tgname = 'trg_no_orphan_root_folder'
AND NOT t.tgisinternal
) INTO trigger_exists;
IF NOT trigger_exists THEN
RAISE EXCEPTION
'D0 step-8 migration post-flight failed: trigger '
'trg_no_orphan_root_folder did not attach to storage.folders.';
END IF;
END $BODY$;
+82
View File
@@ -0,0 +1,82 @@
//! DTOs for the `/api/drives` endpoint surface.
//!
//! D0 surfaces only the read-only list. Mutating endpoints
//! (`POST /api/drives` for shared-drive creation, `PATCH` for rename /
//! policy edits, membership APIs) land in D2/D3.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domain::entities::drive::DriveKind;
use crate::domain::repositories::drive_repository::DriveWithRootName;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DriveKindDto {
Personal,
Shared,
}
impl From<DriveKind> for DriveKindDto {
fn from(k: DriveKind) -> Self {
match k {
DriveKind::Personal => DriveKindDto::Personal,
DriveKind::Shared => DriveKindDto::Shared,
}
}
}
/// One row in `GET /api/drives` — a drive the caller can read.
///
/// `default_for_user` is `Some(<caller_id>)` for the caller's default
/// Personal drive and `None` otherwise. The picker UI uses this to put
/// the default at the top of the list and mark it as "your home".
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct DriveDto {
pub id: Uuid,
/// Display name. Sourced from `storage.folders.name` of the row
/// pointed at by `root_folder_id` (drives have no `name` column —
/// see docs/plan/drive.md §3). The wire shape is unchanged from
/// the client's perspective.
pub name: String,
pub kind: DriveKindDto,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_for_user: Option<Uuid>,
/// The drive's mount-point folder. Folder API calls
/// (`POST /api/folders { parent_id: <root_folder_id> }`,
/// `PATCH /api/folders/<root_folder_id>` to rename) use this id —
/// no polymorphic "create at drive root" surface needed.
pub root_folder_id: Uuid,
/// Storage cap in bytes. `None` means "no quota" (admin override /
/// future system drives). Mutation is OxiCloud-admin only — drive
/// owners cannot self-grant capacity.
#[serde(skip_serializing_if = "Option::is_none")]
pub quota_bytes: Option<i64>,
/// Running total of bytes consumed. Maintained incrementally in D4;
/// on D0 this reflects the backfilled baseline.
pub used_bytes: i64,
/// Capability-flag bag — clients render UI affordances based on
/// known keys (`forbid_public_links`, `include_in_photo_index`,
/// `forbid_music_index`, …). Unknown keys preserved verbatim.
pub policies: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<DriveWithRootName> for DriveDto {
fn from(d: DriveWithRootName) -> Self {
Self {
id: d.drive.id,
name: d.root_folder_name,
kind: d.drive.kind.into(),
default_for_user: d.drive.default_for_user,
root_folder_id: d.drive.root_folder_id,
quota_bytes: d.drive.quota_bytes,
used_bytes: d.drive.used_bytes,
policies: d.drive.policies,
created_at: d.drive.created_at,
updated_at: d.drive.updated_at,
}
}
}
+18
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::domain::entities::file::File;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
@@ -75,6 +76,19 @@ pub struct FileDto {
/// through `If-Match` / `If-None-Match` on download / mutation
/// endpoints without a separate HEAD round-trip.
pub etag: String,
/// §14 provenance: user that originally created this file.
/// `None` when the referenced user has been deleted (FK is
/// `ON DELETE SET NULL`) or for stub/legacy files.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
/// §14 provenance: user that performed the most recent mutation
/// that bumped `updated_at`. Authorship signal — distinct from
/// `owner_id`. `None` when the referenced user is deleted or for
/// stub/legacy files.
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
}
impl From<File> for FileDto {
@@ -114,6 +128,8 @@ impl From<File> for FileDto {
sort_date: None,
content_hash,
etag,
created_by: parts.created_by,
updated_by: parts.updated_by,
}
}
}
@@ -171,6 +187,8 @@ impl FileDto {
content_hash: String::new(),
etag: String::new(),
sort_date: None,
created_by: None,
updated_by: None,
}
}
}
+25
View File
@@ -52,6 +52,12 @@ pub struct FolderDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
/// Drive that owns this folder. The scope axis for path-based
/// lookups across REST / WebDAV / NextCloud / CalDAV / CardDAV.
/// Post-D0 `storage.folders.drive_id` is `NOT NULL`; stub /
/// DTO-reconstructed folders carry `Uuid::nil()`.
pub drive_id: Uuid,
/// Creation timestamp
pub created_at: u64,
@@ -80,6 +86,19 @@ pub struct FolderDto {
/// pass it back through `If-Match` on rename / move endpoints
/// without a separate HEAD round-trip.
pub etag: String,
/// §14 provenance: user that originally created this folder.
/// `None` when the referenced user has been deleted (FK is
/// `ON DELETE SET NULL`) or for stub/legacy folders.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
/// §14 provenance: user that performed the most recent mutation
/// that bumped `updated_at`. Authorship signal — distinct from
/// `owner_id`. `None` when the referenced user is deleted or for
/// stub/legacy folders.
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
}
impl From<Folder> for FolderDto {
@@ -93,6 +112,7 @@ impl From<Folder> for FolderDto {
path: folder.path_string().to_string(),
parent_id: folder.parent_id().map(String::from),
owner_id: folder.owner_id().map(|u| u.to_string()),
drive_id: folder.drive_id(),
created_at: folder.created_at(),
modified_at: folder.modified_at(),
is_root,
@@ -100,6 +120,8 @@ impl From<Folder> for FolderDto {
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
etag,
created_by: folder.created_by(),
updated_by: folder.updated_by(),
}
}
}
@@ -144,6 +166,7 @@ impl FolderDto {
path: "/stub/path".to_string(),
parent_id: None,
owner_id: None,
drive_id: Uuid::nil(),
created_at: 0,
modified_at: 0,
is_root: true,
@@ -151,6 +174,8 @@ impl FolderDto {
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
etag: String::new(),
created_by: None,
updated_by: None,
}
}
}
+3
View File
@@ -58,6 +58,7 @@ impl From<Subject> for SubjectDto {
pub enum ResourceTypeDto {
Folder,
File,
Drive,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -72,6 +73,7 @@ impl From<ResourceDto> for Resource {
match dto.kind {
ResourceTypeDto::Folder => Resource::Folder(dto.id),
ResourceTypeDto::File => Resource::File(dto.id),
ResourceTypeDto::Drive => Resource::Drive(dto.id),
}
}
}
@@ -81,6 +83,7 @@ impl From<Resource> for ResourceDto {
let (kind, id) = match r {
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
Resource::File(id) => (ResourceTypeDto::File, id),
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
};
ResourceDto { kind, id }
}
+1
View File
@@ -6,6 +6,7 @@ pub mod calendar_dto;
pub mod contact_dto;
pub mod device_auth_dto;
pub mod display_helpers;
pub mod drive_dto;
pub mod favorites_dto;
pub mod file_dto;
pub mod folder_dto;
@@ -61,6 +61,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
Resource::Drive(id) => ("Drive", id),
};
// Audit-worthy: denials are the interesting signal. Routed
// through the `audit` tracing target so log aggregators can
+17 -5
View File
@@ -35,14 +35,26 @@ pub struct ContentHitDto {
/// holds an `Option<Arc<dyn ContentIndexPort>>` (the feature is toggleable).
#[async_trait]
pub trait ContentIndexPort: Send + Sync + 'static {
/// Search indexed file names + content for `query`, scoped to `user_id`.
/// Search indexed file names + content for `query`, scoped to the drives
/// the caller can read.
///
/// Returns up to `limit` hits sorted by BM25 score descending. Matching is
/// tokenized (not substring): exact terms, typo-tolerant fuzzy terms
/// (edit distance 1) and prefix expansion on the last query token.
/// The filter is applied as an `Occur::Must` set-membership clause on
/// the `drive_id` field — Tantivy's collector only ever sees documents
/// in one of the accessible drives, so counts, snippets, and
/// pagination cursors all reflect the filtered set (no anti-
/// enumeration leak — see `docs/plan/drive.md` §11). Pass the
/// caller's full accessible-drive set; the engine already expands
/// group-mediated drive grants before this is called.
///
/// An empty `accessible_drive_ids` returns no hits — same semantics
/// as "no drives, no search" (e.g. external users with grants only).
/// Returns up to `limit` hits sorted by BM25 score descending.
/// Matching is tokenized (not substring): exact terms, typo-tolerant
/// fuzzy terms (edit distance 1) and prefix expansion on the last
/// query token.
async fn search_content(
&self,
user_id: Uuid,
accessible_drive_ids: &[Uuid],
query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError>;
+28 -2
View File
@@ -44,24 +44,45 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
/// Register a new file row pointing at an already-ingested blob.
///
/// Takes ownership of the blob's reference (released on failure).
///
/// `caller_id` is plumbed down into
/// `FileWritePort::save_file_with_blob` so the §14 `created_by` /
/// `updated_by` columns record the principal performing the upload —
/// not the parent folder's owner. D2 shared drives surface this
/// most clearly: Adam upload into Alice's folder must record
/// `created_by = adam.id`.
async fn upload_file_streaming(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
blob: StoredBlob,
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// Replace the content of the file at `path` with an already-ingested
/// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT).
///
/// Takes ownership of the blob's reference (released on failure).
///
/// `drive_id` scopes both the existence probe (`find_file_by_path`)
/// and the parent-folder resolution (`get_parent_folder_id`) — the
/// handler is responsible for deriving it from its protocol context
/// (NC chroot, native default-drive lookup, WOPI default-drive).
///
/// `caller_id` is plumbed down into
/// `FileWritePort::update_file_content_with_blob` so the §14
/// `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(
&self,
path: &str,
drive_id: Uuid,
blob: StoredBlob,
content_type: &str,
modified_at: Option<i64>,
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
}
@@ -104,8 +125,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// Gets a file by its path (for WebDAV)
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
/// Gets a file by its path (for WebDAV), scoped to a drive.
///
/// Post-D0, `storage.files.path` is unique only within a single
/// drive. The `drive_id` filter scopes the lookup to a specific
/// drive (caller derives it from its protocol context: NC chroot,
/// native default-drive lookup, WOPI default-drive lookup).
async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result<FileDto, DomainError>;
/// Lists files in a folder
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
+14 -9
View File
@@ -36,8 +36,20 @@ pub trait FolderUseCase: Send + Sync + 'static {
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Gets a folder by its path
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
/// Gets a folder by its path within the caller's tree.
///
/// Scoped by `drive_id` because `storage.folders.path` is unique
/// only within a single drive after D0 — multiple drives (whether
/// owned by the same user or different users) share names like
/// `"Personal"` for their root folder (docs/plan/drive.md §10).
/// Pre-D0 the wrapper name embedded the username; post-D0 the
/// caller derives a `drive_id` from its protocol context (NC
/// chroot, native default-drive lookup, WOPI default-drive).
async fn get_folder_by_path(
&self,
path: &str,
drive_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
@@ -84,13 +96,6 @@ pub trait FolderUseCase: Send + Sync + 'static {
/// Deletes a folder (ownership verified against caller_id)
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Creates a root-level home folder for a user during registration.
async fn create_home_folder(
&self,
user_id: Uuid,
name: String,
) -> Result<FolderDto, DomainError>;
/// Lists every folder in a subtree rooted at `folder_id` (inclusive),
/// ordered by path. Uses ltree `<@` — single GiST-indexed query.
///
+73 -13
View File
@@ -82,11 +82,25 @@ pub trait FileReadPort: Send + Sync + 'static {
/// Gets the logical storage path of a file.
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
/// Gets the parent folder ID from a path (WebDAV).
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
/// Gets the parent folder ID from a path (WebDAV), scoped to a drive.
///
/// Post-D0, `storage.folders.path` is unique only within a single
/// drive. The `drive_id` filter scopes the lookup to a specific
/// drive (caller derives it from its protocol context: NC chroot,
/// native default-drive lookup, WOPI default-drive lookup).
async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid)
-> Result<String, DomainError>;
/// Gets a folder ID by its path.
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError>;
/// Gets a folder ID by its path, scoped to a drive.
///
/// Post-D0 same scoping rule as `get_parent_folder_id` — names like
/// `"Personal"` repeat across drives, so the `drive_id` filter is
/// required to disambiguate.
async fn get_folder_id_by_path(
&self,
folder_path: &str,
drive_id: Uuid,
) -> Result<String, DomainError>;
/// Gets the content-addressable blob hash for a file (O(1) DB lookup).
///
@@ -94,11 +108,22 @@ pub trait FileReadPort: Send + Sync + 'static {
/// Used for dedup reference tracking without loading file content.
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError>;
/// Find a file by its logical path (folder_name/.../file_name).
/// Find a file by its logical path (folder_name/.../file_name),
/// scoped to a drive.
///
/// Post-D0 `storage.files.path` is unique only within a single
/// drive. The `drive_id` filter prevents non-deterministic
/// resolution when the same path exists in multiple drives.
///
/// The default implementation falls back to `list_files(None)` + linear
/// scan (O(N)). Repositories should override with a direct SQL query.
async fn find_file_by_path(&self, path: &str) -> Result<Option<File>, DomainError> {
/// scan (O(N)) and ignores the drive filter — only used by stubs.
/// Repositories should override with a direct SQL query that applies
/// the filter.
async fn find_file_by_path(
&self,
path: &str,
_drive_id: Uuid,
) -> Result<Option<File>, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
let all_files = self.list_files(None).await?;
for file in all_files {
@@ -259,6 +284,13 @@ pub trait FileWritePort: Send + Sync + 'static {
///
/// Takes ownership of one blob reference: on any failure the reference
/// is released before the error is returned.
///
/// `caller_id` is stamped into both `created_by` and `updated_by`
/// (§14 provenance — authorship belongs to the caller, not to the
/// parent folder's owner). In D2 shared drives, a non-owner member
/// can upload into a folder owned by someone else; the previous
/// `created_by = parent.user_id` would have silently recorded the
/// wrong principal.
async fn save_file_with_blob(
&self,
name: String,
@@ -266,17 +298,29 @@ pub trait FileWritePort: Send + Sync + 'static {
content_type: String,
blob_hash: &str,
size: u64,
caller_id: Uuid,
) -> Result<File, DomainError>;
/// Moves a file to another folder.
/// Moves a file to another folder. `caller_id` is stamped into
/// `updated_by` alongside the `updated_at = NOW()` bump
/// (§14 provenance — authorship belongs to the caller, not to
/// the destination folder's owner).
async fn move_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
caller_id: Uuid,
) -> Result<File, DomainError>;
/// Renames a file (same folder, different name).
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError>;
/// Renames a file (same folder, different name). `caller_id` is
/// stamped into `updated_by` alongside the `updated_at = NOW()`
/// bump (§14 provenance).
async fn rename_file(
&self,
file_id: &str,
new_name: &str,
caller_id: Uuid,
) -> Result<File, DomainError>;
/// Deletes a file.
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
@@ -290,24 +334,32 @@ pub trait FileWritePort: Send + Sync + 'static {
/// Returns `(new_blob_hash, updated_at_epoch)` — everything a caller
/// needs to rebuild the fresh entity/ETag from a `File` it already
/// holds, without re-reading the row it just updated.
///
/// `caller_id` is stamped into `updated_by` alongside the
/// `updated_at` bump (§14 provenance).
async fn update_file_content_with_blob(
&self,
file_id: &str,
blob_hash: &str,
size: u64,
modified_at: Option<i64>,
caller_id: Uuid,
) -> Result<(String, i64), DomainError>;
/// Registers file metadata WITHOUT writing content to disk (write-behind).
///
/// Returns `(File, PathBuf)` where `PathBuf` is the destination path for the
/// deferred write that the `WriteBehindCache` will perform.
///
/// `caller_id` is stamped into both `created_by` and `updated_by`
/// (§14 provenance — see `save_file_with_blob`).
async fn register_file_deferred(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
size: u64,
caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError>;
/// Copies a file to a (possibly different) folder.
@@ -319,11 +371,16 @@ pub trait FileWritePort: Send + Sync + 'static {
/// the same folder always collides on the source's filename. WebDAV
/// COPY uses this for the "same folder, different name" case (the
/// classic `COPY /a.txt → /b.txt` pattern).
///
/// `caller_id` is stamped into both `created_by` and `updated_by`
/// on the new row (§14 provenance — the caller authored this copy,
/// not the destination folder's owner).
async fn copy_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
new_name: Option<&str>,
caller_id: Uuid,
) -> Result<File, DomainError>;
/// Copies an entire folder subtree atomically using ltree.
@@ -350,14 +407,17 @@ pub trait FileWritePort: Send + Sync + 'static {
// ── Trash operations ──
/// Moves a file to the trash
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>;
/// Moves a file to the trash. `caller_id` is stamped into
/// `updated_by` (§14 provenance).
async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Restores a file from the trash to its original location
/// Restores a file from the trash to its original location.
/// `caller_id` is stamped into `updated_by` (§14 provenance).
async fn restore_from_trash(
&self,
file_id: &str,
original_path: &str,
caller_id: Uuid,
) -> Result<(), DomainError>;
/// Permanently deletes a file (used by the trash)
+1 -1
View File
@@ -127,7 +127,7 @@ pub enum LogoutReason {
/// How aggressively `on_user_deleted` cleanup should run. Today both
/// variants are equivalent (only `AuditLifecycleHook` exists, and it logs
/// regardless). The split exists so PR 4's `HomeFolderLifecycleHook` can
/// regardless). The split exists so PR 4's `PersonalDriveLifecycleHook` can
/// trash on `AdminDelete` but hard-delete on `GdprPurge`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeletionMode {
@@ -124,7 +124,7 @@ pub struct AuthApplicationService {
token_service: Arc<JwtTokenService>,
/// Dispatcher for user-lifecycle events. `None` only in tests that don't
/// exercise the lifecycle path; production DI always wires this.
/// HomeFolderLifecycleHook (registered on this dispatcher) owns the
/// PersonalDriveLifecycleHook (registered on this dispatcher) owns the
/// per-user folder provisioning that AuthApplicationService used to do
/// inline pre-PR 3.
user_lifecycle: Option<Arc<UserLifecycleService>>,
@@ -404,7 +404,7 @@ impl AuthApplicationService {
// Save user
let created_user = self.user_storage.create_user(user).await?;
// Lifecycle: HomeFolderLifecycleHook handles personal-folder
// Lifecycle: PersonalDriveLifecycleHook handles personal-folder
// creation (was inlined here pre-PR 3); audit log + future
// provisioning steps land here too.
if let Some(lc) = &self.user_lifecycle {
@@ -506,8 +506,8 @@ impl AuthApplicationService {
let created_user = self.user_storage.create_user(user).await?;
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
// HomeFolderLifecycleHook fired here.
// Lifecycle: HomeFolderLifecycleHook provisions the admin's
// PersonalDriveLifecycleHook fired here.
// Lifecycle: PersonalDriveLifecycleHook provisions the admin's
// home folder. Audit logs the creation event.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created_user).await;
@@ -654,7 +654,7 @@ impl AuthApplicationService {
/// A second redemption attempt receives `Ok(false)` and is rejected
/// as `AccessDenied`.
/// 3. Load the user, verify they're active.
/// 4. Dispatch `on_user_login` (so HomeFolderLifecycleHook can
/// 4. Dispatch `on_user_login` (so PersonalDriveLifecycleHook can
/// safety-net any internal user whose first credential happens
/// to be a magic link — externals short-circuit by `is_external()`).
/// 5. Register login + persist + issue session in the same pipeline
@@ -1722,7 +1722,7 @@ impl AuthApplicationService {
.await?;
}
// Lifecycle: HomeFolderLifecycleHook handles the home-folder
// Lifecycle: PersonalDriveLifecycleHook handles the home-folder
// provisioning (idempotent + short-circuits on is_external).
// Audit logs the creation event.
if let Some(lc) = &self.user_lifecycle {
@@ -1785,7 +1785,7 @@ impl AuthApplicationService {
/// Runs the whole flow in a single transaction so the lifecycle
/// hooks (`SessionRevocationLifecycleHook` revoking sessions with
/// audit, `AuthzCacheLifecycleHook` invalidating the Moka cache,
/// `HomeFolderLifecycleHook` for future trash policy, …) can do
/// `PersonalDriveLifecycleHook` for future trash policy, …) can do
/// their work atomically with the user DELETE. If any hook returns
/// `Err`, the transaction rolls back and the user remains intact.
pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> {
@@ -2260,7 +2260,7 @@ impl AuthApplicationService {
// Lifecycle: created (audit + home-folder provisioning) +
// login (no register_login() for a fresh OIDC user means
// `last_login_at` is naturally None → first-login detection
// works). HomeFolderLifecycleHook creates the home folder.
// works). PersonalDriveLifecycleHook creates the home folder.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created_user).await;
lc.dispatch_login(&created_user).await;
@@ -2362,7 +2362,7 @@ impl AuthApplicationService {
// `create_personal_folder` was removed in PR 3 of the
// UserLifecycleHook migration — home-folder provisioning is now
// owned by `HomeFolderLifecycleHook` in folder_service.rs and runs
// owned by `PersonalDriveLifecycleHook` in folder_service.rs and runs
// via `dispatch_created` / `dispatch_login`.
}
@@ -623,6 +623,7 @@ impl DeltaUploadService {
Some(folder_id.clone()),
content_type,
blob,
caller_id,
)
.await
}
@@ -98,6 +98,7 @@ impl FileManagementService {
&self,
file_id: &str,
folder_id: Option<String>,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
info!(
"Moving file with ID: {} to folder: {:?}",
@@ -106,7 +107,7 @@ impl FileManagementService {
let moved_file = self
.file_repository
.move_file(file_id, folder_id)
.move_file(file_id, folder_id, caller_id)
.await
.map_err(|e| {
error!("Error moving file (ID: {}): {}", file_id, e);
@@ -128,6 +129,7 @@ impl FileManagementService {
file_id: &str,
target_folder_id: Option<String>,
new_name: Option<&str>,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
info!(
"Copying file with ID: {} to folder: {:?} as {:?}",
@@ -136,7 +138,7 @@ impl FileManagementService {
let copied_file = self
.file_repository
.copy_file(file_id, target_folder_id, new_name)
.copy_file(file_id, target_folder_id, new_name, caller_id)
.await
.map_err(|e| {
error!("Error copying file (ID: {}): {}", file_id, e);
@@ -157,7 +159,12 @@ impl FileManagementService {
Ok(dto)
}
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError> {
async fn rename_file(
&self,
file_id: &str,
new_name: &str,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
if let Err(reason) = validate_storage_name(new_name) {
return Err(DomainError::validation_error(format!(
"Invalid file name '{new_name}': {reason}"
@@ -168,7 +175,7 @@ impl FileManagementService {
let renamed_file = self
.file_repository
.rename_file(file_id, new_name)
.rename_file(file_id, new_name, caller_id)
.await
.map_err(|e| {
error!("Error renaming file (ID: {}): {}", file_id, e);
@@ -253,7 +260,7 @@ impl FileManagementUseCase for FileManagementService {
.await?;
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
.await?;
self.move_file(file_id, folder_id).await
self.move_file(file_id, folder_id, caller_id).await
}
async fn copy_file_with_perms(
@@ -268,7 +275,7 @@ impl FileManagementUseCase for FileManagementService {
.await?;
self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id)
.await?;
self.copy_file(file_id, target_folder_id, new_name.as_deref())
self.copy_file(file_id, target_folder_id, new_name.as_deref(), caller_id)
.await
}
@@ -280,7 +287,7 @@ impl FileManagementUseCase for FileManagementService {
) -> Result<FileDto, DomainError> {
self.require_file_perm(file_id, Permission::Update, caller_id)
.await?;
self.rename_file(file_id, new_name).await
self.rename_file(file_id, new_name, caller_id).await
}
async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
@@ -286,13 +286,16 @@ impl FileRetrievalUseCase for FileRetrievalService {
}
// FIXME no authorisation at all
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result<FileDto, DomainError> {
// Direct SQL lookup — O(folder_depth) queries instead of O(total_files)
// NOTE: This method does NOT perform any authorization check. Callers
// that surface its result to a user-driven request MUST resolve the
// file via get_file_owned afterwards, or call authz.require directly.
// (Tracked in the audit punch-list under "path-based lookups".)
if let Some(file) = self.file_read.find_file_by_path(path).await? {
// `drive_id` scope axis prevents cross-drive resolution — without
// it, `find_file_by_path` would return a non-deterministic row
// when the same path exists in multiple drives.
if let Some(file) = self.file_read.find_file_by_path(path, drive_id).await? {
return Ok(FileDto::from(file));
}
@@ -209,6 +209,7 @@ impl FileUploadService {
size: metadata.size,
is_new_blob: false,
},
caller_id,
)
.await?;
@@ -255,7 +256,7 @@ impl FileUploadService {
let file = file_read.get_file(file_id).await?;
let (new_hash, updated_at) = self
.file_write
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None)
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id)
.await?;
// The file maps to a different blob now — stale cached content must
// never be served for the rest of its TTI window.
@@ -326,10 +327,18 @@ impl FileUploadUseCase for FileUploadService {
folder_id: Option<String>,
content_type: String,
blob: StoredBlob,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
let file = self
.file_write
.save_file_with_blob(name.clone(), folder_id, content_type, &blob.hash, blob.size)
.save_file_with_blob(
name.clone(),
folder_id,
content_type,
&blob.hash,
blob.size,
caller_id,
)
.await?;
let dto = FileDto::from(file);
info!(
@@ -348,18 +357,26 @@ impl FileUploadUseCase for FileUploadService {
async fn update_file_streaming(
&self,
path: &str,
drive_id: Uuid,
blob: StoredBlob,
content_type: &str,
modified_at: Option<i64>,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
// 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).await?
&& let Some(file) = file_read.find_file_by_path(path, drive_id).await?
{
let file_id = file.id().to_string();
let (new_hash, updated_at) = self
.file_write
.update_file_content_with_blob(&file_id, &blob.hash, blob.size, modified_at)
.update_file_content_with_blob(
&file_id,
&blob.hash,
blob.size,
modified_at,
caller_id,
)
.await?;
// Invalidate content cache — file content has changed.
if let Some(cc) = &self.content_cache {
@@ -402,9 +419,15 @@ impl FileUploadUseCase for FileUploadService {
// get_parent_folder_id expects the full file path — it strips the
// last segment (filename) internally to find the parent folder.
// `drive_id` scopes the parent lookup to the same drive as the
// incoming write (post-D0 `storage.folders.path` repeats across
// drives).
let parent_id = if path_normalized.contains('/') {
if let Some(file_read) = &self.file_read {
file_read.get_parent_folder_id(path_normalized).await.ok()
file_read
.get_parent_folder_id(path_normalized, drive_id)
.await
.ok()
} else {
None
}
@@ -421,6 +444,7 @@ impl FileUploadUseCase for FileUploadService {
content_type.to_string(),
&blob.hash,
blob.size,
caller_id,
)
.await?;
let dto = FileDto::from(created);
+119 -119
View File
@@ -82,7 +82,11 @@ impl FolderService {
Ok(FolderDto::empty())
}
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
async fn get_folder_by_path(
&self,
_path: &str,
_drive_id: Uuid,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
@@ -163,14 +167,6 @@ impl FolderService {
) -> Result<(), DomainError> {
Ok(())
}
async fn create_home_folder(
&self,
_user_id: Uuid,
_name: String,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
}
FolderServiceStub
@@ -229,31 +225,11 @@ impl FolderUseCase for FolderService {
let folder = self
.folder_storage
.create_folder(dto.name, dto.parent_id)
.create_folder(dto.name, dto.parent_id, caller_id)
.await?;
Ok(FolderDto::from(folder))
}
/// Creates a root-level home folder for a user during registration.
async fn create_home_folder(
&self,
user_id: Uuid,
name: String,
) -> Result<FolderDto, DomainError> {
let folder = self
.folder_storage
.create_home_folder(user_id, name)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("Failed to create home folder: {}", e),
)
})?;
Ok(FolderDto::from(folder))
}
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<FolderDto>, DomainError> {
let folders = self.folder_storage.list_subtree_folders(folder_id).await?;
Ok(folders.into_iter().map(FolderDto::from).collect())
@@ -288,14 +264,17 @@ impl FolderUseCase for FolderService {
self.get_folder(id).await
}
/// Gets a folder by its path
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError> {
// Convert the string path to StoragePath
/// Gets a folder by its path, scoped to a drive.
async fn get_folder_by_path(
&self,
path: &str,
drive_id: Uuid,
) -> Result<FolderDto, DomainError> {
let storage_path = StoragePath::from_string(path);
let folder = self
.folder_storage
.get_folder_by_path(&storage_path)
.get_folder_by_path(&storage_path, drive_id)
.await
.map_err(|e| {
DomainError::internal_error(
@@ -329,7 +308,7 @@ impl FolderUseCase for FolderService {
///
/// **Note (post PR 3):** the self-heal block that auto-created a
/// home folder when listing returned empty has been removed.
/// `HomeFolderLifecycleHook` (registered on `UserLifecycleService`)
/// `PersonalDriveLifecycleHook` (registered on `UserLifecycleService`)
/// now provisions the folder on `on_user_created` / `on_user_login`,
/// idempotently, so the listing path no longer needs to self-heal.
async fn list_folders_with_perms(
@@ -477,7 +456,7 @@ impl FolderUseCase for FolderService {
let folder = self
.folder_storage
.rename_folder(id, dto.name)
.rename_folder(id, dto.name, caller_id)
.await
.map_err(|e| {
DomainError::internal_error(
@@ -529,7 +508,7 @@ impl FolderUseCase for FolderService {
let parent_ref = dto.parent_id.as_deref();
let folder = self
.folder_storage
.move_folder(id, parent_ref)
.move_folder(id, parent_ref, caller_id)
.await
.map_err(|e| {
DomainError::internal_error(
@@ -614,62 +593,6 @@ impl FolderService {
Ok((rows, next_cursor))
}
/// Idempotently provision a home folder for a user.
///
/// Returns `Ok(true)` if a folder was newly created, `Ok(false)` if the
/// user already had at least one root folder.
///
/// **System-level operation** — bypasses authz because this runs on
/// the user's own behalf (during creation or login provisioning) at a
/// point where the caller may be the engine itself, not an HTTP user.
/// Callers must be inside trusted code paths (lifecycle hooks).
///
/// Used by [`HomeFolderLifecycleHook`] on `on_user_created` and
/// `on_user_login`. Replaces the old self-heal at the listing path
/// and the four eager `create_personal_folder` calls in
/// `AuthApplicationService` (removed in the same PR).
pub async fn ensure_home_folder(
&self,
user_id: Uuid,
username: Option<&str>,
) -> Result<bool, DomainError> {
let existing = self
.folder_storage
.list_folders_by_owner(None, user_id)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("ensure_home_folder: list root folders: {}", e),
)
})?;
if !existing.is_empty() {
return Ok(false);
}
let folder_name = match username {
Some(u) => format!("My Folder - {}", u),
None => format!("My Folder - {}", user_id),
};
self.folder_storage
.create_home_folder(user_id, folder_name.clone())
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("ensure_home_folder: create: {}", e),
)
})?;
tracing::info!(
target: "user_lifecycle",
hook = "home_folder",
user_id = %user_id,
folder_name = %folder_name,
"Home folder provisioned"
);
Ok(true)
}
}
/// Build the next-page cursor from the last row of the current page.
@@ -725,7 +648,7 @@ fn build_folder_resource_cursor(
}
// ─────────────────────────────────────────────────────────────────────────────
// HomeFolderLifecycleHook
// PersonalDriveLifecycleHook
//
// Owns home-folder provisioning policy. Replaces:
// - the 4 eager `create_personal_folder` calls in AuthApplicationService
@@ -743,36 +666,115 @@ use async_trait::async_trait;
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
use crate::domain::entities::user::User;
/// Lifecycle hook: provisions and (in PR 4) deprovisions a user's home folder.
pub struct HomeFolderLifecycleHook {
folder_service: Arc<FolderService>,
/// Lifecycle hook: provisions a user's default Personal drive at first
/// login (replaces the legacy `My Folder - <username>` wrapper as of D0).
///
/// Two writes happen on first provisioning:
/// 1. A row in `storage.drives` with `kind='personal'`,
/// `default_for_user=<uid>`, and the user's quota carried over from
/// `auth.users.storage_quota_bytes`.
/// 2. An Owner role grant in `storage.role_grants` so the user can
/// read/write/manage their own drive (the engine's owner short-
/// circuit applies to folders/files but not drives — see
/// `pg_acl_engine::check_inner` D0-6 rewrite).
///
/// Both writes are idempotent: `find_default_for_user` short-circuits
/// when the drive already exists; `set_role` is an UPSERT that no-ops
/// when the Owner row is already present.
pub struct PersonalDriveLifecycleHook {
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
// The `AuthorizationEngine` trait isn't `dyn`-compatible (native
// async-fn-in-trait methods are not object-safe), so we hold the
// concrete engine. This matches the convention already used by
// `AppState.authorization`. Only the idempotent-rerun path uses it
// now; the create path goes through the repo's atomic CTE which
// writes the role_grant inline.
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
}
impl HomeFolderLifecycleHook {
pub fn new(folder_service: Arc<FolderService>) -> Self {
Self { folder_service }
impl PersonalDriveLifecycleHook {
pub fn new(
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
) -> Self {
Self {
drive_repo,
authorization,
}
}
/// Idempotent provisioning shared by `on_user_created` and
/// `on_user_login`. External users are skipped per tip #2 in the
/// trait docstring.
/// trait docstring — they have no resources of their own, only
/// grants on other users' resources.
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
use crate::domain::repositories::drive_repository::DriveRepositoryError;
use crate::domain::services::authorization::{Resource, Role, Subject};
if user.is_external() {
return Ok(());
}
// `ensure_home_folder` handles the "does the user already have a
// root folder?" check internally and is a no-op if so.
self.folder_service
.ensure_home_folder(user.id(), user.username())
// Idempotent shortcut: if the user already has a default drive,
// the atomic CTE already ran on a prior turn. The CTE writes
// the Owner role_grant inline, so there's nothing to repair —
// but we still re-emit the grant via `set_role` (UPSERT-safe)
// to cover the historical case where a pre-CTE provisioning
// path partially completed (drive created, grant missing).
match self.drive_repo.find_default_for_user(user.id()).await {
Ok(drive_with_name) => {
self.authorization
.set_role(
user.id(),
Subject::User(user.id()),
Role::Owner,
Resource::Drive(drive_with_name.drive.id),
None,
)
.await
.map(|_grant| ())?;
return Ok(());
}
Err(DriveRepositoryError::NotFound(_)) => { /* fall through to create */ }
Err(e) => {
return Err(DomainError::internal_error(
"PersonalDriveHook",
format!("find_default lookup: {e}"),
));
}
}
// One atomic CTE — drive row + root folder ("Personal",
// parent_id=NULL, drive_id pinned) + drives.root_folder_id
// wire-up + Owner role_grant. Single SQL statement, atomic
// against server crash mid-sequence (docs/plan/drive.md §3).
let drive_with_name = self
.drive_repo
.create_personal_drive_atomic(user.id(), Some(user.storage_quota_bytes()))
.await
.map(|_created| ())
.map_err(|e| {
DomainError::internal_error(
"PersonalDriveHook",
format!("create_personal_drive_atomic: {e}"),
)
})?;
tracing::info!(
target: "user_lifecycle",
hook = "personal_drive",
user_id = %user.id(),
drive_id = %drive_with_name.drive.id,
root_folder_id = %drive_with_name.drive.root_folder_id,
"Default personal drive + root folder + owner grant provisioned (atomic CTE)"
);
Ok(())
}
}
#[async_trait]
impl UserLifecycleHook for HomeFolderLifecycleHook {
impl UserLifecycleHook for PersonalDriveLifecycleHook {
fn name(&self) -> &'static str {
"home_folder"
"personal_drive"
}
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
@@ -787,7 +789,7 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
// Folders don't react to logout. Explicit no-op per the
// Drives don't react to logout. Explicit no-op per the
// "no defaults" convention.
Ok(())
}
@@ -798,24 +800,22 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
mode: DeletionMode,
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
// For both DeletionMode variants today the FK CASCADE on
// `storage.folders.user_id` (and downstream files/blobs)
// removes the home folder + contents when the user row goes.
// `storage.drives.default_for_user` has ON DELETE CASCADE
// referencing `auth.users(id)`, and `storage.folders.drive_id`
// / `storage.files.drive_id` both have ON DELETE CASCADE on
// `storage.drives(id)` (M3). So a user delete cascades:
// user → drive → folders → files in one transaction.
//
// The hook emits a per-mode tracing event so audit can tell
// AdminDelete (currently recoverable only via DB-level rollback
// before commit) from GdprPurge (no sweeper exists yet — the
// variant is reserved for a future PR that adds retention).
//
// The `tx` is provided per the trait contract but unused here:
// emitting a tracing event doesn't require DB access. Future
// policy (trash with retention) would write to `storage.trash`
// inside this same tx.
tracing::info!(
target: "user_lifecycle",
hook = "home_folder",
hook = "personal_drive",
user_id = %user.id(),
mode = ?mode,
"Home folder will be removed via FK CASCADE on user delete"
"Personal drive (and tree) will be removed via FK CASCADE on user delete"
);
Ok(())
}
@@ -101,7 +101,11 @@ impl FileReadPort for MockFileReadPort {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
unimplemented!()
}
@@ -127,7 +131,11 @@ impl FileReadPort for MockFileReadPort {
Ok(0)
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
unimplemented!()
}
@@ -176,6 +184,7 @@ impl FileWritePort for MockFileWritePort {
_content_type: String,
_blob_hash: &str,
_size: u64,
_caller_id: Uuid,
) -> Result<File, DomainError> {
unimplemented!()
}
@@ -184,6 +193,7 @@ impl FileWritePort for MockFileWritePort {
&self,
file_id: &str,
_target_folder_id: Option<String>,
_caller_id: Uuid,
) -> Result<File, DomainError> {
let files = self.files.lock().unwrap();
files
@@ -192,7 +202,12 @@ impl FileWritePort for MockFileWritePort {
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
}
async fn rename_file(&self, file_id: &str, _new_name: &str) -> Result<File, DomainError> {
async fn rename_file(
&self,
file_id: &str,
_new_name: &str,
_caller_id: Uuid,
) -> Result<File, DomainError> {
let files = self.files.lock().unwrap();
files
.get(file_id)
@@ -210,6 +225,7 @@ impl FileWritePort for MockFileWritePort {
_blob_hash: &str,
_size: u64,
_modified_at: Option<i64>,
_caller_id: Uuid,
) -> Result<(String, i64), DomainError> {
Ok((String::new(), 0))
}
@@ -220,6 +236,7 @@ impl FileWritePort for MockFileWritePort {
_folder_id: Option<String>,
_content_type: String,
_size: u64,
_caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError> {
unimplemented!()
}
@@ -229,11 +246,12 @@ impl FileWritePort for MockFileWritePort {
_file_id: &str,
_target_folder_id: Option<String>,
_new_name: Option<&str>,
_caller_id: Uuid,
) -> Result<File, DomainError> {
unimplemented!()
}
async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> {
async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
Ok(())
}
@@ -241,6 +259,7 @@ impl FileWritePort for MockFileWritePort {
&self,
_file_id: &str,
_original_path: &str,
_caller_id: Uuid,
) -> Result<(), DomainError> {
Ok(())
}
@@ -307,6 +307,23 @@ impl MagicLinkInviteService {
let (kind, resource_id) = match resource {
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
Resource::File(id) => (MagicLinkResourceKind::File, id),
// Drive sharing — and therefore drive magic-link invitations —
// land in D2. The grant DTOs accept `Resource::Drive` from the
// wire today (see ResourceTypeDto) but no public API path
// actually grants on a drive in D0, so this arm is
// defensively unreachable. Treating it as an audit-logged
// no-op (grant is in place, mail suppressed) matches the
// ineligible-recipient branch above.
Resource::Drive(_) => {
tracing::info!(
target: "audit",
event = "magic_link.invitation_suppressed",
reason = "drive_resource_unsupported",
user_id = %recipient.id(),
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
);
return Ok(());
}
};
// Invitation tokens are cross-device by design (recipient has
// no prior browser context with the server) — no challenge
@@ -329,6 +346,11 @@ impl MagicLinkInviteService {
let kind_key = match resource {
Resource::Folder(_) => "server.magic_link.email.kind_folder",
Resource::File(_) => "server.magic_link.email.kind_file",
// Unreachable — the early-return above exits before we get
// here for a Drive resource. The arm exists only to satisfy
// exhaustiveness; if you find this firing, the early-return
// was bypassed.
Resource::Drive(_) => "server.magic_link.email.kind_folder",
};
// PR C: render in the recipient's preferred locale (set by UI
// switcher, OIDC JIT claim, or inviter inheritance at row
@@ -731,6 +753,15 @@ impl From<ResourceKind> for MagicLinkResourceKind {
match kind {
ResourceKind::Folder => Self::Folder,
ResourceKind::File => Self::File,
// Drives aren't a magic-link invite target in D0. The
// grant DTO surface accepts drive resources, but the
// grant_handler doesn't issue magic-links for them
// (drive sharing lands in D2). Mapping Drive → Folder
// gives a non-panicking fallback that would still emit a
// valid token shape if the path were ever reached; the
// runtime branches above suppress drive invitations
// before reaching this conversion.
ResourceKind::Drive => Self::Folder,
}
}
}
@@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rand_core::RngCore;
use uuid::Uuid;
/// Maximum number of concurrent pending login flows to prevent memory exhaustion.
const MAX_PENDING_FLOWS: usize = 1000;
@@ -30,6 +31,13 @@ pub struct LoginResult {
struct PendingFlow {
created_at: Instant,
poll_token: String,
/// Set after the user authenticates on the login page **and** has more
/// than one root drive — the flow is paused until the user picks a
/// drive on the picker page. Consumed by `take_pending_user` when the
/// picker submission arrives, so the second step is single-use even
/// if the flow token leaks. `None` for single-drive accounts (legacy
/// path goes straight to `completed`).
pending_user_id: Option<Uuid>,
completed: Option<LoginResult>,
}
@@ -80,6 +88,7 @@ impl NextcloudLoginFlowService {
PendingFlow {
created_at: Instant::now(),
poll_token: poll_token.clone(),
pending_user_id: None,
completed: None,
},
);
@@ -101,6 +110,39 @@ impl NextcloudLoginFlowService {
state.flows.contains_key(flow_token)
}
/// Stash a verified user_id on the flow so a follow-up drive-pick
/// request can prove "this browser just authenticated" without
/// asking for the password again. Returns `false` if the flow
/// token is unknown or expired.
///
/// Only used on multi-drive accounts — single-drive logins go
/// straight to [`complete`](Self::complete).
pub fn mark_awaiting_drive(&self, flow_token: &str, user_id: Uuid) -> bool {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
match state.flows.get_mut(flow_token) {
Some(pending) => {
pending.pending_user_id = Some(user_id);
true
}
None => false,
}
}
/// Consume the stashed user_id (single-use). Returns the user_id
/// when the flow is in "awaiting drive choice" state, or `None`
/// when the flow is unknown, expired, or was never marked. Single-
/// use semantics make this safe even if the flow token leaks: the
/// second drive-pick attempt finds nothing to consume.
pub fn take_pending_user(&self, flow_token: &str) -> Option<Uuid> {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
state
.flows
.get_mut(flow_token)
.and_then(|pending| pending.pending_user_id.take())
}
pub fn complete(
&self,
flow_token: &str,
@@ -257,6 +299,35 @@ mod tests {
assert!(svc.poll(&info.poll_token).is_none());
}
#[test]
fn test_mark_awaiting_drive_then_take_pending_user() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
let flow_token = info.login_url.rsplit('/').next().unwrap();
let uid = Uuid::new_v4();
assert!(svc.mark_awaiting_drive(flow_token, uid));
// First take consumes the slot.
assert_eq!(svc.take_pending_user(flow_token), Some(uid));
// Second take must return None (single-use).
assert_eq!(svc.take_pending_user(flow_token), None);
}
#[test]
fn test_mark_awaiting_drive_unknown_flow_returns_false() {
let svc = service();
assert!(!svc.mark_awaiting_drive("nonexistent", Uuid::new_v4()));
}
#[test]
fn test_take_pending_user_without_mark_returns_none() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
let flow_token = info.login_url.rsplit('/').next().unwrap();
// Flow exists but mark_awaiting_drive was never called.
assert_eq!(svc.take_pending_user(flow_token), None);
}
#[test]
fn test_max_pending_flows_cap() {
let svc = NextcloudLoginFlowService::new(Duration::from_secs(600));
+1 -1
View File
@@ -199,7 +199,7 @@ impl PeopleService {
})
})
.collect();
out.sort_by(|a, b| b.face_count.cmp(&a.face_count));
out.sort_by_key(|p| std::cmp::Reverse(p.face_count));
Ok(out)
}
@@ -472,6 +472,11 @@ impl RecipientNotificationService {
let kind_key = match resource {
Resource::Folder(_) => "server.magic_link.email.kind_folder",
Resource::File(_) => "server.magic_link.email.kind_file",
// Drives don't generate share notifications in D0 — drive
// sharing lands in D2 and gets its own template key. Fall
// back to the folder label so any path that does reach
// here produces a readable, if generic, mail body.
Resource::Drive(_) => "server.magic_link.email.kind_folder",
};
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
// Short form for the subject, long form (with email) for the
+92 -3
View File
@@ -50,6 +50,20 @@ pub struct SearchService {
/// matches; hits are hydrated and re-filtered through SQL before use.
content_index: Option<Arc<dyn ContentIndexPort>>,
/// Optional authorization engine — needed to resolve the caller's
/// accessible drive set before querying the content index, and to
/// re-verify each Tantivy hit against `engine.check(Read, File(id))`
/// as a defense-in-depth measure (catches index staleness and
/// per-file grants that the drive-only Tantivy filter misses; see
/// `docs/plan/drive.md` §11). `None` short-circuits the content
/// index (the cheapest safe degradation).
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
/// Optional drive repository — used in tandem with the authorization
/// engine to resolve the caller's accessible drives for the Tantivy
/// filter. `None` short-circuits the content index.
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
@@ -151,6 +165,8 @@ impl SearchService {
file_repository: Arc<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>,
content_index: Option<Arc<dyn ContentIndexPort>>,
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
cache_ttl: u64,
max_cache_size: usize,
) -> Self {
@@ -163,6 +179,8 @@ impl SearchService {
file_repository,
folder_repository,
content_index,
authorization,
drive_repo,
search_cache,
}
}
@@ -250,9 +268,18 @@ impl SearchService {
criteria: &SearchCriteriaDto,
user_id: Uuid,
) -> Vec<ContentHitDto> {
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::domain::services::authorization::{Permission, Resource, Subject};
let Some(index) = &self.content_index else {
return Vec::new();
};
let Some(authz) = &self.authorization else {
return Vec::new();
};
let Some(drive_repo) = &self.drive_repo else {
return Vec::new();
};
if criteria.offset != 0 {
return Vec::new();
}
@@ -265,16 +292,78 @@ impl SearchService {
return Vec::new();
};
match index
.search_content(user_id, query, CONTENT_HITS_LIMIT)
// Resolve the caller's accessible drive set via the engine
// (handles group-mediated drive grants) + the repo lookup.
let caller = Subject::User(user_id);
let (subject_types, subject_ids) = match authz.expand_subject_for_listing(caller).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!("Content-index: subject expansion failed — degrading to empty: {e}");
return Vec::new();
}
};
let accessible_drives: Vec<Uuid> = match drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
{
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
Err(e) => {
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
return Vec::new();
}
};
// Tantivy filter (Must drive_id ∈ accessible_drives) handles
// the cross-drive isolation. Empty drive list short-circuits
// inside `search_content`.
let hits = match index
.search_content(&accessible_drives, query, CONTENT_HITS_LIMIT)
.await
{
Ok(hits) => hits,
Err(e) => {
tracing::warn!("Content-index lookup failed — returning name-only results: {e}");
Vec::new()
return Vec::new();
}
};
// Defense in depth: re-verify each hit through the engine.
// Catches two cases the drive_id filter can't:
// * Index staleness — the file just moved drives and the
// worker hasn't caught up.
// * Per-file grants — ReBAC can grant a single file inside a
// drive the caller doesn't otherwise have. The Tantivy
// filter is drive-only; this re-check restores per-file
// resolution.
// Failures degrade conservatively (drop the hit, log it) —
// never leak.
let mut verified = Vec::with_capacity(hits.len());
for hit in hits {
let file_uuid = match Uuid::parse_str(&hit.file_id) {
Ok(u) => u,
Err(_) => {
tracing::warn!("Content-index hit had non-UUID file_id: {}", hit.file_id);
continue;
}
};
match authz
.check(caller, Permission::Read, Resource::File(file_uuid))
.await
{
Ok(true) => verified.push(hit),
Ok(false) => {
tracing::debug!(
target: "oxicloud::search",
file_id = %file_uuid,
"dropping content-index hit: ReBAC denies Read after Tantivy filter",
);
}
Err(e) => {
tracing::warn!("ReBAC re-check failed for {file_uuid}: {e}");
}
}
}
verified
}
/// Merge content-index hits into the name-search result page:
+21 -11
View File
@@ -838,11 +838,19 @@ mod tests {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: uuid::Uuid,
) -> Result<String, DomainError> {
unimplemented!()
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: uuid::Uuid,
) -> Result<String, DomainError> {
unimplemented!()
}
@@ -898,6 +906,7 @@ mod tests {
&self,
_name: String,
_parent_id: Option<String>,
_caller_id: uuid::Uuid,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
@@ -925,6 +934,7 @@ mod tests {
async fn get_folder_by_path(
&self,
_storage_path: &crate::domain::services::path_service::StoragePath,
_drive_id: uuid::Uuid,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
@@ -971,6 +981,7 @@ mod tests {
&self,
_id: &str,
_new_name: String,
_caller_id: Uuid,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
@@ -979,6 +990,7 @@ mod tests {
&self,
_id: &str,
_new_parent_id: Option<&str>,
_caller_id: Uuid,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
@@ -990,6 +1002,7 @@ mod tests {
async fn folder_exists(
&self,
_storage_path: &crate::domain::services::path_service::StoragePath,
_drive_id: uuid::Uuid,
) -> Result<bool, DomainError> {
unimplemented!()
}
@@ -1001,7 +1014,11 @@ mod tests {
unimplemented!()
}
async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> {
async fn move_to_trash(
&self,
_folder_id: &str,
_caller_id: Uuid,
) -> Result<(), DomainError> {
unimplemented!()
}
@@ -1009,6 +1026,7 @@ mod tests {
&self,
_folder_id: &str,
_original_path: &str,
_caller_id: Uuid,
) -> Result<(), DomainError> {
unimplemented!()
}
@@ -1016,14 +1034,6 @@ mod tests {
async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> {
unimplemented!()
}
async fn create_home_folder(
&self,
_user_id: Uuid,
_name: String,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
}
struct MockShareRepository {
+18 -6
View File
@@ -253,9 +253,10 @@ impl TrashUseCase for TrashService {
}
};
// Then physically move the file to trash
// Then physically move the file to trash.
// §14: caller_id stamps `updated_by` on the trashed row.
info!("Physically moving file to trash: {}", item_id);
match self.file_write_port.move_to_trash(item_id).await {
match self.file_write_port.move_to_trash(item_id, user_id).await {
Ok(_) => {
debug!("File physically moved to trash successfully: {}", item_id);
}
@@ -320,9 +321,10 @@ impl TrashUseCase for TrashService {
}
};
// Then physically move the folder to trash
// Then physically move the folder to trash.
// §14: caller_id stamps `updated_by` on every cascade-trashed row.
self.folder_storage_port
.move_to_trash(item_id)
.move_to_trash(item_id, user_id)
.await
.map_err(|e| {
DomainError::new(
@@ -391,7 +393,7 @@ impl TrashUseCase for TrashService {
);
match self
.file_write_port
.restore_from_trash(&file_id, &original_path)
.restore_from_trash(&file_id, &original_path, user_id)
.await
{
Ok(_) => {
@@ -431,7 +433,7 @@ impl TrashUseCase for TrashService {
);
match self
.folder_storage_port
.restore_from_trash(&folder_id, &original_path)
.restore_from_trash(&folder_id, &original_path, user_id)
.await
{
Ok(_) => {
@@ -821,12 +823,19 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Trash listing — drive_id is informational and the trash
// row doesn't currently SELECT it. Path-based lookups
// never enter this code path.
drive_id: uuid::Uuid::nil(),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
// §14 provenance not selected by the trash listing query.
created_by: None,
updated_by: None,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -867,6 +876,9 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the trash listing query.
created_by: None,
updated_by: None,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
+33 -15
View File
@@ -139,7 +139,7 @@ where
)
})?;
self.file_write_port
.move_to_trash(item_id)
.move_to_trash(item_id, user_id)
.await
.map_err(|e| {
DomainError::new(
@@ -181,7 +181,7 @@ where
)
})?;
self.folder_storage_port
.move_to_trash(item_id)
.move_to_trash(item_id, user_id)
.await
.map_err(|e| {
DomainError::new(
@@ -215,7 +215,7 @@ where
let original_path = item.original_path().to_string();
let result = self
.file_write_port
.restore_from_trash(&file_id, &original_path)
.restore_from_trash(&file_id, &original_path, user_id)
.await;
if let Err(e) = result
&& !format!("{}", e).contains("not found")
@@ -232,7 +232,7 @@ where
let original_path = item.original_path().to_string();
let result = self
.folder_storage_port
.restore_from_trash(&folder_id, &original_path)
.restore_from_trash(&folder_id, &original_path, user_id)
.await;
if let Err(e) = result
&& !format!("{}", e).contains("not found")
@@ -500,13 +500,18 @@ impl FileReadPort for MockFileRepository {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: Uuid,
) -> std::result::Result<String, DomainError> {
unimplemented!()
}
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: Uuid,
) -> std::result::Result<String, DomainError> {
unimplemented!()
}
@@ -561,6 +566,7 @@ impl FileWritePort for MockFileRepository {
_content_type: String,
_blob_hash: &str,
_size: u64,
_caller_id: Uuid,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}
@@ -569,6 +575,7 @@ impl FileWritePort for MockFileRepository {
&self,
_file_id: &str,
_target_folder_id: Option<String>,
_caller_id: Uuid,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}
@@ -577,6 +584,7 @@ impl FileWritePort for MockFileRepository {
&self,
_file_id: &str,
_new_name: &str,
_caller_id: Uuid,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}
@@ -591,6 +599,7 @@ impl FileWritePort for MockFileRepository {
_blob_hash: &str,
_size: u64,
_modified_at: Option<i64>,
_caller_id: Uuid,
) -> std::result::Result<(String, i64), DomainError> {
Ok((String::new(), 0))
}
@@ -601,6 +610,7 @@ impl FileWritePort for MockFileRepository {
_folder_id: Option<String>,
_content_type: String,
_size: u64,
_caller_id: Uuid,
) -> std::result::Result<(File, PathBuf), DomainError> {
unimplemented!()
}
@@ -610,11 +620,16 @@ impl FileWritePort for MockFileRepository {
_file_id: &str,
_target_folder_id: Option<String>,
_new_name: Option<&str>,
_caller_id: Uuid,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
async fn move_to_trash(
&self,
id: &str,
_caller_id: Uuid,
) -> std::result::Result<(), DomainError> {
let mut files = self.files.lock().unwrap();
let mut trashed = self.trashed_files.lock().unwrap();
@@ -630,6 +645,7 @@ impl FileWritePort for MockFileRepository {
&self,
id: &str,
_original_path: &str,
_caller_id: Uuid,
) -> std::result::Result<(), DomainError> {
let mut files = self.files.lock().unwrap();
let mut trashed = self.trashed_files.lock().unwrap();
@@ -693,6 +709,7 @@ impl FolderRepository for MockFolderRepository {
&self,
_name: String,
_parent_id: Option<String>,
_caller_id: Uuid,
) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
@@ -709,6 +726,7 @@ impl FolderRepository for MockFolderRepository {
async fn get_folder_by_path(
&self,
_storage_path: &StoragePath,
_drive_id: Uuid,
) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
@@ -753,6 +771,7 @@ impl FolderRepository for MockFolderRepository {
&self,
_id: &str,
_new_name: String,
_caller_id: Uuid,
) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
@@ -761,6 +780,7 @@ impl FolderRepository for MockFolderRepository {
&self,
_id: &str,
_new_parent_id: Option<&str>,
_caller_id: Uuid,
) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
@@ -772,6 +792,7 @@ impl FolderRepository for MockFolderRepository {
async fn folder_exists(
&self,
_storage_path: &StoragePath,
_drive_id: Uuid,
) -> std::result::Result<bool, DomainError> {
Ok(false)
}
@@ -780,7 +801,11 @@ impl FolderRepository for MockFolderRepository {
Ok(StoragePath::from_string("/"))
}
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
async fn move_to_trash(
&self,
id: &str,
_caller_id: Uuid,
) -> std::result::Result<(), DomainError> {
let mut folders = self.folders.lock().unwrap();
let mut trashed = self.trashed_folders.lock().unwrap();
@@ -796,6 +821,7 @@ impl FolderRepository for MockFolderRepository {
&self,
id: &str,
_original_path: &str,
_caller_id: Uuid,
) -> std::result::Result<(), DomainError> {
let mut folders = self.folders.lock().unwrap();
let mut trashed = self.trashed_folders.lock().unwrap();
@@ -822,14 +848,6 @@ impl FolderRepository for MockFolderRepository {
))
}
}
async fn create_home_folder(
&self,
_user_id: Uuid,
_name: String,
) -> std::result::Result<Folder, DomainError> {
Ok(Folder::default())
}
}
#[cfg(integration_tests)]
@@ -132,7 +132,7 @@ impl UserLifecycleService {
//
// Always-on observer. Emits one structured `tracing::info!(target: "audit",
// ...)` line per event. The only hook registered in PR 1; subsequent PRs
// add HomeFolderLifecycleHook, AuthzCacheLifecycleHook, etc., each living
// add PersonalDriveLifecycleHook, AuthzCacheLifecycleHook, etc., each living
// next to the service it works for.
// ─────────────────────────────────────────────────────────────────────────────
+70 -13
View File
@@ -218,10 +218,41 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Two parallel subtrees off root: one for the user-grant scenario, one for
// the group-grant scenario. Each has its own depth/fanout shape so a single
// grant cascades over a known fixed number of descendants.
let shared_subtree =
build_subtree(&pool, admin.id, "shared_root", args.depth, args.fanout).await?;
let group_subtree =
build_subtree(&pool, admin.id, "group_root", args.depth, args.fanout).await?;
// Post-D0: every folder needs a drive_id (NOT NULL) and root folders
// (parent_id IS NULL) are reserved for the atomic drive-creation
// transaction. The load-seed subtrees nest under admin's default
// personal drive's root folder ("Personal") — they're a workload
// shape, not their own drives. Look it up once and pass to both
// build_subtree calls so they share the same parent.
let admin_root: (Uuid,) = sqlx::query_as(
"SELECT f.id FROM storage.folders f
JOIN storage.drives d ON d.root_folder_id = f.id
WHERE d.default_for_user = $1
LIMIT 1",
)
.bind(admin.id)
.fetch_one(&pool)
.await?;
let admin_root_id = admin_root.0;
let shared_subtree = build_subtree(
&pool,
admin.id,
admin_root_id,
"shared_root",
args.depth,
args.fanout,
)
.await?;
let group_subtree = build_subtree(
&pool,
admin.id,
admin_root_id,
"group_root",
args.depth,
args.fanout,
)
.await?;
let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64;
let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len();
@@ -463,7 +494,14 @@ struct Subtree {
leaves: Vec<Uuid>,
}
/// Build a folder tree under `parent=NULL` rooted at `root_name`.
/// Build a folder tree under `parent_id = mount_under` rooted at `root_name`.
///
/// `mount_under` is an existing folder UUID (post-D0: the user's default
/// drive's root folder, since root folders — `parent_id IS NULL` — are
/// reserved for the atomic drive-creation transaction and would trip
/// the no-orphan-root-folder constraint trigger). Every level's
/// `drive_id` is derived from the parent folder so the NOT NULL column
/// is satisfied automatically.
///
/// Inserts level-by-level so `trg_folders_path` resolves `path`/`lpath` from
/// the already-committed parent rows. Returns the root, a depth-4 sample, the
@@ -471,6 +509,7 @@ struct Subtree {
async fn build_subtree(
pool: &PgPool,
user_id: Uuid,
mount_under: Uuid,
root_name: &str,
depth: u32,
fanout: u32,
@@ -485,14 +524,19 @@ async fn build_subtree(
root_name, predicted
);
// Level 0 — the root folder.
// Level 0 — the subtree's "root" sits inside `mount_under`, not at
// parent_id=NULL. drive_id is inherited from the mount point.
let root: (Uuid,) = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
"INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
SELECT $1, parent.id, $2, parent.drive_id, $2, $2
FROM storage.folders parent
WHERE parent.id = $3::uuid
RETURNING id",
)
.bind(root_name)
.bind(user_id)
.bind(mount_under)
.fetch_one(pool)
.await?;
let root_id = root.0;
@@ -523,10 +567,16 @@ async fn build_subtree(
parents.len()
);
// drive_id derives from the parent folder — same pattern as
// file_blob_write_repository's resolve_owner_and_drive helper.
// Every parent in `current_level` already has a drive_id set,
// so the JOIN is guaranteed to find one.
let rows: Vec<(Uuid,)> = sqlx::query_as(
"INSERT INTO storage.folders (name, parent_id, user_id)
SELECT f.name, f.parent_id, $1
"INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
SELECT f.name, f.parent_id, $1, parent.drive_id, $1, $1
FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name)
JOIN storage.folders parent ON parent.id = f.parent_id
RETURNING id",
)
.bind(user_id)
@@ -601,10 +651,17 @@ async fn insert_files(
.flat_map(|li| (0..files_per_leaf).map(move |fi| format!("file_{}_{}.txt", li, fi)))
.collect();
// Post-D0: storage.files.drive_id is NOT NULL — derive it from the
// parent folder (same pattern as file_blob_write_repository's
// INSERTs and the resolve_owner_and_drive helper). The folder's
// drive_id was set during the M2 backfill or by the lifecycle hook
// for users provisioned after D0.
sqlx::query(
"INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
SELECT f.name, f.folder_id, $1, $2, 0, 'text/plain'
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)",
"INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size, mime_type)
SELECT f.name, f.folder_id, $1, fo.drive_id, $2, 0, 'text/plain'
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)
JOIN storage.folders fo ON fo.id = f.folder_id",
)
.bind(user_id)
.bind(blob_hash)
+22 -4
View File
@@ -454,6 +454,7 @@ impl AppServiceFactory {
repos: &RepositoryServices,
trash_service: Option<Arc<TrashService>>,
authz: &Arc<PgAclEngine>,
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
storage_usage: &Arc<StorageUsageService>,
content_index: Option<Arc<TantivyContentIndex>>,
plugin_dispatch: Option<
@@ -540,6 +541,8 @@ impl AppServiceFactory {
repos.file_read_repository.clone(),
repos.folder_repository.clone(),
content_index_port,
Some(authz.clone()),
Some(drive_repo.clone()),
300, // Cache TTL in seconds (5 minutes)
1000, // Maximum cache entries
)));
@@ -1050,6 +1053,12 @@ impl AppServiceFactory {
subject_group_repo.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.
let drive_repo =
Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone()));
// 3b. Trash service (needed before application services)
let trash_service = self
.create_trash_service(&repos, &core, &authorization)
@@ -1078,6 +1087,7 @@ impl AppServiceFactory {
&repos,
trash_service.clone(),
&authorization,
&drive_repo,
&storage_usage,
content_index.as_ref().map(|(idx, _)| idx.clone()),
plugin_dispatch.clone(),
@@ -1147,7 +1157,7 @@ impl AppServiceFactory {
// audit event is recorded
// even if a later hook
// errors out.
// 2. HomeFolderLifecycleHook — provisions the user's
// 2. PersonalDriveLifecycleHook — provisions the user's
// home folder on
// created/login (no-op
// for external users).
@@ -1190,8 +1200,9 @@ impl AppServiceFactory {
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
))
.with_hook(Arc::new(
crate::application::services::folder_service::HomeFolderLifecycleHook::new(
apps.folder_service_concrete.clone(),
crate::application::services::folder_service::PersonalDriveLifecycleHook::new(
drive_repo.clone(),
authorization.clone(),
),
))
.with_hook(Arc::new(
@@ -1224,7 +1235,7 @@ impl AppServiceFactory {
// Auth services. Folder service no longer threaded here —
// PR 3 moved home-folder provisioning into
// HomeFolderLifecycleHook, which already holds an Arc to the
// PersonalDriveLifecycleHook, which already holds an Arc to the
// folder service via the user_lifecycle dispatcher.
if self.config.features.enable_auth {
let services = crate::infrastructure::auth_factory::create_auth_services(
@@ -1379,6 +1390,7 @@ impl AppServiceFactory {
webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
authorization,
drive_repo: drive_repo.clone(),
subject_group_service: Some(Arc::new(
crate::application::services::subject_group_service::SubjectGroupService::new(
subject_group_repo.clone(),
@@ -1839,6 +1851,12 @@ pub struct AppState {
/// an enum dispatcher or `Arc<dyn AuthorizationEngine>` (with
/// `async_trait` boxing).
pub authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
/// Drive entity repository — `GET /api/drives`, the personal-drive
/// lifecycle hook, and (post-D2) shared-drive creation flow all read
/// through this. Backing table is `storage.drives`; membership is
/// resolved through `role_grants` not a separate `drive_members`
/// table (see `docs/plan/drive.md` §3).
pub drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
/// ReBAC subject-group management (CRUD + membership). `None` when the
/// auth subsystem is not configured.
pub subject_group_service:
+52 -26
View File
@@ -101,11 +101,19 @@ impl FileReadPort for StubFileReadPort {
Ok(StoragePath::from_string("/"))
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
Ok("root".to_string())
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
Ok("stub-folder-id".to_string())
}
@@ -157,6 +165,7 @@ impl FileWritePort for StubFileWritePort {
_content_type: String,
_blob_hash: &str,
_size: u64,
_caller_id: Uuid,
) -> Result<File, DomainError> {
Ok(File::default())
}
@@ -165,6 +174,7 @@ impl FileWritePort for StubFileWritePort {
&self,
_file_id: &str,
_target_folder_id: Option<String>,
_caller_id: Uuid,
) -> Result<File, DomainError> {
Ok(File::default())
}
@@ -174,11 +184,17 @@ impl FileWritePort for StubFileWritePort {
_file_id: &str,
_target_folder_id: Option<String>,
_new_name: Option<&str>,
_caller_id: Uuid,
) -> Result<File, DomainError> {
Ok(File::default())
}
async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result<File, DomainError> {
async fn rename_file(
&self,
_file_id: &str,
_new_name: &str,
_caller_id: Uuid,
) -> Result<File, DomainError> {
Ok(File::default())
}
@@ -192,6 +208,7 @@ impl FileWritePort for StubFileWritePort {
_blob_hash: &str,
_size: u64,
_modified_at: Option<i64>,
_caller_id: Uuid,
) -> Result<(String, i64), DomainError> {
Ok((String::new(), 0))
}
@@ -202,11 +219,12 @@ impl FileWritePort for StubFileWritePort {
_folder_id: Option<String>,
_content_type: String,
_size: u64,
_caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError> {
Ok((File::default(), PathBuf::from("/tmp/dummy")))
}
async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> {
async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
Ok(())
}
@@ -214,6 +232,7 @@ impl FileWritePort for StubFileWritePort {
&self,
_file_id: &str,
_original_path: &str,
_caller_id: Uuid,
) -> Result<(), DomainError> {
Ok(())
}
@@ -234,6 +253,7 @@ impl FolderRepository for StubFolderStoragePort {
&self,
_name: String,
_parent_id: Option<String>,
_caller_id: Uuid,
) -> Result<Folder, DomainError> {
Ok(Folder::default())
}
@@ -242,7 +262,11 @@ impl FolderRepository for StubFolderStoragePort {
Ok(Folder::default())
}
async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> Result<Folder, DomainError> {
async fn get_folder_by_path(
&self,
_storage_path: &StoragePath,
_drive_id: Uuid,
) -> Result<Folder, DomainError> {
Ok(Folder::default())
}
@@ -279,7 +303,12 @@ impl FolderRepository for StubFolderStoragePort {
Ok((Vec::new(), Some(0)))
}
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, DomainError> {
async fn rename_folder(
&self,
_id: &str,
_new_name: String,
_caller_id: Uuid,
) -> Result<Folder, DomainError> {
Ok(Folder::default())
}
@@ -287,6 +316,7 @@ impl FolderRepository for StubFolderStoragePort {
&self,
_id: &str,
_new_parent_id: Option<&str>,
_caller_id: Uuid,
) -> Result<Folder, DomainError> {
Ok(Folder::default())
}
@@ -295,7 +325,11 @@ impl FolderRepository for StubFolderStoragePort {
Ok(())
}
async fn folder_exists(&self, _storage_path: &StoragePath) -> Result<bool, DomainError> {
async fn folder_exists(
&self,
_storage_path: &StoragePath,
_drive_id: Uuid,
) -> Result<bool, DomainError> {
Ok(false)
}
@@ -303,7 +337,7 @@ impl FolderRepository for StubFolderStoragePort {
Ok(StoragePath::from_string("/"))
}
async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> {
async fn move_to_trash(&self, _folder_id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
Ok(())
}
@@ -311,6 +345,7 @@ impl FolderRepository for StubFolderStoragePort {
&self,
_folder_id: &str,
_original_path: &str,
_caller_id: Uuid,
) -> Result<(), DomainError> {
Ok(())
}
@@ -318,14 +353,6 @@ impl FolderRepository for StubFolderStoragePort {
async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> {
Ok(())
}
async fn create_home_folder(
&self,
_user_id: Uuid,
_name: String,
) -> Result<Folder, DomainError> {
Ok(Folder::default())
}
}
// ---------------------------------------------------------------------------
@@ -397,7 +424,11 @@ impl FolderUseCase for StubFolderUseCase {
Ok(FolderDto::default())
}
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
async fn get_folder_by_path(
&self,
_path: &str,
_drive_id: Uuid,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default())
}
@@ -455,14 +486,6 @@ impl FolderUseCase for StubFolderUseCase {
) -> Result<(), DomainError> {
Ok(())
}
async fn create_home_folder(
&self,
_user_id: Uuid,
_name: String,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default())
}
}
// ---------------------------------------------------------------------------
@@ -478,6 +501,7 @@ impl FileUploadUseCase for StubFileUploadUseCase {
_folder_id: Option<String>,
_content_type: String,
_blob: StoredBlob,
_caller_id: Uuid,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
@@ -485,9 +509,11 @@ impl FileUploadUseCase for StubFileUploadUseCase {
async fn update_file_streaming(
&self,
_path: &str,
_drive_id: Uuid,
_blob: StoredBlob,
_content_type: &str,
_modified_at: Option<i64>,
_caller_id: Uuid,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
@@ -567,7 +593,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Box::new(empty_stream))
}
async fn get_file_by_path(&self, _path: &str) -> Result<FileDto, DomainError> {
async fn get_file_by_path(&self, _path: &str, _drive_id: Uuid) -> Result<FileDto, DomainError> {
Err(DomainError::not_found("File", "stub"))
}
+136
View File
@@ -0,0 +1,136 @@
//! Drive — the top-level container that owns a tree of folders/files.
//!
//! Drives replaced the per-user `My Folder - <username>` wrapper at D0.
//! Every folder and file row carries a `drive_id` (added by D0's
//! migration); a drive is the natural unit of quota, sharing, and
//! lifecycle. Membership is expressed through `storage.role_grants` rows
//! with `resource_type='drive'` — there is no separate `drive_members`
//! table.
//!
//! ## Kinds
//!
//! Two kinds today; the discriminant is the `kind` column with a CHECK
//! constraint.
//!
//! - **`personal`** — single-user, single-owner. The owner is captured
//! by `default_for_user` (for the default Personal drive) or by an
//! Owner role_grant on a secondary personal drive. Personal drives
//! refuse `add_member`, `remove_member`, and `delete_drive` (when
//! it's the user's only or default drive). A user can have multiple
//! personal drives — one is marked default (`default_for_user =
//! <uid>`), the others are secondaries (`default_for_user = NULL`,
//! one Owner row in role_grants pinning them to the same user).
//!
//! - **`shared`** — multi-member, group-aware, full role roster
//! (viewer / commenter / contributor / editor / owner). Members
//! come from role_grants; group subjects expand transitively via
//! the existing `subject_groups` machinery. Last-owner protection
//! applies on member removal and drive deletion. Quota is set by
//! the drive owner (or admin); `used_bytes` tracks consumption.
//!
//! Future kinds (e.g. `system` for built-in scratch space) drop in by
//! extending the CHECK + the `DriveKind` enum.
//!
//! ## Policies
//!
//! `policies` is a JSONB bag carrying feature flags / capability toggles
//! that drive owners can flip without a schema change. Known keys live in
//! `docs/plan/drive.md` §8 and §15 (e.g. `forbid_public_links`,
//! `include_in_photo_index`, `forbid_music_index`). Unknown keys are
//! preserved by the application — the schema is intentionally permissive
//! so future capability flags can land without a migration.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Drive kind discriminant. Mirrors the `storage.drives.kind` CHECK
/// constraint values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DriveKind {
/// Single-owner storage compartment. Cannot have members added or
/// removed via the membership API; the owner is fixed for the drive's
/// lifetime.
Personal,
/// Multi-member drive supporting the full role roster. Membership is
/// open to admin/owner-driven changes through the membership API.
Shared,
}
impl DriveKind {
pub fn as_str(self) -> &'static str {
match self {
DriveKind::Personal => "personal",
DriveKind::Shared => "shared",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"personal" => Some(DriveKind::Personal),
"shared" => Some(DriveKind::Shared),
_ => None,
}
}
}
/// Domain entity for a row in `storage.drives`.
///
/// Drives are pure metadata under the D0 design (docs/plan/drive.md §3):
/// no `name` column — the display name lives on the root folder pointed
/// at by `root_folder_id`. Code that needs the name pairs this struct
/// with a JOIN through `storage.folders`; see the repository's
/// `DriveWithRootName` view-model.
///
/// Field-level constraints are enforced at the SQL layer (CHECK on
/// `kind`, partial UNIQUE on `default_for_user`). The struct mirrors
/// the column set 1:1; behaviour beyond field access lives in
/// `DriveRepository` and `DriveService` (post-D0).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Drive {
/// Stable identifier. Generated server-side at creation.
pub id: Uuid,
/// Discriminant — see [`DriveKind`].
pub kind: DriveKind,
/// Set iff this is the user's default personal drive (UNIQUE in SQL
/// via a partial index `WHERE default_for_user IS NOT NULL`). NULL
/// on shared drives and on secondary personal drives.
pub default_for_user: Option<Uuid>,
/// The drive's mount-point folder. The column is NULLable in SQL
/// only because the atomic creation CTE writes it mid-statement
/// (a column-level `NOT NULL` would refuse the initial drive INSERT
/// — see docs/plan/drive.md §3). After any successful creation path,
/// this is populated; code reading `Drive` may treat it as `Uuid`,
/// not `Option<Uuid>`. A NULL at read time is a data-invariant bug.
pub root_folder_id: Uuid,
/// Soft cap on this drive's storage usage, in bytes. `None` means
/// "no quota" (rare; reserved for admin overrides). The default
/// initial quota for a fresh personal drive is taken from the
/// owner's `auth.users.storage_quota_bytes` at creation time.
/// **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) —
/// not in the drive `owner` role bundle.
pub quota_bytes: Option<i64>,
/// Running total of bytes consumed. Maintained incrementally by
/// upload/delete paths in D4; on D0 still reflects the pre-Drive
/// per-user counters via the backfill.
pub used_bytes: i64,
/// Capability flags / feature toggles. Extensible JSONB — see
/// `docs/plan/drive.md` §8 and §15 for the known keys.
pub policies: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl Drive {
/// `true` for the user's default personal drive (the only drive for
/// which `default_for_user` is set to that user's id).
pub fn is_default_for(&self, user_id: Uuid) -> bool {
self.default_for_user == Some(user_id)
}
/// `true` if this drive is a personal drive of any kind (default or
/// secondary). Encapsulates the kind check at the call site.
pub fn is_personal(&self) -> bool {
matches!(self.kind, DriveKind::Personal)
}
}
+79
View File
@@ -25,6 +25,10 @@ pub struct FileParts {
pub owner_id: Option<Uuid>,
/// BLAKE3 content hash. See [`File::content_hash`] for semantics.
pub blob_hash: String,
/// §14 provenance: original creator. See [`File::created_by`].
pub created_by: Option<Uuid>,
/// §14 provenance: most recent mutator. See [`File::updated_by`].
pub updated_by: Option<Uuid>,
}
/**
@@ -77,6 +81,18 @@ pub struct File {
/// ETag (the ETag formula may grow to include `modified_at` etc.,
/// but `content_hash` remains the raw hash).
blob_hash: String,
/// User that originally created this file (§14 provenance).
/// Stamped at INSERT and never updated thereafter. `None` when
/// the referenced user has been deleted (FK is `ON DELETE SET
/// NULL`) or for stub/DTO-reconstructed files.
created_by: Option<Uuid>,
/// User that performed the most recent mutation that bumped
/// `updated_at` (rename, move, content overwrite, trash, restore).
/// Authorship signal — distinct from ownership. `None` when the
/// referenced user is deleted or for stub/DTO-reconstructed files.
updated_by: Option<Uuid>,
}
// We no longer need this module, now we use a String directly
@@ -95,6 +111,8 @@ impl Default for File {
modified_at: 0,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
}
}
}
@@ -134,6 +152,8 @@ impl File {
modified_at: now,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
})
}
@@ -166,6 +186,8 @@ impl File {
modified_at,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
})
}
@@ -207,6 +229,40 @@ impl File {
modified_at: u64,
owner_id: Option<Uuid>,
blob_hash: String,
) -> FileResult<Self> {
Self::with_timestamps_blob_hash_and_provenance(
id,
name,
storage_path,
size,
mime_type,
folder_id,
created_at,
modified_at,
owner_id,
blob_hash,
None,
None,
)
}
/// Full constructor including the §14 provenance columns
/// (`created_by` / `updated_by`). PG-row callers use this to
/// preserve authorship across reconstruction.
#[allow(clippy::too_many_arguments)]
pub fn with_timestamps_blob_hash_and_provenance(
id: String,
name: String,
storage_path: StoragePath,
size: u64,
mime_type: String,
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
blob_hash: String,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> FileResult<Self> {
let name = normalize_storage_name(&name);
if let Err(reason) = validate_storage_name(&name) {
@@ -228,6 +284,8 @@ impl File {
modified_at,
owner_id,
blob_hash,
created_by,
updated_by,
})
}
@@ -248,6 +306,8 @@ impl File {
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,
}
}
@@ -351,6 +411,21 @@ impl File {
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.
pub fn created_by(&self) -> Option<Uuid> {
self.created_by
}
/// User that performed the most recent mutation that bumped
/// `updated_at`. Authorship signal — distinct from ownership.
/// `None` when the referenced user is deleted or for
/// stub/DTO entities.
pub fn updated_by(&self) -> Option<Uuid> {
self.updated_by
}
#[allow(clippy::too_many_arguments)]
pub fn from_dto(
id: String,
@@ -382,6 +457,10 @@ impl File {
modified_at,
owner_id: None,
blob_hash: String::new(),
// DTO round-trips don't carry provenance; callers needing
// it must reload from the repository.
created_by: None,
updated_by: None,
}
}
+122 -1
View File
@@ -29,6 +29,13 @@ pub struct Folder {
/// `None` only for legacy/stub folders; real folders always have an owner.
owner_id: Option<Uuid>,
/// 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).
/// `Uuid::nil()` only for stub/legacy in-memory folders that never
/// touched the DB.
drive_id: Uuid,
/// Creation timestamp
created_at: u64,
@@ -43,6 +50,20 @@ pub struct Folder {
/// HTTP ETag emitted in PROPFIND/GET/HEAD responses — see
/// [`Folder::etag`] for the formula and rationale.
tree_modified_at: u64,
/// User that originally created this folder. Stamped at INSERT
/// from the caller's id and never updated afterwards (provenance,
/// not ownership — see §14 of the Drive plan). `None` when the
/// referenced user is later deleted (FK is `ON DELETE SET NULL`)
/// or for stub/DTO-reconstructed folders that never touched the DB.
created_by: Option<Uuid>,
/// User that performed the most recent mutation that touched
/// `updated_at` (rename, move, trash, restore, content overwrite).
/// Authorship signal — does NOT propagate via the tree-ETag flush
/// trigger. `None` when the referenced user is deleted or for
/// stub/DTO-reconstructed folders.
updated_by: Option<Uuid>,
}
// We no longer need this module, now we use a String directly
@@ -56,9 +77,12 @@ impl Default for Folder {
path_string: "/".to_string(),
parent_id: None,
owner_id: None,
drive_id: Uuid::nil(),
created_at: 0,
modified_at: 0,
tree_modified_at: 0,
created_by: None,
updated_by: None,
}
}
}
@@ -103,9 +127,18 @@ impl Folder {
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,
})
}
@@ -128,6 +161,7 @@ impl Folder {
storage_path,
parent_id,
None,
Uuid::nil(),
created_at,
modified_at,
modified_at,
@@ -154,6 +188,7 @@ impl Folder {
storage_path,
parent_id,
owner_id,
Uuid::nil(),
created_at,
modified_at,
modified_at,
@@ -162,7 +197,12 @@ impl Folder {
/// Full constructor used by the PG repository when reading rows.
/// `tree_modified_at` comes from the trigger-maintained column on
/// `storage.folders` and feeds [`Folder::etag`].
/// `storage.folders` and feeds [`Folder::etag`]. `drive_id` is the
/// post-D0 `storage.folders.drive_id NOT NULL` column — every
/// path-based lookup scopes by this axis. `created_by` /
/// `updated_by` are the §14 provenance columns; both are nullable
/// because the M1 FK is `ON DELETE SET NULL` (a deleted user
/// leaves authored rows in place).
#[allow(clippy::too_many_arguments)]
pub fn with_timestamps_and_tree(
id: String,
@@ -170,9 +210,42 @@ impl Folder {
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
drive_id: Uuid,
created_at: u64,
modified_at: u64,
tree_modified_at: u64,
) -> FolderResult<Self> {
Self::with_timestamps_tree_and_provenance(
id,
name,
storage_path,
parent_id,
owner_id,
drive_id,
created_at,
modified_at,
tree_modified_at,
None,
None,
)
}
/// Full constructor including the §14 provenance columns
/// (`created_by` / `updated_by`). Direct PG-row callers use this
/// to preserve authorship through the entity layer.
#[allow(clippy::too_many_arguments)]
pub fn with_timestamps_tree_and_provenance(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
drive_id: Uuid,
created_at: u64,
modified_at: u64,
tree_modified_at: u64,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> FolderResult<Self> {
let name = normalize_storage_name(&name);
if let Err(reason) = validate_storage_name(&name) {
@@ -188,9 +261,12 @@ impl Folder {
path_string,
parent_id,
owner_id,
drive_id,
created_at,
modified_at,
tree_modified_at,
created_by,
updated_by,
})
}
@@ -227,6 +303,29 @@ impl Folder {
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`).
pub fn drive_id(&self) -> Uuid {
self.drive_id
}
/// User that originally created this folder (§14 provenance).
/// `None` when the referenced user has been deleted
/// (FK is `ON DELETE SET NULL`) or for in-memory/DTO-reconstructed
/// entities.
pub fn created_by(&self) -> Option<Uuid> {
self.created_by
}
/// User that performed the most recent mutation that bumped
/// `updated_at`. Authorship signal — distinct from ownership.
/// `None` when the referenced user has been deleted or for
/// in-memory/DTO-reconstructed entities.
pub fn updated_by(&self) -> Option<Uuid> {
self.updated_by
}
/// Latest descendant-write timestamp. Statement-level Postgres
/// triggers enqueue every file/folder write into
/// `storage.tree_etag_dirty`; the background `TreeEtagFlushService`
@@ -314,9 +413,18 @@ impl Folder {
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
// through the repository.
drive_id: Uuid::nil(),
created_at,
modified_at,
tree_modified_at: modified_at,
// DTO round-trips through this constructor lose
// provenance; callers that need it reload through the repo.
created_by: None,
updated_by: None,
}
}
@@ -353,12 +461,17 @@ impl Folder {
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,
// Renaming bumps both self and descendant rollup —
// ancestors' listings now show a new name, so the
// collection has materially changed.
tree_modified_at: now,
// Provenance is preserved across the in-memory rebuild;
// real persisted updates re-read from the DB.
created_by: self.created_by,
updated_by: self.updated_by,
})
}
@@ -389,9 +502,12 @@ impl Folder {
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,
tree_modified_at: now,
created_by: self.created_by,
updated_by: self.updated_by,
})
}
@@ -478,6 +594,7 @@ mod tests {
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
5_000,
@@ -499,6 +616,7 @@ mod tests {
StoragePath::from_string("/a"),
None,
None,
Uuid::nil(),
0,
0,
42,
@@ -510,6 +628,7 @@ mod tests {
StoragePath::from_string("/b"),
None,
None,
Uuid::nil(),
0,
0,
42,
@@ -532,6 +651,7 @@ mod tests {
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
3_000,
@@ -543,6 +663,7 @@ mod tests {
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
4_000,
+1
View File
@@ -3,6 +3,7 @@ pub mod calendar;
pub mod calendar_event;
pub mod contact;
pub mod device_code;
pub mod drive;
pub mod entity_errors;
pub mod face;
pub mod file;
+173
View File
@@ -0,0 +1,173 @@
//! Repository for [`Drive`] entities backed by `storage.drives`.
//!
//! Drives have no separate membership table — owner/editor/viewer
//! membership lives in `storage.role_grants` with
//! `resource_type='drive'`. That means **listing the drives a user can
//! reach goes through the role-grant query, not through this
//! repository**. This repo handles:
//!
//! * Creating a drive (used by the user-creation lifecycle hook and
//! by D3's shared-drive flow).
//! * Looking up a single drive by id (used by the engine's owner_of /
//! check paths, by `/api/drives/{id}`, and by the drive picker).
//! * Finding the caller's default drive (used by the Photos / Music
//! endpoints and by D1's redirect-from-`/` logic).
//!
//! Membership-flavoured queries (e.g. "list every drive user X can
//! read") live in `DriveListingService` (post-D0) which reads
//! `role_grants` and resolves the matching drive rows here.
use thiserror::Error;
use uuid::Uuid;
use crate::domain::entities::drive::{Drive, DriveKind};
#[derive(Debug, Error)]
pub enum DriveRepositoryError {
#[error("Drive not found: {0}")]
NotFound(String),
/// A user already has a default drive set — partial unique index on
/// `default_for_user` rejects a second one. Surfaces the constraint
/// explicitly so the lifecycle hook can no-op idempotently.
#[error("User already has a default drive: {0}")]
DefaultDriveAlreadyExists(String),
#[error("Invalid drive kind: {0}")]
InvalidKind(String),
#[error("Storage error: {0}")]
StorageError(String),
}
/// A drive paired with the display name from its root folder.
///
/// `storage.drives` has no `name` column under the D0 design
/// (docs/plan/drive.md §3) — the display name lives on
/// `storage.folders.name` of the row pointed at by `drive.root_folder_id`.
/// Read paths join the two tables and hand callers this view-model so the
/// API surface can continue to expose a single "drive with name" shape
/// without a follow-up query per drive.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DriveWithRootName {
pub drive: Drive,
/// The drive's display name. Sourced from `storage.folders.name`
/// of the root folder via JOIN at read time.
pub root_folder_name: String,
}
#[async_trait::async_trait]
pub trait DriveRepository: Send + Sync + 'static {
/// Atomically create a personal drive together with its root folder
/// and the owner role_grant — all four DB writes in a single SQL
/// statement (docs/plan/drive.md §3 "Atomic creation"). The
/// statement runs as its own implicit transaction in autocommit mode
/// so a server crash mid-statement leaves no half-row state.
///
/// The root folder is created with name `"Personal"` (the canonical
/// default) and `parent_id IS NULL`. The drive's `root_folder_id`
/// is wired to point at it before the statement commits.
///
/// Returns `DefaultDriveAlreadyExists` when the owner already has a
/// default drive — relies on the partial UNIQUE index on
/// `default_for_user`.
async fn create_personal_drive_atomic(
&self,
owner_id: Uuid,
quota_bytes: Option<i64>,
) -> Result<DriveWithRootName, DriveRepositoryError>;
/// Fetch a drive by id together with its display name. `NotFound`
/// when no row matches.
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError>;
/// Return the caller's default personal drive paired with its
/// display name, or `NotFound` if they don't have one (e.g.
/// external users; users created before the lifecycle hook fired).
/// Drives the Photos timeline scope, the `/api/recent/*` scope, and
/// D1's redirect-from-`/`.
async fn find_default_for_user(
&self,
user_id: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError>;
/// Canonical "what is this user's home root folder id?" lookup.
///
/// Returns `Some(uuid)` for any internal user with a default personal
/// drive (the lifecycle hook provisions one at registration), and
/// `None` for users who have no default drive (external users; users
/// created before the hook existed). The id identifies the user's
/// home **by drive ownership** (`default_for_user == user_id`),
/// never by folder name — users can rename their home, so any code
/// that wants to ask "is this folder the user's home?" must compare
/// folder ids, not names.
///
/// Storage errors (DB unreachable, etc.) bubble up as `Err`; the
/// "user simply has no home" case is `Ok(None)`, not an error.
async fn home_root_folder_id_for(
&self,
user_id: Uuid,
) -> Result<Option<Uuid>, DriveRepositoryError> {
match self.find_default_for_user(user_id).await {
Ok(d) => Ok(Some(d.drive.root_folder_id)),
Err(DriveRepositoryError::NotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
/// List drives the caller can read, resolved via `role_grants` for
/// `resource_type='drive'`. The caller's group memberships are
/// expanded by the engine's `subject_match_set`; that expanded set
/// is what this method's `subject_ids` argument carries.
///
/// Returns rows in a stable order: default drive first (if any),
/// then by display name. The `/api/drives` handler relies on that
/// order for the picker UI without a follow-up sort.
async fn list_for_subjects(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
}
/// Convenience: convert the canonical kind discriminator from its SQL
/// form into the typed enum. Mirrored on the entity for symmetry.
impl DriveKind {
pub fn from_sql(s: &str) -> Result<Self, DriveRepositoryError> {
DriveKind::parse(s).ok_or_else(|| DriveRepositoryError::InvalidKind(s.to_owned()))
}
}
/// Locate the user's home root folder within a generic list of items,
/// identifying it by **drive ownership** (never by folder name — users
/// can rename their home).
///
/// `id_fn` extracts a candidate `Uuid` from each item. The callsite
/// commonly works with `FolderDto` (whose `id` is a `String`); the
/// closure is `|f| Uuid::parse_str(&f.id).ok()`. Items whose ids can't
/// be parsed are simply skipped — `position` ignores them.
///
/// Defined as a free function (not a trait method) so the
/// `DriveRepository` trait stays `dyn`-compatible. Generic over both
/// the repo (`R`) and the item shape (`T`); accepts both concrete repo
/// types and `&dyn DriveRepository`.
///
/// Returns `None` when:
/// * The user has no default drive (external users, pre-hook accounts).
/// * The user's home root folder id isn't present in `items`.
/// * The repo lookup errored (storage error is swallowed to None —
/// callers wanting fail-loud semantics should call
/// `home_root_folder_id_for` directly).
pub async fn position_of_user_home_root_folder<R, T>(
drive_repo: &R,
user_id: Uuid,
items: &[T],
id_fn: impl Fn(&T) -> Option<Uuid>,
) -> Option<usize>
where
R: DriveRepository + ?Sized,
{
let home_id = drive_repo
.home_root_folder_id_for(user_id)
.await
.ok()
.flatten()?;
items.iter().position(|item| id_fn(item) == Some(home_id))
}
+32 -8
View File
@@ -12,6 +12,7 @@ use std::path::PathBuf;
use bytes::Bytes;
use futures::Stream;
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
@@ -49,8 +50,12 @@ pub trait FileReadRepository: Send + Sync + 'static {
/// Gets the logical storage path of a file.
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
/// Gets the parent folder ID from a path (WebDAV).
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
/// Gets the parent folder ID from a path (WebDAV), scoped to a drive.
///
/// Post-D0, `storage.folders.path` is unique only within a single
/// drive — the `drive_id` filter scopes the lookup.
async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid)
-> Result<String, DomainError>;
}
// ─────────────────────────────────────────────────────
@@ -73,6 +78,9 @@ pub trait FileWriteRepository: Send + Sync + 'static {
/// Registers a file row pointing at a blob already stored in the
/// content-addressable chunk store (one blob reference is consumed).
///
/// `caller_id` stamps both `created_by` and `updated_by`
/// (§14 provenance).
async fn save_file_with_blob(
&self,
name: String,
@@ -80,17 +88,26 @@ pub trait FileWriteRepository: Send + Sync + 'static {
content_type: String,
blob_hash: &str,
size: u64,
caller_id: Uuid,
) -> Result<File, DomainError>;
/// Moves a file to another folder.
/// Moves a file to another folder. `caller_id` stamps `updated_by`
/// in the same UPDATE that bumps `updated_at` (§14 provenance).
async fn move_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
caller_id: Uuid,
) -> Result<File, DomainError>;
/// Renames a file (same folder, different name).
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError>;
/// Renames a file (same folder, different name). `caller_id`
/// stamps `updated_by` in the same UPDATE (§14 provenance).
async fn rename_file(
&self,
file_id: &str,
new_name: &str,
caller_id: Uuid,
) -> Result<File, DomainError>;
/// Deletes a file.
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
@@ -103,24 +120,31 @@ pub trait FileWriteRepository: Send + Sync + 'static {
///
/// Returns `(File, PathBuf)` where `PathBuf` is the destination path for
/// the deferred write that the `WriteBehindCache` will perform.
///
/// `caller_id` stamps both `created_by` and `updated_by`
/// (§14 provenance).
async fn register_file_deferred(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
size: u64,
caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError>;
// ── Trash operations ──
/// Moves a file to the trash
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>;
/// Moves a file to the trash. `caller_id` stamps `updated_by`
/// (§14 provenance).
async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Restores a file from the trash to its original location
/// Restores a file from the trash to its original location.
/// `caller_id` stamps `updated_by` (§14 provenance).
async fn restore_from_trash(
&self,
file_id: &str,
original_path: &str,
caller_id: Uuid,
) -> Result<(), DomainError>;
/// Permanently deletes a file (used by the trash)
+49 -15
View File
@@ -18,18 +18,35 @@ use uuid::Uuid;
/// Defines the CRUD and management operations required for
/// the Folder entity in the storage system.
pub trait FolderRepository: Send + Sync + 'static {
/// Creates a new folder
/// Creates a new folder.
///
/// `caller_id` is stamped into `created_by` and `updated_by`
/// (D0 §14 provenance — authorship belongs to whoever issued the
/// create, not to the parent folder's owner). Pre-D2 they're
/// silently equivalent (only the owner can write); D2 ships
/// shared drives where this distinction matters.
async fn create_folder(
&self,
name: String,
parent_id: Option<String>,
caller_id: Uuid,
) -> Result<Folder, DomainError>;
/// Gets a folder by its ID
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
/// Gets a folder by its storage path
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError>;
/// Gets a folder by its storage path within a drive's tree.
///
/// Post-D0, `storage.folders.path` is unique only within a single
/// drive — root-folder names like `"Personal"` repeat across drives.
/// The `drive_id` filter scopes the lookup to a specific drive
/// (caller derives it from its protocol context: NC chroot, native
/// default-drive lookup, WOPI default-drive lookup).
async fn get_folder_by_path(
&self,
storage_path: &StoragePath,
drive_id: Uuid,
) -> Result<Folder, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
@@ -64,44 +81,61 @@ pub trait FolderRepository: Send + Sync + 'static {
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Renames a folder
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
/// Renames a folder. `caller_id` is stamped into `updated_by`
/// alongside the `updated_at = NOW()` bump (§14 provenance).
async fn rename_folder(
&self,
id: &str,
new_name: String,
caller_id: Uuid,
) -> Result<Folder, DomainError>;
/// Moves a folder to another parent
/// Moves a folder to another parent. `caller_id` is stamped into
/// `updated_by` alongside the `updated_at = NOW()` bump
/// (§14 provenance).
async fn move_folder(
&self,
id: &str,
new_parent_id: Option<&str>,
caller_id: Uuid,
) -> Result<Folder, DomainError>;
/// Deletes a folder
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
/// Checks if a folder exists at the given path
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
/// Checks if a folder exists at the given path within a drive.
///
/// Post-D0 `storage.folders.path` is unique only within a single
/// drive — the `drive_id` filter scopes the existence check.
async fn folder_exists(
&self,
storage_path: &StoragePath,
drive_id: Uuid,
) -> Result<bool, DomainError>;
/// Gets the path of a folder
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError>;
// ── Trash operations ──
/// Moves a folder to the trash
async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError>;
/// Moves a folder to the trash. `caller_id` is stamped into
/// `updated_by` for the root row and every cascade-trashed
/// descendant (§14 provenance).
async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Restores a folder from the trash to its original location
/// Restores a folder from the trash to its original location.
/// `caller_id` is stamped into `updated_by` for the root row and
/// every cascade-restored descendant (§14 provenance).
async fn restore_from_trash(
&self,
folder_id: &str,
original_path: &str,
caller_id: Uuid,
) -> Result<(), DomainError>;
/// Permanently deletes a folder (used by the trash)
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
/// Creates a root-level home folder for a user.
/// This is used during user registration to create the user's personal folder.
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError>;
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
///
/// Uses ltree `<@` for a single GiST-indexed scan. The result is
+1
View File
@@ -2,6 +2,7 @@ pub mod address_book_repository;
pub mod calendar_event_repository;
pub mod calendar_repository;
pub mod contact_repository;
pub mod drive_repository;
pub mod file_repository;
pub mod folder_repository;
pub mod magic_link_token_repository;
+10 -3
View File
@@ -74,6 +74,10 @@ impl fmt::Display for Subject {
pub enum Resource {
Folder(Uuid),
File(Uuid),
/// A drive — root scope for a tree of folders/files plus its own
/// membership and policy bag. Added in D0; membership lives in
/// `storage.role_grants` (no separate `drive_members` table).
Drive(Uuid),
// Reserved for future use:
// Calendar(Uuid),
// Reserved for future use:
@@ -87,6 +91,7 @@ impl Resource {
match self {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
Resource::Drive(_) => "drive",
//Resource::Calendar(_) => "calendar",
//Resource::AddressBook(_) => "adressbook",
//Resource::Playlist(_) => "playlist",
@@ -95,12 +100,10 @@ impl Resource {
pub fn id(&self) -> Uuid {
match self {
Resource::Folder(id)
| Resource::File(id)
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
//| Resource::Calendar(id)
//| Resource::AddressBook(id)
//| Resource::Playlist(id)
=> *id,
}
}
@@ -108,6 +111,7 @@ impl Resource {
match resource_type {
"folder" => Some(Resource::Folder(id)),
"file" => Some(Resource::File(id)),
"drive" => Some(Resource::Drive(id)),
//"calendar" => Some(Resource::Calendar(id)),
//"adressbook" => Some(Resource::AddressBook(id)),
//"playlist" => Some(Resource::Playlist(id)),
@@ -351,6 +355,7 @@ impl Grant {
pub enum ResourceKind {
File,
Folder,
Drive,
// Future: Calendar, AddressBook, Playlist, …
}
@@ -359,6 +364,7 @@ impl ResourceKind {
match self {
ResourceKind::File => "file",
ResourceKind::Folder => "folder",
ResourceKind::Drive => "drive",
}
}
@@ -366,6 +372,7 @@ impl ResourceKind {
match s {
"file" => Some(ResourceKind::File),
"folder" => Some(ResourceKind::Folder),
"drive" => Some(ResourceKind::Drive),
_ => None,
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub async fn create_auth_services(
);
// Wire the user-lifecycle dispatcher. Home-folder provisioning is
// now handled by HomeFolderLifecycleHook (registered on the
// now handled by PersonalDriveLifecycleHook (registered on the
// dispatcher in DI) — AuthApplicationService no longer needs a
// direct FolderService dependency for that path.
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
+20 -1
View File
@@ -200,6 +200,25 @@ async fn run_migrations(pool: &PgPool) -> Result<()> {
match sqlx::migrate!().run(pool).await {
Ok(()) => Ok(()),
Err(e) => Err(DbError(format!("Migration error: {}", e))),
Err(e) => Err(DbError(format_error_chain("Migration error", &e))),
}
}
/// Format an error and every wrapped `source()` cause on a single line.
///
/// sqlx's `MigrateError::Execute` wraps the underlying `sqlx::Error::Database`
/// which in turn carries the PG `DETAIL` (e.g. `Key (version)=(20260803000000)`
/// for a duplicate-key on `_sqlx_migrations_pkey`). The default `Display`
/// only renders the outermost layer, so the operationally-critical hint
/// gets buried. Walking the chain surfaces it without needing to bump
/// `RUST_LOG` to debug.
fn format_error_chain(prefix: &str, e: &(dyn std::error::Error + 'static)) -> String {
let mut out = format!("{prefix}: {e}");
let mut cur = e.source();
while let Some(c) = cur {
out.push_str(" -> ");
out.push_str(&c.to_string());
cur = c.source();
}
out
}
@@ -0,0 +1,271 @@
//! PostgreSQL implementation of [`DriveRepository`].
//!
//! The repo deals only with the `storage.drives` table itself. Drive
//! membership lives in `storage.role_grants` (`resource_type='drive'`)
//! and is queried through the engine's existing grant paths;
//! `list_for_subjects` below resolves `role_grants` → `storage.drives`
//! via a single join.
//!
//! See `migrations/20260802000000_drives_schema_additive.sql` for the
//! schema and `docs/plan/drive.md` §3 / §15 for the locked design.
use std::sync::Arc;
use sqlx::{PgPool, Row, types::Uuid};
use crate::domain::entities::drive::{Drive, DriveKind};
use crate::domain::repositories::drive_repository::{
DriveRepository, DriveRepositoryError, DriveWithRootName,
};
pub struct DrivePgRepository {
pool: Arc<PgPool>,
}
impl DrivePgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> DriveRepositoryError {
if let sqlx::Error::Database(ref dberr) = e
&& let Some(code) = dberr.code()
&& code.as_ref() == "23505"
{
// unique_violation. With drives, the only relevant unique is
// the partial index `idx_drives_default_for_user_unique` —
// surface the typed variant so the lifecycle hook can detect
// idempotent re-runs (D0-9 calls create_personal_drive_atomic
// during user provisioning).
return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string());
}
DriveRepositoryError::StorageError(format!("{context}: {e}"))
}
/// Map a row carrying both the drive's columns AND a `root_folder_name`
/// column (sourced via JOIN with `storage.folders`) into the view-model.
fn row_to_drive_with_name(
row: &sqlx::postgres::PgRow,
) -> Result<DriveWithRootName, DriveRepositoryError> {
let kind_str: String = row.get("kind");
let kind = DriveKind::from_sql(&kind_str)?;
let drive = Drive {
id: row.get("id"),
kind,
default_for_user: row.get("default_for_user"),
root_folder_id: row.get("root_folder_id"),
quota_bytes: row.get("quota_bytes"),
used_bytes: row.get("used_bytes"),
policies: row.get("policies"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
};
Ok(DriveWithRootName {
drive,
root_folder_name: row.get("root_folder_name"),
})
}
}
#[async_trait::async_trait]
impl DriveRepository for DrivePgRepository {
async fn create_personal_drive_atomic(
&self,
owner_id: Uuid,
quota_bytes: Option<i64>,
) -> Result<DriveWithRootName, DriveRepositoryError> {
// Four writes wrapped in a single transaction so either all
// commit or none does (docs/plan/drive.md §3). A single CTE
// statement would be cleaner on paper but doesn't work in
// PostgreSQL: CTE sub-statements share an MVCC snapshot, so
// `UPDATE storage.drives WHERE id = …` cannot match a row
// inserted by an earlier CTE branch. We use plain sequential
// statements inside `pool.begin()` instead — each statement
// sees the prior ones' writes (transaction-local visibility),
// and FK constraints are satisfied at insert time because the
// referenced rows already exist.
//
// Rollback semantics: any error before `tx.commit()` (FK
// violation, unique_violation on `default_for_user`, server
// crash) discards every partial write. No orphan drive, no
// folder without a drive, no drive without an owner.
let mut tx = self
.pool
.begin()
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?;
// 1. Drive row (root_folder_id NULL — populated in step 3).
let drive_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.drives
(kind, default_for_user, quota_bytes, policies)
VALUES ('personal', $1, $2, '{}'::jsonb)
RETURNING id
"#,
)
.bind(owner_id)
.bind(quota_bytes)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.drive", e))?;
// 2. Root folder. `parent_id IS NULL` makes it a root in the
// drive; `drive_id` closes the FK in this direction.
let folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, $1, $2, $1, $1)
RETURNING id
"#,
)
.bind(owner_id)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.folder", e))?;
// 3. Close the other side of the circular reference.
sqlx::query(r#"UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2"#)
.bind(folder_id)
.bind(drive_id)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.wire", e))?;
// 4. Owner role_grant — the caller becomes the drive's sole
// owner (single-user invariant on personal drives, §2).
sqlx::query(
r#"
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id,
role, granted_by)
VALUES ('user', $1, 'drive', $2, 'owner', $1)
"#,
)
.bind(owner_id)
.bind(drive_id)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.grant", e))?;
// Fetch the row in its final state so the caller gets a
// consistent view (including DB-computed defaults like
// `created_at`, `used_bytes`).
let row = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
WHERE d.id = $1
"#,
)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.read", e))?;
tx.commit()
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.commit", e))?;
Self::row_to_drive_with_name(&row)
}
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError> {
let row = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
WHERE d.id = $1
"#,
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(id.to_string()))?;
Self::row_to_drive_with_name(&row)
}
async fn find_default_for_user(
&self,
user_id: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError> {
let row = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
WHERE d.default_for_user = $1
"#,
)
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("find_default_for_user", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(user_id.to_string()))?;
Self::row_to_drive_with_name(&row)
}
async fn list_for_subjects(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
// Joining role_grants → drives → folders returns every drive the
// expanded subject set can read, paired with its display name.
// ORDER BY puts default drives first (so the picker UI doesn't
// need a follow-up sort), then alphabetical by name. GROUP BY
// collapses duplicate role_grants on the same drive (direct +
// group-mediated) and sidesteps PostgreSQL's "ORDER BY
// expression must appear in select list" rule that SELECT
// DISTINCT imposes.
let rows = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
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())
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at, f.name
ORDER BY (d.default_for_user IS NULL) ASC,
LOWER(f.name) ASC
"#,
)
.bind(
subject_types
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
)
.bind(subject_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
rows.iter().map(Self::row_to_drive_with_name).collect()
}
}
@@ -19,6 +19,8 @@ type MediaFileRow = (
i64, // updated_at
String, // blob_hash
Option<Uuid>, // user_id
Option<Uuid>, // created_by (§14 provenance)
Option<Uuid>, // updated_by (§14 provenance)
i64, // sort_date
Option<i32>, // width
Option<i32>, // height
@@ -42,7 +44,9 @@ use crate::infrastructure::services::dedup_service::DedupService;
use uuid::Uuid;
/// Type alias for file metadata rows from SQL queries.
/// Fields: id, name, folder_id, folder_path, size, mime_type, created_at, updated_at, blob_hash, user_id
/// Fields: id, name, folder_id, folder_path, size, mime_type,
/// created_at, updated_at, blob_hash, user_id, created_by, updated_by.
/// `created_by` / `updated_by` are the §14 provenance columns.
type FileRow = (
String,
String,
@@ -54,6 +58,8 @@ type FileRow = (
i64,
String,
Option<Uuid>,
Option<Uuid>,
Option<Uuid>,
);
/// Append the optional type/date/size filters from `criteria` to
@@ -228,7 +234,8 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id \
fi.user_id, \
fi.created_by, fi.updated_by \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
WHERE {where_clause}"
@@ -248,8 +255,10 @@ impl FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -277,7 +286,8 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id \
fi.user_id, \
fi.created_by, fi.updated_by \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
@@ -291,8 +301,10 @@ impl FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -357,9 +369,11 @@ impl FileBlobReadRepository {
modified_at: i64,
blob_hash: String,
owner_id: Option<Uuid>,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps_and_blob_hash(
File::with_timestamps_blob_hash_and_provenance(
id,
name,
storage_path,
@@ -370,6 +384,8 @@ impl FileBlobReadRepository {
modified_at as u64,
owner_id,
blob_hash,
created_by,
updated_by,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
@@ -428,6 +444,7 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by,
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
fm.width, fm.height
FROM storage.files fi
@@ -453,9 +470,9 @@ impl FileBlobReadRepository {
let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows {
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, sd, w, h) in rows {
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)?);
sort_dates.push(sd);
dims.push((w, h));
@@ -530,6 +547,8 @@ impl FileReadPort for FileBlobReadRepository {
i64, // updated_at
String, // blob_hash
Option<Uuid>, // user_id (owner)
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
>(
r#"
@@ -538,7 +557,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.id = $1::uuid AND NOT fi.is_trashed
@@ -555,7 +575,7 @@ impl FileReadPort for FileBlobReadRepository {
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
)
}
@@ -576,6 +596,8 @@ impl FileReadPort for FileBlobReadRepository {
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
>(
r#"
@@ -584,7 +606,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.id = $1::uuid
@@ -598,7 +621,7 @@ impl FileReadPort for FileBlobReadRepository {
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
)
}
@@ -616,6 +639,8 @@ impl FileReadPort for FileBlobReadRepository {
i64, // updated_at
String, // blob_hash
Option<Uuid>, // user_id (owner)
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
>(
r#"
@@ -624,7 +649,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.id = $1::uuid
@@ -643,7 +669,7 @@ impl FileReadPort for FileBlobReadRepository {
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
)
}
@@ -657,7 +683,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
@@ -675,7 +702,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -689,8 +717,10 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect()
@@ -711,7 +741,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
@@ -731,7 +762,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -747,8 +779,10 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect()
@@ -777,7 +811,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
@@ -798,7 +833,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -815,8 +851,10 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect()
@@ -839,7 +877,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
@@ -862,7 +901,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -883,8 +923,10 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect()
@@ -935,7 +977,11 @@ impl FileReadPort for FileBlobReadRepository {
Ok(Self::make_file_path(row.1.as_deref(), &row.0))
}
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
path: &str,
drive_id: Uuid,
) -> Result<String, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
@@ -955,29 +1001,49 @@ impl FileReadPort for FileBlobReadRepository {
));
}
self.get_folder_id_by_path(&folder_path).await
self.get_folder_id_by_path(&folder_path, drive_id).await
}
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError> {
async fn get_folder_id_by_path(
&self,
folder_path: &str,
drive_id: Uuid,
) -> Result<String, DomainError> {
let folder_path = folder_path.trim_start_matches('/').trim_end_matches('/');
if folder_path.is_empty() {
return Err(DomainError::not_found("Folder", "empty path"));
}
// Post-D0 `storage.folders.path` repeats across drives —
// filter by `drive_id` to scope the lookup.
sqlx::query_scalar::<_, String>(
"SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed",
"SELECT id::text FROM storage.folders \
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed",
)
.bind(folder_path)
.bind(drive_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("folder lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", format!("path: {folder_path}")))
}
/// Direct SQL lookup using materialized folder paths.
/// Direct SQL lookup using materialized folder paths, scoped to a drive.
/// O(1) query instead of O(depth) folder walk.
async fn find_file_by_path(&self, path: &str) -> Result<Option<File>, DomainError> {
///
/// Post-D0 `storage.folders.path` repeats across drives (each drive
/// has its own root with a name like `"Personal"`). Without the
/// `drive_id` filter the lookup would be non-deterministic. The
/// root-level branch filters on `fi.drive_id`; the nested branch
/// filters on the parent folder's `fo.drive_id` (which closes the
/// leak cleanly and matches the path semantics — see Step 2 of
/// the path-lookup refactor).
async fn find_file_by_path(
&self,
path: &str,
drive_id: Uuid,
) -> Result<Option<File>, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
@@ -996,7 +1062,8 @@ impl FileReadPort for FileBlobReadRepository {
let folder_path = segments[..segments.len() - 1].join("/");
let row = if folder_path.is_empty() {
// File at root level (no parent folder)
// File at root level (no parent folder) — filter on
// `fi.drive_id` because there's no folder row to join through.
sqlx::query_as::<
_,
(
@@ -1010,6 +1077,8 @@ impl FileReadPort for FileBlobReadRepository {
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
>(
r#"
@@ -1018,17 +1087,23 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed
WHERE fi.name = $1 AND fi.folder_id IS NULL
AND fi.drive_id = $2 AND NOT fi.is_trashed
"#,
)
.bind(filename)
.bind(drive_id)
.fetch_optional(self.pool.as_ref())
.await
} else {
// File inside a folder — look up by folder path + filename
// File inside a folder — look up by folder path + filename,
// filtered by the parent folder's drive_id (path semantics
// are folder-scoped, so this also catches mis-pointed file
// rows during D0/D7's dual-write window).
sqlx::query_as::<
_,
(
@@ -1042,6 +1117,8 @@ impl FileReadPort for FileBlobReadRepository {
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
>(
r#"
@@ -1050,14 +1127,17 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed
WHERE fo.path = $1 AND fi.name = $2
AND fo.drive_id = $3 AND NOT fi.is_trashed
"#,
)
.bind(&folder_path)
.bind(filename)
.bind(drive_id)
.fetch_optional(self.pool.as_ref())
.await
}
@@ -1065,7 +1145,7 @@ impl FileReadPort for FileBlobReadRepository {
match row {
Some(r) => Ok(Some(Self::row_to_file(
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9,
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, r.11,
)?)),
None => Ok(None),
}
@@ -1086,6 +1166,7 @@ impl FileReadPort for FileBlobReadRepository {
let mut row_stream = sqlx::query_as::<_, (
String, String, Option<String>, Option<String>,
i64, String, i64, i64, String, Option<Uuid>,
Option<Uuid>, Option<Uuid>,
)>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -1093,7 +1174,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid)
@@ -1107,9 +1189,9 @@ impl FileReadPort for FileBlobReadRepository {
while let Some(row) = row_stream.try_next().await.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}"))
})? {
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) = row;
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub) = row;
let file = FileBlobReadRepository::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)?;
yield file;
}
@@ -1173,6 +1255,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
fi.created_by, fi.updated_by, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
@@ -1195,6 +1278,8 @@ impl FileReadPort for FileBlobReadRepository {
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
i64,
),
>(&sql)
@@ -1217,13 +1302,15 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
// total_count is the same in every row; 0 when result set is empty.
let total_count = rows.first().map_or(0, |r| r.10) as usize;
let total_count = rows.first().map_or(0, |r| r.12) as usize;
let files = rows
.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1299,6 +1386,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
fi.created_by, fi.updated_by, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
JOIN storage.folders fo ON fo.id = fi.folder_id \
@@ -1321,6 +1409,8 @@ impl FileReadPort for FileBlobReadRepository {
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
i64,
),
>(&sql)
@@ -1341,13 +1431,15 @@ impl FileReadPort for FileBlobReadRepository {
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
})?;
let total_count = rows.first().map_or(0, |r| r.10) as usize;
let total_count = rows.first().map_or(0, |r| r.12) as usize;
let files = rows
.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1389,7 +1481,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid
@@ -1418,7 +1511,8 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL
@@ -1443,8 +1537,10 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect()
@@ -27,6 +27,10 @@ use crate::infrastructure::services::dedup_service::DedupService;
pub struct FileBlobWriteRepository {
pool: Arc<PgPool>,
dedup: Arc<DedupService>,
/// Retained on the struct after D0-8 inlined parent-folder lookups
/// directly via SQL; kept for now so D0's diff stays scoped to drive_id
/// + provenance plumbing. Slated for removal in a follow-up cleanup.
#[allow(dead_code)]
folder_repo: Arc<FolderDbRepository>,
/// Shared handle to `FileBlobReadRepository`'s file_id → blob_hash
/// cache. Content swaps and hard deletes invalidate the mapping here
@@ -111,9 +115,11 @@ impl FileBlobWriteRepository {
modified_at: i64,
owner_id: Option<Uuid>,
blob_hash: String,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps_and_blob_hash(
File::with_timestamps_blob_hash_and_provenance(
id,
name,
storage_path,
@@ -124,14 +130,33 @@ impl FileBlobWriteRepository {
modified_at as u64,
owner_id,
blob_hash,
created_by,
updated_by,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
/// Derive user_id from the parent folder, or error if folder_id is None.
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<Uuid, DomainError> {
/// Derive `(user_id, drive_id)` from the parent folder. Both are
/// needed during the D0 dual-write window: `user_id` for the legacy
/// column (dropped in D7) and `drive_id` for the new owning-drive
/// reference.
async fn resolve_owner_and_drive(
&self,
folder_id: Option<&str>,
) -> Result<(Uuid, Uuid), DomainError> {
match folder_id {
Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
Some(fid) => {
let row: Option<(Uuid, Uuid)> = sqlx::query_as::<_, (Uuid, Uuid)>(
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
)
.bind(fid)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
})?;
row.ok_or_else(|| DomainError::not_found("Folder", fid))
}
None => Err(DomainError::internal_error(
"FileBlobWrite",
"folder_id is required to determine file owner",
@@ -150,12 +175,18 @@ impl FileBlobWriteRepository {
/// `(new_hash, updated_at_epoch)` on success — the effective timestamp
/// is returned so callers can rebuild the fresh entity without
/// re-reading the row.
///
/// §14: `updated_by = $5` (caller_id). The caller mutated this
/// row — not the row's owner. D2 shared drives let non-owners
/// overwrite content; the previous `updated_by = f.user_id` would
/// have silently recorded the wrong principal.
async fn swap_blob_hash(
&self,
file_id: &str,
new_hash: &str,
new_size: i64,
modified_at: Option<i64>,
caller_id: Uuid,
) -> Result<(String, i64), DomainError> {
// Atomic CTE: capture old hash then update in one round-trip, no TOCTOU.
// Deadlock victims (40P01) retry before the compensation below runs —
@@ -168,7 +199,8 @@ impl FileBlobWriteRepository {
)
UPDATE storage.files f
SET blob_hash = $1, size = $2,
updated_at = COALESCE(to_timestamp($4), NOW())
updated_at = COALESCE(to_timestamp($4), NOW()),
updated_by = $5
FROM old
WHERE f.id = old.id
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint
@@ -178,6 +210,7 @@ impl FileBlobWriteRepository {
.bind(new_size)
.bind(file_id)
.bind(modified_at.map(|t| t as f64))
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
})
.await
@@ -223,6 +256,12 @@ impl FileBlobWriteRepository {
/// Register a file row pointing at a blob already stored in the chunk
/// store (the upload-ingest layer streamed the content in). Consumes the
/// caller's blob reference: any failure releases it before returning.
///
/// §14: `created_by = $7 = updated_by = caller_id` — authorship
/// belongs to the principal performing the upload, not to the parent
/// folder's owner. In D2 shared drives a non-owner member can upload
/// into a folder Alice owns; binding `parent.user_id` would have
/// silently recorded Alice as the author.
async fn save_file_with_blob_impl(
&self,
name: String,
@@ -230,6 +269,7 @@ impl FileBlobWriteRepository {
content_type: String,
blob_hash: &str,
size: u64,
caller_id: Uuid,
) -> Result<File, DomainError> {
// Root files have no parent folder to derive an owner from — keep the
// previous resolve_user_id(None) contract (release the ref, error out).
@@ -260,19 +300,24 @@ impl FileBlobWriteRepository {
// (a retried INSERT can legitimately lose to a concurrent identical
// upload).
let result = retry_on_deadlock("files.insert", || {
sqlx::query_as::<_, (String, Uuid, String, i64, i64)>(
sqlx::query_as::<_, (String, Uuid, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
WITH parent AS (
SELECT id, user_id, path FROM storage.folders WHERE id = $2::uuid
SELECT id, user_id, drive_id, path FROM storage.folders WHERE id = $2::uuid
)
INSERT INTO storage.files
(name, folder_id, user_id, blob_hash, size, mime_type, category_order)
SELECT $1, parent.id, parent.user_id, $3, $4, $5, $6 FROM parent
(name, folder_id, user_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
SELECT $1, parent.id, parent.user_id, parent.drive_id, $3, $4,
$5, $6, $7, $7
FROM parent
RETURNING id::text,
user_id,
(SELECT path FROM parent),
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
created_by,
updated_by
"#,
)
.bind(&name)
@@ -281,44 +326,46 @@ impl FileBlobWriteRepository {
.bind(size as i64)
.bind(&content_type)
.bind(category_order_for(&name, &content_type))
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
})
.await;
let (id, user_id, folder_path, created_at, updated_at) = match result {
Ok(Some(row)) => row,
Ok(None) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after missing parent folder — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
let (id, user_id, folder_path, created_at, updated_at, created_by, updated_by) =
match result {
Ok(Some(row)) => row,
Ok(None) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after missing parent folder — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
return Err(DomainError::not_found("Folder", fid));
}
return Err(DomainError::not_found("Folder", fid));
}
Err(e) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("23505")
{
return Err(DomainError::already_exists(
"File",
format!("'{name}' already exists in this folder"),
Err(e) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("23505")
{
return Err(DomainError::already_exists(
"File",
format!("'{name}' already exists in this folder"),
));
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
));
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
));
}
};
};
tracing::info!(
"📡 STREAMING WRITE: {} ({} bytes, hash: {})",
@@ -338,6 +385,8 @@ impl FileBlobWriteRepository {
updated_at,
Some(user_id),
blob_hash.to_string(),
created_by,
updated_by,
)
}
}
@@ -350,8 +399,9 @@ impl FileWritePort for FileBlobWriteRepository {
content_type: String,
blob_hash: &str,
size: u64,
caller_id: Uuid,
) -> Result<File, DomainError> {
self.save_file_with_blob_impl(name, folder_id, content_type, blob_hash, size)
self.save_file_with_blob_impl(name, folder_id, content_type, blob_hash, size, caller_id)
.await
}
@@ -359,20 +409,50 @@ impl FileWritePort for FileBlobWriteRepository {
&self,
file_id: &str,
target_folder_id: Option<String>,
caller_id: Uuid,
) -> Result<File, DomainError> {
// If moving to a different folder, get the new user_id (must be same user)
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
// If moving to a different folder, get the new user_id (must be same user).
//
// §14: `updated_by = $3` (caller_id) — the caller mutated this
// row. The previous COALESCE derived authorship from the
// destination folder's owner, which is wrong: dest's user_id
// has no claim to authorship of the file's content. D2 shared
// drives surface this most starkly (Alice moves Bob's file
// into Charlie's drive — `updated_by` must be Alice).
let row = sqlx::query_as::<
_,
(
String,
String,
Option<String>,
i64,
String,
i64,
i64,
Option<Uuid>,
Option<Uuid>,
),
>(
r#"
UPDATE storage.files
SET folder_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
WITH dest AS (
SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid
)
UPDATE storage.files f
SET folder_id = $1::uuid,
user_id = COALESCE((SELECT user_id FROM dest), f.user_id),
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
updated_at = NOW(),
updated_by = $3
WHERE f.id = $2::uuid AND NOT f.is_trashed
RETURNING f.id::text, f.name, f.folder_id::text, f.size, f.mime_type,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint,
f.created_by, f.updated_by
"#,
)
.bind(&target_folder_id)
.bind(file_id)
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
@@ -390,6 +470,8 @@ impl FileWritePort for FileBlobWriteRepository {
row.6,
None,
String::new(),
row.7,
row.8,
)
}
@@ -398,9 +480,16 @@ impl FileWritePort for FileBlobWriteRepository {
file_id: &str,
target_folder_id: Option<String>,
new_name: Option<&str>,
caller_id: Uuid,
) -> Result<File, DomainError> {
// Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count.
// Single round-trip; blob content is NOT copied (dedup makes this zero-copy).
//
// §14: `created_by = $4 = updated_by = caller_id` — the caller
// authored this copy. The previous binding used
// `dest_folder.user_id` which silently recorded the destination
// folder's owner as the author when Adam copied a file into
// Alice's folder.
let target_fid = target_folder_id.clone();
let rename_to = new_name.map(|s| s.to_string());
@@ -416,6 +505,8 @@ impl FileWritePort for FileBlobWriteRepository {
i64,
i64,
String,
Option<Uuid>,
Option<Uuid>,
),
>(
r#"
@@ -424,20 +515,39 @@ impl FileWritePort for FileBlobWriteRepository {
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
),
-- The destination folder may differ from the source's
-- folder (when $2 is set); derive drive_id from the
-- DESTINATION so cross-drive copies land in the right
-- drive. Files in personal drives only copy within the
-- same drive today, but the join makes the migration
-- future-proof for D2's cross-drive copy story.
dest_folder AS (
SELECT id, user_id, drive_id
FROM storage.folders
WHERE id = COALESCE($2::uuid,
(SELECT folder_id FROM src))
),
new_file AS (
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
SELECT COALESCE($3::text, name),
COALESCE($2::uuid, folder_id),
user_id,
blob_hash,
size,
mime_type,
category_order
FROM src
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
SELECT COALESCE($3::text, src.name),
dest_folder.id,
dest_folder.user_id,
dest_folder.drive_id,
src.blob_hash,
src.size,
src.mime_type,
src.category_order,
$4,
$4
FROM src, dest_folder
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
blob_hash
blob_hash,
created_by,
updated_by
)
SELECT * FROM new_file
"#,
@@ -445,6 +555,7 @@ impl FileWritePort for FileBlobWriteRepository {
.bind(file_id)
.bind(&target_fid)
.bind(&rename_to)
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
})
.await
@@ -490,22 +601,45 @@ impl FileWritePort for FileBlobWriteRepository {
row.6,
None,
row.7,
row.8,
row.9,
)
}
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
async fn rename_file(
&self,
file_id: &str,
new_name: &str,
caller_id: Uuid,
) -> Result<File, DomainError> {
// §14: `updated_by = $3` (caller_id), see move_file.
let row = sqlx::query_as::<
_,
(
String,
String,
Option<String>,
i64,
String,
i64,
i64,
Option<Uuid>,
Option<Uuid>,
),
>(
r#"
UPDATE storage.files
SET name = $1, updated_at = NOW()
SET name = $1, updated_at = NOW(), updated_by = $3
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
created_by, updated_by
"#,
)
.bind(new_name)
.bind(file_id)
.bind(caller_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
@@ -530,6 +664,8 @@ impl FileWritePort for FileBlobWriteRepository {
row.6,
None,
String::new(),
row.7,
row.8,
)
}
@@ -559,12 +695,13 @@ impl FileWritePort for FileBlobWriteRepository {
blob_hash: &str,
size: u64,
modified_at: Option<i64>,
caller_id: Uuid,
) -> Result<(String, i64), DomainError> {
// The content was already ingested into the chunk store by the
// upload-ingest layer; swap_blob_hash consumes its reference and
// releases it on failure.
let swapped = self
.swap_blob_hash(file_id, blob_hash, size as i64, modified_at)
.swap_blob_hash(file_id, blob_hash, size as i64, modified_at, caller_id)
.await?;
// The file now maps to a different blob — drop the read-side cache
// entry so streaming downloads cannot serve the previous content
@@ -579,30 +716,41 @@ impl FileWritePort for FileBlobWriteRepository {
folder_id: Option<String>,
content_type: String,
size: u64,
caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
let (user_id, drive_id) = self.resolve_owner_and_drive(folder_id.as_deref()).await?;
// For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
// §14: `created_by = $9 = updated_by = caller_id`. The legacy
// `user_id` column (dropped in D7) stays bound to the parent
// folder's owner; only the two provenance columns flip to the
// caller — see save_file_with_blob_impl.
let row = retry_on_deadlock("files.insert_deferred", || {
sqlx::query_as::<_, (String, i64, i64)>(
sqlx::query_as::<_, (String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
created_by,
updated_by
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(user_id)
.bind(drive_id)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
.bind(category_order_for(&name, &content_type))
.bind(caller_id)
.fetch_one(self.pool.as_ref())
})
.await
@@ -620,6 +768,8 @@ impl FileWritePort for FileBlobWriteRepository {
row.2,
Some(user_id),
String::new(),
row.3,
row.4,
)?;
// The target_path is not meaningful for blob storage (content goes to .blobs/)
@@ -631,18 +781,21 @@ impl FileWritePort for FileBlobWriteRepository {
// ── Trash operations ──
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> {
async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError> {
// §14: `updated_by = $2` (caller_id), see move_file.
let result = sqlx::query(
r#"
UPDATE storage.files
SET is_trashed = TRUE,
trashed_at = NOW(),
original_folder_id = folder_id,
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(file_id)
.bind(caller_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?;
@@ -657,7 +810,9 @@ impl FileWritePort for FileBlobWriteRepository {
&self,
file_id: &str,
_original_path: &str,
caller_id: Uuid,
) -> Result<(), DomainError> {
// §14: `updated_by = $2` (caller_id), see move_file.
let result = sqlx::query(
r#"
UPDATE storage.files
@@ -665,11 +820,13 @@ impl FileWritePort for FileBlobWriteRepository {
trashed_at = NULL,
folder_id = COALESCE(original_folder_id, folder_id),
original_folder_id = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
WHERE id = $1::uuid AND is_trashed
"#,
)
.bind(file_id)
.bind(caller_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?;
@@ -21,36 +21,58 @@ use crate::domain::services::authorization::ResourceKind;
use crate::domain::services::path_service::StoragePath;
/// Type alias for folder metadata rows from SQL queries.
/// Tuple order: id, name, path, parent_id, user_id, created_at,
/// modified_at, tree_modified_at. The trailing `tree_modified_at`
/// feeds [`Folder::etag`] — every SELECT here must include
/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64, i64);
/// Tuple order: id, name, path, parent_id, user_id, drive_id,
/// created_at, modified_at, tree_modified_at, created_by, updated_by.
/// The trailing `tree_modified_at` feeds [`Folder::etag`] — every
/// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based
/// lookups. `created_by` / `updated_by` are the §14 provenance
/// columns, nullable because the FK is `ON DELETE SET NULL`.
type FolderRow = (
String,
String,
String,
Option<String>,
Uuid,
Uuid,
i64,
i64,
i64,
Option<Uuid>,
Option<Uuid>,
);
/// Type alias for paginated folder rows (includes total_count as
/// the last element after `tree_modified_at`).
/// the last element after the §14 provenance columns).
type FolderRowPaginated = (
String,
String,
String,
Option<String>,
Uuid,
Uuid,
i64,
i64,
i64,
Option<Uuid>,
Option<Uuid>,
i64,
);
/// Type alias for folder rows with optional user_id.
/// Includes the §14 provenance columns `created_by` / `updated_by`.
type FolderRowOptUser = (
String,
String,
String,
Option<String>,
Option<Uuid>,
Uuid,
i64,
i64,
i64,
Option<Uuid>,
Option<Uuid>,
);
/// PostgreSQL-backed folder repository.
@@ -84,7 +106,9 @@ impl FolderDbRepository {
/// Convert a database row into a `Folder` domain entity.
///
/// The `path` comes directly from the materialized `path` column — no
/// extra queries needed.
/// extra queries needed. `created_by` / `updated_by` carry the
/// §14 provenance signal through the entity layer; both are
/// `Option<Uuid>` because the FK is `ON DELETE SET NULL`.
#[allow(clippy::too_many_arguments)]
fn row_to_folder(
id: String,
@@ -92,20 +116,26 @@ impl FolderDbRepository {
path: String,
parent_id: Option<String>,
user_id: Option<Uuid>,
drive_id: Uuid,
created_at: i64,
modified_at: i64,
tree_modified_at: i64,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> Result<Folder, DomainError> {
let storage_path = StoragePath::from_string(&path);
Folder::with_timestamps_and_tree(
Folder::with_timestamps_tree_and_provenance(
id,
name,
storage_path,
parent_id,
user_id,
drive_id,
created_at as u64,
modified_at as u64,
tree_modified_at as u64,
created_by,
updated_by,
)
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
}
@@ -123,10 +153,11 @@ impl FolderDbRepository {
let rows = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE id = ANY($1) AND NOT is_trashed
"#,
@@ -137,7 +168,9 @@ impl FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
rows.into_iter()
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7))
.map(|r| {
Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7, r.8, r.9, r.10)
})
.collect()
}
}
@@ -147,40 +180,59 @@ impl FolderRepository for FolderDbRepository {
&self,
name: String,
parent_id: Option<String>,
caller_id: Uuid,
) -> Result<Folder, DomainError> {
// Derive user_id from parent folder. Root-level folders require the
// caller to have set up the home folder beforehand (done during user
// registration).
let user_id: Uuid = if let Some(ref pid) = parent_id {
sqlx::query_scalar::<_, Uuid>("SELECT user_id FROM storage.folders WHERE id = $1::uuid")
.bind(pid)
.fetch_optional(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("parent lookup: {e}"))
})?
.ok_or_else(|| DomainError::not_found("Folder", pid))?
// Derive (user_id, drive_id) from parent folder in one round-trip.
// Root-level folders require the caller to have set up the home
// drive beforehand (done during user registration via the
// lifecycle hook).
let (user_id, drive_id): (Uuid, Uuid) = if let Some(ref pid) = parent_id {
sqlx::query_as::<_, (Uuid, Uuid)>(
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
)
.bind(pid)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", pid))?
} else {
return Err(DomainError::internal_error(
"FolderDb",
"Cannot create root folder without user_id — use create_home_folder instead",
"Cannot create root folder — root folders are reserved for the \
atomic drive-creation transaction in DrivePgRepository::\
create_personal_drive_atomic (docs/plan/drive.md §3). The \
no-orphan-root-folder trigger enforces this at the DB level.",
));
};
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
// D0 dual-write: drive_id alongside user_id (drops in D7); plus
// §14 provenance — `created_by` / `updated_by` bind to the caller
// ($5), NOT to the parent folder's `user_id`. Pre-D2 they're
// silently equivalent (only the parent's owner can write); the
// distinction matters once shared drives let an Editor mutate
// a folder owned by someone else.
//
// RETURNING also surfaces the two provenance columns so the
// built entity / DTO carries fresh values without a re-read.
let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, $2::uuid, $3)
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $5, $5)
RETURNING id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by,
updated_by
"#,
)
.bind(&name)
.bind(&parent_id)
.bind(user_id)
.bind(drive_id)
.bind(caller_id)
.fetch_one(self.pool())
.await
.map_err(|e| {
@@ -201,19 +253,24 @@ impl FolderRepository for FolderDbRepository {
row.1,
parent_id,
Some(user_id),
drive_id,
row.2,
row.3,
row.4,
// Fresh from RETURNING — caller_id was bound to both columns.
row.5,
row.6,
)
}
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE id = $1::uuid AND NOT is_trashed
"#,
@@ -224,10 +281,26 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
)
}
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
async fn get_folder_by_path(
&self,
storage_path: &StoragePath,
drive_id: Uuid,
) -> Result<Folder, DomainError> {
let path_str = storage_path.to_string();
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
@@ -236,23 +309,44 @@ impl FolderRepository for FolderDbRepository {
return Err(DomainError::not_found("Folder", "empty path"));
}
// Scoped by drive_id: post-D0 `storage.folders.path` is unique
// only within a single drive. Root-folder names like
// `"Personal"` repeat across drives, so without the drive_id
// filter the planner returns a non-deterministic row — which
// breaks owner-short-circuit checks and crosses drive
// boundaries (the AuthZ axis that replaces the old per-user
// wrapper scoping post-D0).
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE path = $1 AND NOT is_trashed
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed
"#,
)
.bind(lookup)
.bind(drive_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
)
}
#[allow(clippy::type_complexity)]
@@ -260,10 +354,11 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id = $1::uuid AND NOT is_trashed
ORDER BY name
@@ -275,10 +370,11 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id IS NULL AND NOT is_trashed
ORDER BY name
@@ -290,8 +386,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -305,10 +401,11 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
ORDER BY name
@@ -321,10 +418,11 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
ORDER BY name
@@ -337,8 +435,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -357,10 +455,11 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id = $1::uuid AND NOT is_trashed
@@ -376,10 +475,11 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id IS NULL AND NOT is_trashed
@@ -396,16 +496,18 @@ impl FolderRepository for FolderDbRepository {
// total_count is identical in every row; 0 when the result set is empty.
let total = if include_total {
Some(rows.first().map_or(0, |r| r.8) as usize)
Some(rows.first().map_or(0, |r| r.11) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.map(
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
},
)
.collect();
Ok((folders?, total))
}
@@ -424,10 +526,11 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
@@ -444,10 +547,11 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
@@ -464,41 +568,55 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
let total = if include_total {
Some(rows.first().map_or(0, |r| r.8) as usize)
Some(rows.first().map_or(0, |r| r.11) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.map(
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
},
)
.collect();
Ok((folders?, total))
}
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
async fn rename_folder(
&self,
id: &str,
new_name: String,
caller_id: Uuid,
) -> Result<Folder, DomainError> {
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
// That multi-row rewrite can deadlock against the tree-ETag
// flusher's id-ordered ancestor bump — retry instead of failing
// the user's operation (40P01 only; 23505 still maps below).
//
// §14: `updated_by = $3` (caller_id) — the caller mutated this
// row, not the row's owner. In D2 a shared-drive member can
// rename a row they don't own; the previous `updated_by = user_id`
// would have silently recorded the wrong principal.
let row = retry_on_deadlock("folders.rename", || {
sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET name = $1, updated_at = NOW()
SET name = $1, updated_at = NOW(), updated_by = $3
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
"#,
)
.bind(&new_name)
.bind(id)
.bind(caller_id)
.fetch_optional(self.pool())
})
.await
@@ -512,39 +630,68 @@ impl FolderRepository for FolderDbRepository {
})?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
)
}
async fn move_folder(
&self,
id: &str,
new_parent_id: Option<&str>,
caller_id: Uuid,
) -> Result<Folder, DomainError> {
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
//
// §14: `updated_by = $3` (caller_id), see rename_folder.
let row = retry_on_deadlock("folders.move", || {
sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET parent_id = $1::uuid, updated_at = NOW()
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = $3
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
"#,
)
.bind(new_parent_id)
.bind(id)
.bind(caller_id)
.fetch_optional(self.pool())
})
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
)
}
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
@@ -582,14 +729,22 @@ impl FolderRepository for FolderDbRepository {
Ok(())
}
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
async fn folder_exists(
&self,
storage_path: &StoragePath,
drive_id: Uuid,
) -> Result<bool, DomainError> {
let path_str = storage_path.to_string();
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
// Post-D0 `storage.folders.path` repeats across drives —
// filter by `drive_id` to scope the existence check.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM storage.folders WHERE path = $1 AND NOT is_trashed)",
"SELECT EXISTS(SELECT 1 FROM storage.folders \
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed)",
)
.bind(lookup)
.bind(drive_id)
.fetch_one(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?;
@@ -611,7 +766,7 @@ impl FolderRepository for FolderDbRepository {
// ── Trash operations ──
async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> {
async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError> {
// Soft-delete the whole subtree in one statement: the root flips
// `is_trashed` and records `original_parent_id` so restore knows
// where to put it back; every descendant (folder or file) that
@@ -626,6 +781,10 @@ impl FolderRepository for FolderDbRepository {
// `/g9-tree/file.txt` still resolved 207 even though the parent
// collection was gone) — a class of data-integrity drift that
// confused desktop-sync tree walks.
//
// §14: all three CTE branches stamp `updated_by = $2`
// (caller_id). The cascade is "the caller trashed this
// subtree", not "each owner trashed their own row".
let result = retry_on_deadlock("folders.trash", || {
sqlx::query_scalar::<_, i64>(
r#"
@@ -634,7 +793,8 @@ impl FolderRepository for FolderDbRepository {
SET is_trashed = TRUE,
trashed_at = NOW(),
original_parent_id = parent_id,
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
WHERE id = $1::uuid AND NOT is_trashed
RETURNING id, lpath
),
@@ -642,7 +802,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.folders f
SET is_trashed = TRUE,
trashed_at = NOW(),
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
FROM trash_root tr
WHERE f.lpath <@ tr.lpath
AND f.id != tr.id
@@ -653,7 +814,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.files fi
SET is_trashed = TRUE,
trashed_at = NOW(),
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
FROM trash_root tr
JOIN storage.folders f ON f.lpath <@ tr.lpath
WHERE fi.folder_id = f.id
@@ -664,6 +826,7 @@ impl FolderRepository for FolderDbRepository {
"#,
)
.bind(folder_id)
.bind(caller_id)
.fetch_one(self.pool())
})
.await
@@ -680,6 +843,7 @@ impl FolderRepository for FolderDbRepository {
&self,
folder_id: &str,
_original_path: &str,
caller_id: Uuid,
) -> Result<(), DomainError> {
// Inverse of the cascade in `move_to_trash`: restore the root
// (BEFORE UPDATE trigger recomputes path/lpath via the parent_id
@@ -689,6 +853,10 @@ impl FolderRepository for FolderDbRepository {
// *before* this folder went to trash have `original_*` set, so
// they correctly stay in trash and continue to show up as
// top-level trash entries via `storage.trash_items`.
//
// §14: all three CTE branches stamp `updated_by = $2`
// (caller_id). Restoration is "the caller restored this
// subtree", regardless of who originally owned each row.
let result = retry_on_deadlock("folders.restore", || {
sqlx::query_scalar::<_, i64>(
r#"
@@ -698,7 +866,8 @@ impl FolderRepository for FolderDbRepository {
trashed_at = NULL,
parent_id = COALESCE(original_parent_id, parent_id),
original_parent_id = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
WHERE id = $1::uuid AND is_trashed
RETURNING id, lpath
),
@@ -706,7 +875,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.folders f
SET is_trashed = FALSE,
trashed_at = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
FROM restore_root rr
WHERE f.lpath <@ rr.lpath
AND f.id != rr.id
@@ -718,7 +888,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.files fi
SET is_trashed = FALSE,
trashed_at = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = $2
FROM restore_root rr
JOIN storage.folders f ON f.lpath <@ rr.lpath
WHERE fi.folder_id = f.id
@@ -730,6 +901,7 @@ impl FolderRepository for FolderDbRepository {
"#,
)
.bind(folder_id)
.bind(caller_id)
.fetch_one(self.pool())
})
.await
@@ -775,61 +947,6 @@ impl FolderRepository for FolderDbRepository {
Ok(())
}
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError> {
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
ON CONFLICT DO NOTHING
RETURNING id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(&name)
.bind(user_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
match row {
Some((id, path, ca, ma, tma)) => {
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma)
}
None => {
// Already exists — fetch it
let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
SELECT id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
"#,
)
.bind(&name)
.bind(user_id)
.fetch_one(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?;
Self::row_to_folder(
existing.0,
name,
existing.1,
None,
Some(user_id),
existing.2,
existing.3,
existing.4,
)
}
}
}
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
///
/// Single GiST-indexed query: `fo.lpath <@ (root's lpath)`.
@@ -837,10 +954,11 @@ impl FolderRepository for FolderDbRepository {
#[allow(clippy::type_complexity)]
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.is_trashed = false \
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
@@ -855,8 +973,8 @@ impl FolderRepository for FolderDbRepository {
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -900,10 +1018,11 @@ impl FolderRepository for FolderDbRepository {
// Recursive, no folder scope → ALL user folders
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
AND fo.is_trashed = false \
@@ -927,8 +1046,8 @@ impl FolderRepository for FolderDbRepository {
return rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
})
.collect();
}
@@ -937,10 +1056,11 @@ impl FolderRepository for FolderDbRepository {
let sql = if parent_id.is_some() {
format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id = $1::uuid \
AND fo.user_id = $2 \
@@ -956,10 +1076,11 @@ impl FolderRepository for FolderDbRepository {
};
format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND fo.user_id = $1 \
@@ -999,8 +1120,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -1025,10 +1146,11 @@ impl FolderRepository for FolderDbRepository {
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
fo.user_id, fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
AND fo.is_trashed = false \
@@ -1055,8 +1177,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -1074,10 +1196,11 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id = $1::uuid
AND NOT is_trashed
@@ -1100,10 +1223,11 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id IS NULL
AND NOT is_trashed
@@ -1126,8 +1250,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -6,6 +6,7 @@ mod contact_group_pg_repository;
mod contact_persistence_dto;
mod contact_pg_repository;
mod device_code_pg_repository;
mod drive_pg_repository;
mod face_pg_repository;
mod favorites_pg_repository;
pub mod file_metadata_repository;
@@ -34,6 +35,7 @@ pub use contact_group_pg_repository::ContactGroupPgRepository;
pub use contact_persistence_dto::*;
pub use contact_pg_repository::ContactPgRepository;
pub use device_code_pg_repository::DeviceCodePgRepository;
pub use drive_pg_repository::DrivePgRepository;
pub use face_pg_repository::FacePgRepository;
pub use favorites_pg_repository::FavoritesPgRepository;
pub use file_blob_read_repository::FileBlobReadRepository;
+49 -28
View File
@@ -1927,7 +1927,7 @@ impl DedupService {
OR b.orphaned_at < now() - ($2::int * interval '1 second'))
AND NOT EXISTS (
SELECT 1 FROM storage.chunk_manifests m
WHERE m.chunk_hashes @> ARRAY[b.hash]
WHERE m.chunk_hashes @> ARRAY[b.hash::text]
)
AND NOT EXISTS (
SELECT 1 FROM storage.files f
@@ -2776,12 +2776,22 @@ mod rechunk_integration_tests {
Arc::new(pool)
}
async fn seed_user(pool: &PgPool) -> Uuid {
sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_one(pool)
.await
.map(|r| r.get::<Uuid, _>("id"))
.expect("auth.users must be seeded (init-test-schema.sh)")
/// Returns `(user_id, drive_id)`. Post-D0 every internal user has a
/// default Personal drive (provisioned by `PersonalDriveLifecycleHook`
/// during init-test-schema.sh's user seeding); the JOIN below picks
/// the user-drive pair atomically so test fixtures can insert into
/// `storage.files` with both `user_id` and `drive_id` populated.
async fn seed_user(pool: &PgPool) -> (Uuid, Uuid) {
sqlx::query(
"SELECT u.id AS user_id, d.id AS drive_id
FROM auth.users u
JOIN storage.drives d ON d.default_for_user = u.id
LIMIT 1",
)
.fetch_one(pool)
.await
.map(|r| (r.get::<Uuid, _>("user_id"), r.get::<Uuid, _>("drive_id")))
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
}
/// Plain local backend in a fresh temp dir.
@@ -2848,7 +2858,7 @@ mod rechunk_integration_tests {
.await
.expect("insert legacy blob row");
let user_id = seed_user(pool).await;
let (user_id, drive_id) = seed_user(pool).await;
let mut file_ids = Vec::new();
for i in 0..n_files {
let name = format!(
@@ -2856,11 +2866,12 @@ mod rechunk_integration_tests {
&Uuid::new_v4().to_string()[..8]
);
let id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, user_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4, $5) RETURNING id",
)
.bind(&name)
.bind(user_id)
.bind(drive_id)
.bind(&hash)
.bind(data.len() as i64)
.fetch_one(pool)
@@ -3121,12 +3132,20 @@ mod delta_upload_integration_tests {
Arc::new(pool)
}
async fn seed_user(pool: &PgPool) -> Uuid {
sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_one(pool)
.await
.map(|r| r.get::<Uuid, _>("id"))
.expect("auth.users must be seeded (init-test-schema.sh)")
/// Returns `(user_id, drive_id)` — same shape as the rechunk tests'
/// `seed_user`. Post-D0 every internal user has a default Personal
/// drive provisioned by `PersonalDriveLifecycleHook`.
async fn seed_user(pool: &PgPool) -> (Uuid, Uuid) {
sqlx::query(
"SELECT u.id AS user_id, d.id AS drive_id
FROM auth.users u
JOIN storage.drives d ON d.default_for_user = u.id
LIMIT 1",
)
.fetch_one(pool)
.await
.map(|r| (r.get::<Uuid, _>("user_id"), r.get::<Uuid, _>("drive_id")))
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
}
async fn local_svc(pool: &Arc<PgPool>, dir: &TempDir) -> DedupService {
@@ -3141,6 +3160,7 @@ mod delta_upload_integration_tests {
svc: &DedupService,
pool: &PgPool,
user_id: Uuid,
drive_id: Uuid,
data: &[u8],
label: &str,
) -> (String, Vec<String>, Uuid) {
@@ -3159,14 +3179,15 @@ mod delta_upload_integration_tests {
.expect("chunks");
let file_id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, user_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4, $5) RETURNING id",
)
.bind(format!(
"rust-test-delta-{label}-{}",
&Uuid::new_v4().to_string()[..8]
))
.bind(user_id)
.bind(drive_id)
.bind(&file_hash)
.bind(data.len() as i64)
.fetch_one(pool)
@@ -3226,13 +3247,13 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
// Owned content (multi-chunk), one foreign chunk (ref 1, no file
// row for this user), one orphan (ref 0), one unknown hash.
let data = content(3 * 1024 * 1024, 21);
let (file_hash, owned_chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "claim").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "claim").await;
assert!(owned_chunks.len() >= 3, "3 MiB must split into ≥3 chunks");
let foreign = blake3::hash(format!("foreign-{}", Uuid::new_v4()).as_bytes())
@@ -3316,12 +3337,12 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
// An owned chunk that the client redundantly re-uploads.
let data = content(100 * 1024, 22);
let (file_hash, owned_chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "loose").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "loose").await;
let owned_chunk_bytes = {
let mut stream = svc.read_blob_stream(&file_hash).await.expect("stream");
let mut out = Vec::new();
@@ -3373,7 +3394,7 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
// (A) An aged orphan (orphaned well past the grace window) with no
// references → must be collected (row + backing file).
@@ -3412,7 +3433,7 @@ mod delta_upload_integration_tests {
// stale ref_count must never delete referenced content.
let data = content(3 * 1024 * 1024, 71);
let (file_hash, owned_chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "gc").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "gc").await;
let referenced = owned_chunks[0].clone();
sqlx::query(
"UPDATE storage.blobs
@@ -3470,12 +3491,12 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
// Single-owner multi-chunk CDC file → its chunks are uniquely owned.
let data = content(3 * 1024 * 1024, 91);
let (file_hash, chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "deref").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "deref").await;
assert!(chunks.len() >= 3, "3 MiB must split into ≥3 chunks");
// The delete_file_permanently sequence: drop the file row (PG trigger)
@@ -3540,11 +3561,11 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
let data = content(2 * 1024 * 1024 + 137, 24);
let (file_hash, _chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "verify").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "verify").await;
let manifest: (Vec<String>, Vec<i64>) = sqlx::query_as(
"SELECT chunk_hashes, chunk_sizes FROM storage.chunk_manifests WHERE file_hash = $1",
@@ -64,6 +64,7 @@ impl PathResolverService {
String, // path
Option<String>, // parent_id
Option<String>, // user_id
Uuid, // drive_id
i64, // created_at
i64, // modified_at
Option<i64>, // size
@@ -72,7 +73,7 @@ impl PathResolverService {
),
>(
r#"
SELECT resource_type, id, name, path, parent_id, user_id,
SELECT resource_type, id, name, path, parent_id, user_id, drive_id,
created_at, modified_at, size, mime_type, folder_id
FROM (
SELECT 'folder'::text AS resource_type,
@@ -81,6 +82,7 @@ impl PathResolverService {
fo.path,
fo.parent_id::text,
fo.user_id::text,
fo.drive_id,
EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
NULL::bigint AS size,
@@ -102,6 +104,7 @@ impl PathResolverService {
END AS path,
NULL::text AS parent_id,
fi.user_id::text,
fi.drive_id,
EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
fi.size,
@@ -136,6 +139,7 @@ impl PathResolverService {
res_path,
parent_id,
uid,
drive_id,
created_at,
modified_at,
size,
@@ -151,12 +155,19 @@ impl PathResolverService {
path: res_path,
parent_id,
owner_id: uid,
drive_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
// §14 provenance not selected by this resolver path —
// it's used for existence/type discrimination, not
// detailed DTO emission. Callers that need provenance
// reload through the repo.
created_by: None,
updated_by: None,
})),
_ => {
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
@@ -183,6 +194,9 @@ impl PathResolverService {
sort_date: None,
content_hash: String::new(),
etag: String::new(),
// §14 provenance not selected by this resolver path
created_by: None,
updated_by: None,
}))
}
}
+68 -1
View File
@@ -259,11 +259,32 @@ impl PgAclEngine {
}
}
/// Public wrapper around `subject_match_set` for callers that need
/// the expanded `(subject_types, subject_ids)` pair without invoking
/// the engine's full `check`/`require` pipeline. Used by
/// `GET /api/drives` (and future drive-aware listing surfaces) to
/// ask the `DriveRepository` for every drive the caller can read,
/// reusing the engine's cached group-expansion logic.
pub async fn expand_subject_for_listing(
&self,
subject: Subject,
) -> Result<(Vec<&'static str>, Vec<Uuid>), DomainError> {
let counters = QueryCounters::default();
self.subject_match_set(subject, &counters).await
}
/// Returns the owner UUID for any resource type.
async fn owner_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
match resource {
Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await,
Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await,
// Drive owner resolution wires up in D0-6 once `DriveRepository`
// lands (D0-5). Drive entity carries `default_for_user` for
// `kind='personal'`; shared drives resolve through role_grants
// (Owner role). Returning NotFound here means a permission
// check that reached owner_of on a Drive falls through to the
// grant-lookup path — safe default during D0-1.
Resource::Drive(_) => Err(DomainError::not_found("Drive", resource.id().to_string())),
}
}
@@ -389,6 +410,43 @@ impl PgAclEngine {
Ok(exists.is_some())
}
/// Direct grant lookup for a drive — no ltree cascade (drives have
/// no ancestors). Mirrors the cascade helpers above but with a
/// straight `resource_type='drive' AND resource_id=$4` filter.
async fn drive_grant_exists(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
permission: Permission,
drive_id: Uuid,
counters: &QueryCounters,
) -> Result<bool, DomainError> {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let roles = Self::roles_implying_strings(permission);
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.role_grants g
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND g.role = ANY($3::storage.grant_role[])
AND g.resource_type = 'drive'
AND g.resource_id = $4
AND (g.expires_at IS NULL OR g.expires_at > NOW())
LIMIT 1
"#,
)
.bind(subject_types)
.bind(subject_ids)
.bind(&roles)
.bind(drive_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("drive grant: {e}")))?;
Ok(exists.is_some())
}
/// Look up a single role grant by id, returning the actors a revoke /
/// notify handler needs to make a decision without a second round-trip.
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
@@ -488,7 +546,12 @@ impl PgAclEngine {
) -> Result<bool, DomainError> {
// Owner short-circuit (only for User subjects — groups/tokens/external
// are never owners of resources).
if let Subject::User(uid) = subject {
// Owner short-circuit applies to Folder/File only — they carry a
// single-owner `user_id` column in their respective tables. Drives
// model ownership through the `Owner` role in `role_grants`, so
// there's no analogous fast path: the grant lookup below resolves
// a drive owner via the same query that resolves any drive role.
if let (Subject::User(uid), Resource::Folder(_) | Resource::File(_)) = (subject, resource) {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
match self.owner_of(resource).await {
Ok(owner) if owner == uid => return Ok(true),
@@ -529,6 +592,10 @@ impl PgAclEngine {
)
.await
}
Resource::Drive(id) => {
self.drive_grant_exists(&subject_types, &subject_ids, permission, id, counters)
.await
}
}
}
}
@@ -242,14 +242,15 @@ impl ContentIndexWorker {
// Authoritative state re-read: a queued 'upsert' whose row vanished
// or got trashed in the meantime becomes a delete.
let files: Vec<(Uuid, String, String, String, String, i64)> =
let files: Vec<(Uuid, String, String, String, String, String, i64)> =
if upsert_candidates.is_empty() {
Vec::new()
} else {
sqlx::query_as(
"SELECT fi.id, fi.user_id::text, fi.name, fi.blob_hash, fi.mime_type, fi.size
FROM storage.files fi
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
"SELECT fi.id, fi.user_id::text, fi.drive_id::text, fi.name,
fi.blob_hash, fi.mime_type, fi.size
FROM storage.files fi
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
)
.bind(&upsert_candidates)
.fetch_all(self.maintenance_pool.as_ref())
@@ -261,10 +262,10 @@ impl ContentIndexWorker {
// Per-blob text: batch-read the extraction cache, extract misses.
let wanted_hashes: Vec<String> = files
.iter()
.filter(|(_, _, name, _, mime, size)| {
.filter(|(_, _, _, name, _, mime, size)| {
text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes
})
.map(|f| f.3.clone())
.map(|f| f.4.clone())
.collect();
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
if !wanted_hashes.is_empty() {
@@ -281,7 +282,7 @@ impl ContentIndexWorker {
}
let mut records = Vec::with_capacity(files.len());
for (file_id, user_id, name, blob_hash, mime, size) in files {
for (file_id, user_id, drive_id, name, blob_hash, mime, size) in files {
let supported = text_extractor::supports(&name, &mime);
let content = if !supported {
None
@@ -301,6 +302,7 @@ impl ContentIndexWorker {
records.push(IndexDocRecord {
file_id: file_id.to_string(),
user_id,
drive_id,
name,
content,
preview,
@@ -37,7 +37,15 @@ use crate::common::errors::DomainError;
/// Bump whenever the Tantivy schema OR the text extractor output changes in a
/// way that requires re-indexing. A mismatch with the on-disk marker wipes the
/// index directory and reseeds the dirty queue with every live file.
pub const INDEX_SCHEMA_VERSION: &str = "1";
///
/// Version history:
/// 1 — initial schema (file_id, user_id, name, content, preview)
/// 2 — D0 added `drive_id` field; query filter pivots from user_id
/// to a `drive_id ∈ accessible_drives` set membership clause. On
/// deploy, every operator's index is wiped and reseeded against
/// the post-D0 schema (the worker drains the dirty queue with
/// drive_id-aware records).
pub const INDEX_SCHEMA_VERSION: &str = "2";
/// Recorded in `storage.blob_extracted_text.extractor`; rows from another
/// version are dropped at worker startup (the reseed re-extracts them).
@@ -73,6 +81,11 @@ const PREFIX_MIN_CHARS: usize = 3;
pub struct IndexDocRecord {
pub file_id: String,
pub user_id: String,
/// Owning drive — written verbatim into the `drive_id` STRING field
/// for set-membership filtering at query time. The user_id field is
/// kept during the D0 dual-write window for rollback safety; the
/// query filter no longer reads it.
pub drive_id: String,
pub name: String,
pub content: Option<String>,
pub preview: Option<String>,
@@ -82,6 +95,7 @@ pub struct IndexDocRecord {
struct IndexFields {
file_id: Field,
user_id: Field,
drive_id: Field,
name: Field,
content: Field,
preview: Field,
@@ -105,6 +119,7 @@ impl TantivyContentIndex {
let fields = IndexFields {
file_id: builder.add_text_field("file_id", STRING | STORED),
user_id: builder.add_text_field("user_id", STRING),
drive_id: builder.add_text_field("drive_id", STRING),
name: builder.add_text_field("name", TEXT),
content: builder.add_text_field("content", TEXT),
preview: builder.add_text_field("preview", STORED),
@@ -197,6 +212,7 @@ impl TantivyContentIndex {
let mut document = doc!(
self.fields.file_id => record.file_id,
self.fields.user_id => record.user_id,
self.fields.drive_id => record.drive_id,
self.fields.name => record.name,
);
if let Some(content) = record.content {
@@ -234,15 +250,26 @@ impl TantivyContentIndex {
/// Build the scored query: every token must match (in name OR content,
/// exact OR fuzzy OR — for the last token — prefix), and the whole thing
/// is `Must`-scoped to the user.
fn build_query(fields: IndexFields, user_id: &str, tokens: &[String]) -> Box<dyn Query> {
let mut clauses: Vec<(Occur, Box<dyn Query>)> = vec![(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_text(fields.user_id, user_id),
IndexRecordOption::Basic,
)),
)];
/// is `Must`-scoped to the caller's accessible drives.
///
/// The drive filter is expressed as a BoolQuery with `Should` arms —
/// at least one drive_id must match — wrapped under an outer `Must`.
/// Equivalent to a TermSetQuery; this form avoids the API churn of
/// rebuilding the same shape across Tantivy versions.
fn build_query(fields: IndexFields, drive_ids: &[String], tokens: &[String]) -> Box<dyn Query> {
// Drive-membership Must clause: union of Term(drive_id = $each).
let drive_alternatives: Vec<(Occur, Box<dyn Query>)> = drive_ids
.iter()
.map(|d| {
let q: Box<dyn Query> = Box::new(TermQuery::new(
Term::from_field_text(fields.drive_id, d),
IndexRecordOption::Basic,
));
(Occur::Should, q)
})
.collect();
let mut clauses: Vec<(Occur, Box<dyn Query>)> =
vec![(Occur::Must, Box::new(BooleanQuery::new(drive_alternatives)))];
let last = tokens.len().saturating_sub(1);
for (i, token) in tokens.iter().enumerate() {
@@ -306,7 +333,7 @@ impl TantivyContentIndex {
searcher: tantivy::Searcher,
analyzer: TextAnalyzer,
fields: IndexFields,
user_id: &str,
drive_ids: &[String],
raw_query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError> {
@@ -315,7 +342,7 @@ impl TantivyContentIndex {
return Ok(Vec::new());
}
let query = Self::build_query(fields, user_id, &tokens);
let query = Self::build_query(fields, drive_ids, &tokens);
let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit.max(1)).order_by_score())
.map_err(|e| DomainError::internal_error("ContentIndex", format!("search: {e}")))?;
@@ -365,18 +392,25 @@ impl TantivyContentIndex {
impl ContentIndexPort for TantivyContentIndex {
async fn search_content(
&self,
user_id: Uuid,
accessible_drive_ids: &[Uuid],
query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError> {
// No accessible drives → no hits, no Tantivy work. Matches the
// anti-enumeration semantics (empty filter set returns empty
// results without any side channel).
if accessible_drive_ids.is_empty() {
return Ok(Vec::new());
}
let searcher = self.reader.searcher();
let analyzer = self.analyzer.clone();
let fields = self.fields;
let user_id = user_id.to_string();
let drive_ids: Vec<String> = accessible_drive_ids.iter().map(|d| d.to_string()).collect();
let query = query.to_owned();
tokio::task::spawn_blocking(move || {
Self::search_blocking(searcher, analyzer, fields, &user_id, &query, limit)
Self::search_blocking(searcher, analyzer, fields, &drive_ids, &query, limit)
})
.await
.map_err(|e| DomainError::internal_error("ContentIndex", format!("join: {e}")))?
@@ -391,6 +425,10 @@ mod tests {
IndexDocRecord {
file_id: file_id.to_owned(),
user_id: user_id.to_owned(),
// Tests stamp a placeholder drive_id derived from user_id so the
// record satisfies the post-D0 schema. Query-side filtering by
// drive_id is exercised in D0-12's integration tests, not here.
drive_id: format!("{user_id}-drive"),
name: name.to_owned(),
content: content.map(str::to_owned),
preview: content.map(str::to_owned),
@@ -401,11 +439,16 @@ mod tests {
// Force a reader reload — OnCommitWithDelay is asynchronous and tests
// must observe the commit immediately.
index.reader.reload().unwrap();
// Test records derive `drive_id = format!("{user_id}-drive")` —
// the same convention used by `record()`. Filtering by that
// single drive id exercises the same path the production
// search uses.
let drive_ids = vec![format!("{user_id}-drive")];
TantivyContentIndex::search_blocking(
index.reader.searcher(),
index.analyzer.clone(),
index.fields,
user_id,
&drive_ids,
query,
32,
)
@@ -113,13 +113,15 @@ impl TreeEtagFlushService {
FROM storage.tree_etag_dirty
ORDER BY id
LIMIT $1)
RETURNING lpath, folder_id
RETURNING lpath, folder_id, drive_id
),
targets AS (
-- Captured chain: covers target folders deleted or
-- moved away since enqueue (the old location's
-- surviving ancestors still get their bump).
SELECT lpath FROM drained
-- surviving ancestors still get their bump). drive_id
-- comes along so the victims walk can enforce
-- cross-drive isolation (D0-13).
SELECT lpath, drive_id FROM drained
UNION
-- Flush-time resolution: a folder MOVED since
-- enqueue had its subtree's lpaths rewritten, so
@@ -128,19 +130,29 @@ impl TreeEtagFlushService {
-- this, a bump queued just before a move would be
-- silently lost and sync clients would never
-- discover the change.
SELECT fo.lpath
SELECT fo.lpath, fo.drive_id
FROM storage.folders fo
JOIN drained d ON fo.id = d.folder_id
),
victims AS (
-- `lpath @> target` = the target folder itself plus
-- every ancestor (GiST-indexed). Folder rows deleted
-- since enqueue simply don't match. Lock in id order
-- so overlapping closures cannot deadlock.
-- every ancestor (GiST-indexed). The `drive_id`
-- predicate prevents a numerically-overlapping
-- lpath in a SIBLING drive from spuriously matching
-- (D0-13). Rows from old queue entries (pre-M4) have
-- NULL `drive_id` — `IS NOT DISTINCT FROM` falls
-- back to pure lpath matching for those, preserving
-- the rollover semantics for any rows enqueued
-- between this migration committing and the
-- service restart.
SELECT f.id
FROM storage.folders f
WHERE EXISTS (SELECT 1 FROM targets t
WHERE f.lpath @> t.lpath)
WHERE EXISTS (
SELECT 1 FROM targets t
WHERE f.lpath @> t.lpath
AND (t.drive_id IS NULL
OR f.drive_id = t.drive_id)
)
ORDER BY f.id
FOR NO KEY UPDATE
),
@@ -417,6 +417,7 @@ impl ChunkedUploadHandler {
parts.folder_id.clone(),
ingested.content_type.clone(),
ingested.stored(),
auth_user.id,
)
.await
{
@@ -0,0 +1,72 @@
//! `GET /api/drives` — list every drive the caller can read.
//!
//! D0 ships the read-only listing; D2 adds shared-drive membership
//! mutations (`POST/DELETE/PUT /api/drives/{id}/members`), D3 adds the
//! create-shared-drive flow, etc.
//!
//! The handler resolves the caller's expanded subject set through the
//! engine (so group-mediated drive grants surface — the foundation for
//! D2/D3) and asks the `DriveRepository` for every drive that set can
//! read. Authorization is purely the subject-expansion step: no
//! `require(...)` call here, because "your accessible drives" is a
//! listing query, not a permission decision on a specific drive.
use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use tracing::error;
use crate::application::dtos::drive_dto::DriveDto;
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::Subject;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
#[utoipa::path(
get,
path = "/api/drives",
responses(
(status = 200, description = "Drives the caller can read", body = Vec<DriveDto>),
(status = 500, description = "Internal server error"),
),
security(("bearerAuth" = [])),
tag = "drives"
)]
pub async fn list_drives(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
) -> impl IntoResponse {
let caller_id = auth_user.id;
// Expand the caller's `Subject::User` into the `(types, ids)` pair
// that includes every group the user transitively belongs to. The
// engine caches this expansion in its Moka cache; if the caller
// just ran a permission check, this is a hit.
let (subject_types, subject_ids) = match state
.authorization
.expand_subject_for_listing(Subject::User(caller_id))
.await
{
Ok(pair) => pair,
Err(e) => {
error!("list_drives: subject expansion failed: {e}");
return AppError::from(e).into_response();
}
};
match state
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
{
Ok(drives) => {
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => {
error!("list_drives: repo lookup failed: {e}");
AppError::internal_error(format!("Failed to list drives: {e}")).into_response()
}
}
}
@@ -262,12 +262,20 @@ pub async fn list_favorites_resources(
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Listing handler — drive_id is informational
// and the favorites row doesn't currently
// SELECT it. Path-based lookups never enter
// this code path.
drive_id: uuid::Uuid::nil(),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
// §14 provenance not selected by the favorites query.
created_by: None,
updated_by: None,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -311,6 +319,9 @@ pub async fn list_favorites_resources(
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the favorites query.
created_by: None,
updated_by: None,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -284,6 +284,7 @@ impl FileHandler {
folder_id,
ingested.content_type.clone(),
ingested.stored(),
auth_user.id,
)
.await
{
@@ -753,12 +753,19 @@ pub async fn list_folder_resources(
path: String::new(), // cleared — share recipients must not see hierarchy
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Resources listing — drive_id is informational
// here; not selected by the underlying query.
// Path-based lookups never enter this code path.
drive_id: uuid::Uuid::nil(),
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
// §14 provenance not selected by the resources query.
created_by: None,
updated_by: None,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -801,6 +808,9 @@ pub async fn list_folder_resources(
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the resources query.
created_by: None,
updated_by: None,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -719,6 +719,13 @@ pub async fn list_shared_with_me(
summary.resource_id
),
},
// Drive grants don't appear in the file/folder "Shared with me"
// listing — they're surfaced through `GET /api/drives` (D0).
// Silently skipping here is the right behaviour: a drive grant
// discovered by `list_incoming_resources_paged` is not a stale
// grant, just a different resource type with a different
// listing surface.
ResourceKind::Drive => continue,
}
}
@@ -953,6 +960,10 @@ pub async fn list_my_shares(
summary.resource_id
),
},
// Drive grants are surfaced via `GET /api/drives` (D0), not
// through the My Shares outgoing-resources surface. Silently
// skip — symmetric with the `list_shared_with_me` arm above.
ResourceKind::Drive => continue,
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod contacts_handler;
pub mod dedup_handler;
pub mod delta_upload_handler;
pub mod device_auth_handler;
pub mod drive_handler;
pub mod favorites_handler;
pub mod file_handler;
pub mod folder_handler;
@@ -292,12 +292,20 @@ pub async fn list_recent_resources(
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Listing handler — drive_id is informational
// and the recents row doesn't currently SELECT
// it. Path-based lookups never enter this code
// path.
drive_id: uuid::Uuid::nil(),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -339,6 +347,9 @@ pub async fn list_recent_resources(
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
+107 -22
View File
@@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
@@ -247,6 +248,29 @@ async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -
}
}
/// 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/<uuid>/…`)
/// 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(
state: &Arc<AppState>,
user_id: Uuid,
) -> Result<Uuid, AppError> {
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)))
}
async fn handle_webdav_dispatch(
state: Arc<AppState>,
req: Request<Body>,
@@ -405,12 +429,18 @@ async fn handle_propfind(
path: "".to_string(),
parent_id: None,
owner_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(
@@ -468,8 +498,11 @@ async fn handle_propfind(
Err(_) => {}
}
} else {
// Fallback: legacy double-query path when PathResolver is unavailable
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
// Fallback: legacy double-query path when PathResolver is unavailable.
// `drive_id` is mandatory post-D0 for path-based lookups — derive
// the caller's default drive once and reuse it for both probes.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
let folder_id = folder.id.clone();
return build_streaming_propfind_response(
@@ -484,7 +517,10 @@ async fn handle_propfind(
)
.await;
}
if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await {
if let Ok(file) = file_retrieval_service
.get_file_by_path(&path, drive_id)
.await
{
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
let mut buf = Vec::with_capacity(1024);
{
@@ -656,7 +692,7 @@ async fn handle_proppatch(
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let _user = extract_user(&req)?;
let user = extract_user(&req)?;
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
// so a lock on the target must release them via `If:`. Captured
@@ -688,10 +724,11 @@ async fn handle_proppatch(
let is_collection = if path.is_empty() || path == "/" {
true
} else {
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
state
.applications
.folder_service
.get_folder_by_path(&path)
.get_folder_by_path(&path, drive_id)
.await
.is_ok()
};
@@ -776,9 +813,12 @@ async fn handle_get(
}
}
} else {
// Legacy fallback — fetch + ownership check
// Legacy fallback — fetch + ownership check. `drive_id` is the
// path-lookup scope post-D0 (`storage.files.path` repeats across
// drives), derived once from the caller's default drive.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let f = file_retrieval_service
.get_file_by_path(&path)
.get_file_by_path(&path, drive_id)
.await
.map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?;
assert_owner(f.owner_id.as_deref(), &user.id.to_string(), &path)?;
@@ -876,8 +916,11 @@ async fn handle_head(
}
}
// Fallback: legacy double-query path (with ownership check)
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
// Fallback: legacy double-query path (with ownership check).
// `drive_id` is the path-lookup scope post-D0 — derive once and
// reuse for both the folder and file probes.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
return Ok(Response::builder()
.status(StatusCode::OK)
@@ -890,7 +933,7 @@ async fn handle_head(
// Try as file — use metadata only, never load content for HEAD
let file = file_retrieval_service
.get_file_by_path(&path)
.get_file_by_path(&path, drive_id)
.await
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
@@ -939,15 +982,27 @@ async fn resolve_or_legacy(
return Some(r);
}
// Path-lookup scope post-D0 — derive the caller's default drive
// for both legacy probes. `find_default_for_user` returning Err
// (e.g. external user, or boot before the lifecycle hook fired)
// means no fallback resolution is possible: return None.
let drive_id = state
.drive_repo
.find_default_for_user(user_id)
.await
.ok()?
.drive
.id;
let user_id_str = user_id.to_string();
let folder_service = &state.applications.folder_service;
if let Ok(folder) = folder_service.get_folder_by_path(path).await
if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await
&& folder.owner_id.as_deref() == Some(&user_id_str)
{
return Some(ResolvedResource::Folder(folder));
}
let file_retrieval = &state.applications.file_retrieval_service;
if let Ok(file) = file_retrieval.get_file_by_path(path).await
if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await
&& file.owner_id.as_deref() == Some(&user_id_str)
{
return Some(ResolvedResource::File(file));
@@ -1143,8 +1198,16 @@ async fn handle_put(
// ── Atomic store: swap the file row onto the ingested blob ──
let content_type = ingested.content_type.clone();
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let result = file_upload_service
.update_file_streaming(&path, ingested.stored(), &content_type, None)
.update_file_streaming(
&path,
drive_id,
ingested.stored(),
&content_type,
None,
user.id,
)
.await;
match result {
@@ -1198,6 +1261,9 @@ async fn handle_mkcol(
// Path is already translated by dispatch (e.g. "My Folder - jared/03/01").
// Walk each segment: the first is the home folder (already exists),
// subsequent segments are created as needed with proper parent_id.
// `drive_id` scopes each per-segment path probe to the caller's default
// drive (post-D0 invariant: `storage.folders.path` repeats across drives).
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut parent_id: Option<String> = None;
let mut accumulated_path = String::new();
@@ -1208,7 +1274,10 @@ async fn handle_mkcol(
}
accumulated_path.push_str(segment);
match folder_service.get_folder_by_path(&accumulated_path).await {
match folder_service
.get_folder_by_path(&accumulated_path, drive_id)
.await
{
Ok(existing) => {
parent_id = Some(existing.id);
}
@@ -1392,6 +1461,11 @@ async fn handle_move(
let file_management_service = &state.applications.file_management_service;
let folder_service = &state.applications.folder_service;
// `drive_id` scopes every path-based lookup below to the caller's
// default drive (post-D0 invariant: `storage.{files,folders}.path`
// repeats across drives).
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
// Check if destination already exists (for Overwrite header compliance)
if !overwrite {
let dest_exists = if let Some(resolver) = &state.path_resolver {
@@ -1401,11 +1475,11 @@ async fn handle_move(
.unwrap_or(false)
} else {
folder_service
.get_folder_by_path(&destination_path)
.get_folder_by_path(&destination_path, drive_id)
.await
.is_ok()
|| file_retrieval_service
.get_file_by_path(&destination_path)
.get_file_by_path(&destination_path, drive_id)
.await
.is_ok()
};
@@ -1443,7 +1517,9 @@ async fn handle_move(
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
parent_id: if dest_parent_path.is_empty() {
None
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await
} else if let Ok(parent) = folder_service
.get_folder_by_path(dest_parent_path, drive_id)
.await
{
assert_owner(
parent.owner_id.as_deref(),
@@ -1483,7 +1559,7 @@ async fn handle_move(
None
} else {
let parent = folder_service
.get_folder_by_path(dest_parent_path)
.get_folder_by_path(dest_parent_path, drive_id)
.await
.map_err(|_| {
AppError::not_found(format!(
@@ -1601,6 +1677,11 @@ async fn handle_copy(
let file_retrieval_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// `drive_id` scopes every path-based lookup below to the caller's
// default drive (post-D0 invariant: `storage.{files,folders}.path`
// repeats across drives).
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
// Check if destination already exists (for Overwrite header compliance)
if !overwrite {
let dest_exists = if let Some(resolver) = &state.path_resolver {
@@ -1610,11 +1691,11 @@ async fn handle_copy(
.unwrap_or(false)
} else {
folder_service
.get_folder_by_path(&destination_path)
.get_folder_by_path(&destination_path, drive_id)
.await
.is_ok()
|| file_retrieval_service
.get_file_by_path(&destination_path)
.get_file_by_path(&destination_path, drive_id)
.await
.is_ok()
};
@@ -1644,7 +1725,10 @@ async fn handle_copy(
let target_parent_id = if dest_parent_path.is_empty() {
None
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await {
} else if let Ok(parent) = folder_service
.get_folder_by_path(dest_parent_path, drive_id)
.await
{
assert_owner(
parent.owner_id.as_deref(),
&user.id.to_string(),
@@ -1740,10 +1824,11 @@ async fn handle_lock(
let is_collection = if path.is_empty() || path == "/" {
true
} else {
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
state
.applications
.folder_service
.get_folder_by_path(&path)
.get_folder_by_path(&path, drive_id)
.await
.is_ok()
};
+29 -1
View File
@@ -23,6 +23,7 @@ use std::sync::Arc;
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::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
/// Shared state for WOPI handlers.
@@ -233,11 +234,38 @@ async fn put_file(
};
// ── Atomic store: swap the file row onto the ingested blob ──
// `drive_id` scopes the path-based lookups in `update_file_streaming`
// post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve
// that to the caller's default drive (WOPI today is a single-drive
// editing surface — no drive marker travels in the token).
let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) {
Ok(u) => u,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
let drive_id = match state
.app_state
.drive_repo
.find_default_for_user(claims_sub_uuid)
.await
{
Ok(d) => d.drive.id,
Err(e) => {
tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let result = state
.app_state
.applications
.file_upload_service
.update_file_streaming(&file.path, ingested.stored(), &content_type, None)
.update_file_streaming(
&file.path,
drive_id,
ingested.stored(),
&content_type,
None,
claims_sub_uuid,
)
.await;
match result {
+6
View File
@@ -13,6 +13,7 @@ use utoipa::{Modify, OpenApi};
use crate::application::dtos::contact_dto::{
AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto,
};
use crate::application::dtos::drive_dto::{DriveDto, DriveKindDto};
use crate::application::dtos::favorites_dto::{
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoritesResourceItemDto,
};
@@ -165,6 +166,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
// Photos handler (free function)
handlers::photos_handler::list_photos,
handlers::photos_handler::list_photos_geo,
// Drive handler (free function)
handlers::drive_handler::list_drives,
// Batch handlers (free functions)
handlers::batch_handler::move_files_batch,
handlers::batch_handler::copy_files_batch,
@@ -359,6 +362,9 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
SharedWithMeDto,
SharedWithMeItemDto,
OutgoingResourceItemDto,
// Drive schemas
DriveDto,
DriveKindDto,
// Subject-group (ReBAC named groups) schemas
handlers::subject_group_handler::CreateGroupRequest,
handlers::subject_group_handler::UpdateGroupRequest,
+13
View File
@@ -440,6 +440,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
router = router.nest("/photos", photos_router);
}
// Drives — every drive the caller can read. D0 ships the read-only
// listing; D2 adds the membership API + shared-drive endpoints under
// `/api/drives/{id}/members`.
{
use crate::interfaces::api::handlers::drive_handler;
let drives_router = Router::new()
.route("/", get(drive_handler::list_drives))
.with_state(app_state.clone());
router = router.nest("/drives", drives_router);
}
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
if app_state.people_service.is_some() {
use crate::interfaces::api::handlers::people_handler;
+2 -1
View File
@@ -103,7 +103,8 @@ impl<B> MakeSpan<B> for ClientIpMakeSpan {
method = %request.method(),
uri = %request.uri().path(),
user_id = tracing::field::Empty,
// The Nextcloud chroot folder id, set by `basic_auth_middleware`.
// The Nextcloud chroot folder id, set by `basic_auth_middleware` (will be the Drive Id in the future).
chroot_id = tracing::field::Empty,
)
}
@@ -101,6 +101,11 @@ pub async fn handle_avatar(
) -> Response {
let size = size.clamp(16, 1024);
let username = match username.split_once("~") {
None => username,
Some((u, _)) => u.to_string(),
};
// ── Stored profile image — preferred when present ───────────
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Ok(user) = auth_svc
@@ -59,9 +59,43 @@ pub async fn basic_auth_middleware(
NextcloudAuthError::Unauthorized
})?;
let (username, password) =
let (raw_username, password) =
parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?;
// ── Multi-drive composite-username parse ────────────────────────
// POC wire shape: `{username}~{drive_marker}` may appear in the
// Basic Auth header. `~` was chosen because it needs no URL
// encoding and doesn't collide with UUID hyphens. The marker
// after `~` is a chroot SELECTOR (handled by `NcSession` via the
// URL `{user}` segment), NOT an auth credential — the password
// is verified against the username PREFIX. The middleware just
// peels the prefix off so the app-password lookup uses the
// canonical name. When no `~` is present, the request is a
// plain single-drive ("home") NC sync.
//
// Reject `name~` (empty marker) and `~marker` (empty username)
// at the auth boundary rather than treating them as "missing
// marker" — they are unambiguous typos that would otherwise
// silently fall into a different code path.
let (username, drive_marker): (String, Option<String>) = match raw_username.split_once('~') {
Some(("", _)) => {
tracing::warn!(
"[NC] 401 malformed composite username (empty prefix): {}",
raw_username
);
return Err(NextcloudAuthError::Unauthorized);
}
Some((_, "")) => {
tracing::warn!(
"[NC] 401 malformed composite username (empty marker): {}",
raw_username
);
return Err(NextcloudAuthError::Unauthorized);
}
Some((u, m)) => (u.to_string(), Some(m.to_string())),
None => (raw_username.clone(), None),
};
// Check account lockout before attempting password verification (saves CPU).
// The lockout is per (account, IP), see #323 for rationale.
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
@@ -126,12 +160,81 @@ pub async fn basic_auth_middleware(
// making it harder to correlate WebDAV / OCS activity to
// a specific principal.
tracing::Span::current().record("user_id", user_id.to_string());
request.extensions_mut().insert(Arc::new(CurrentUser {
let current_user = CurrentUser {
id: user_id,
username: uname,
email,
role,
}));
};
// ── Resolve chroot from the Basic Auth drive marker ─────
// No marker → caller's default personal drive's root folder
// (post-D0 every internal user has one — provisioned by the
// lifecycle hook via the atomic four-write transaction in
// §3 of docs/plan/drive.md). With a marker →
// `get_folder_with_perms` enforces per-folder access (404
// anti-enumeration on miss / no-read). Today this is the
// sole chroot source; tomorrow it'll come from the
// app-password row instead.
//
// Pre-D0 this lookup name-matched `"My Folder - <username>"`
// against the user's root folders; that broke after the
// wrapper was renamed to `"Personal"` and shared across all
// users — name-matching was the wrong axis. The drive lookup
// is the right one: name-independent, secondary-drive-safe.
use crate::application::ports::folder_ports::FolderUseCase;
use crate::domain::repositories::drive_repository::DriveRepository;
let chroot = match drive_marker.as_deref() {
None => {
match state
.drive_repo
.find_default_for_user(current_user.id)
.await
{
Ok(drive_with_name) => state
.applications
.folder_service
.get_folder(&drive_with_name.drive.root_folder_id.to_string())
.await
.ok(),
Err(_) => None,
}
}
Some(folder_id) => state
.applications
.folder_service
.get_folder_with_perms(folder_id, current_user.id)
.await
.ok(),
};
if chroot.is_none() {
tracing::warn!(
"[NC] 404 chroot not resolvable: user={} marker={:?}",
current_user.username,
drive_marker
);
return Err(NextcloudAuthError::Unauthorized);
}
request
.extensions_mut()
.insert(Arc::new(current_user.clone()));
request.extensions_mut().insert(Arc::new(
crate::interfaces::nextcloud::session::NcSession {
user: current_user,
raw_username: raw_username.clone(),
chroot,
},
));
tracing::Span::current().record(
"chroot_id",
request
.extensions()
.get::<Arc<crate::interfaces::nextcloud::session::NcSession>>()
.and_then(|s| s.chroot.as_ref())
.map(|c| c.id.to_string())
.unwrap_or_default(),
);
Ok(next.run(request).await)
}
Err(_) => {
+287 -15
View File
@@ -1,3 +1,4 @@
use askama::Template;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
@@ -7,8 +8,32 @@ use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::common::di::AppState;
use crate::common::errors::DomainError;
use crate::interfaces::middleware::auth::CurrentUser;
/// Drive option rendered on the picker page. `name` is the folder's
/// display name; `id` is the folder UUID that becomes the `~{marker}`
/// half of the composite Basic-Auth username if the user picks
/// anything other than the first (home) row.
struct DriveOption {
id: String,
name: String,
}
#[derive(Template)]
#[template(path = "nextcloud/drive_picker.html")]
struct DrivePickerTemplate {
form_action: String,
drives: Vec<DriveOption>,
}
// Home identification is via `position_of_user_home_root_folder` from
// `domain::repositories::drive_repository` — a generic helper that
// keys off `drives.default_for_user == user_id` rather than folder
// name, so user renames of the home folder don't silently break the
// picker UX.
/// Serve an HTML page with a Content-Security-Policy header as defense-in-depth.
fn html_with_csp(html: &'static str) -> Response {
@@ -175,47 +200,294 @@ pub async fn handle_login_submit(
Err(e) => return login_failed_response(e),
};
let app_password = match nextcloud
.app_passwords
.create_nc(current_user.id, "Nextcloud")
// ── Multi-drive fork ─────────────────────────────────────────────
// List the user's root folders. By convention the first row is the
// user's home; additional rows are extra drives (POC seeded by
// direct DB insert until a drive admin surface exists). With 0 or
// 1 drive we go straight to the legacy one-shot completion path so
// the common case stays one click. With ≥2 drives we pause the
// flow, stash the user_id, and render the picker — drive selection
// resumes the flow via `handle_drive_pick`.
let mut drives = match state
.applications
.folder_service
.list_folders_with_perms(None, current_user.id)
.await
{
Ok((_id, password)) => password,
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to create app password");
tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to list drives");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let base_url = state.core.config.base_url();
let completed =
nextcloud
if drives.len() >= 2 {
// Reorder so home is at index 0. The picker template ties
// both the default-checked radio and the "Home" badge to
// `loop.first`, so placing home first is the single point
// that makes the picker UI line up with the home convention.
// Other drives keep their original alphabetical order.
if let Some(idx) =
crate::domain::repositories::drive_repository::position_of_user_home_root_folder(
state.drive_repo.as_ref(),
current_user.id,
&drives,
|f| uuid::Uuid::parse_str(&f.id).ok(),
)
.await
&& idx != 0
{
let home = drives.remove(idx);
drives.insert(0, home);
}
// If no home matched the convention, we fall through with the
// raw alphabetical order. The picker will still work but the
// first row gets the badge by default — slightly wrong UX but
// never breaks the auth flow (`handle_drive_pick` re-runs
// `find_home_index` independently).
if !nextcloud
.login_flow
.complete(&token, &current_user.username, &base_url, &app_password);
.mark_awaiting_drive(&token, current_user.id)
{
// Flow token vanished (TTL?) between password submit and
// here — extremely unlikely but treat the same as any
// session-expired case.
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
.into_response();
}
return render_drive_picker(&token, &drives);
}
complete_flow(&state, &nextcloud.login_flow, &token, &current_user, None).await
}
/// Render the drive picker page. The form posts to
/// `/login/v2/flow/{token}/drive`, carrying only the chosen folder
/// UUID — the authenticated user id is read from the flow's
/// `pending_user_id` slot (consumed by `take_pending_user`).
fn render_drive_picker(
token: &str,
drives: &[crate::application::dtos::folder_dto::FolderDto],
) -> Response {
let template = DrivePickerTemplate {
form_action: format!("/login/v2/flow/{}/drive", token),
drives: drives
.iter()
.map(|f| DriveOption {
id: f.id.clone(),
name: f.name.clone(),
})
.collect(),
};
match template.render() {
Ok(html) => (
[(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'",
)],
Html(html),
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Login Flow v2: drive picker template render failed");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Mint an app password, complete the flow, and emit the `nc://` deep
/// link. Shared by the single-drive path (called from
/// `handle_login_submit`) and the post-picker path (called from
/// `handle_drive_pick`).
///
/// `drive_id` is `None` for the single-drive shortcut and for the
/// home-drive choice on the picker; `Some(uuid)` for any other drive,
/// in which case the NC login name carries the `~{uuid}` marker.
async fn complete_flow(
state: &Arc<AppState>,
login_flow: &crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService,
token: &str,
user: &CurrentUser,
drive_id: Option<&str>,
) -> Response {
let nextcloud = match state.nextcloud.as_ref() {
Some(nc) => nc,
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
};
let app_password = match nextcloud
.app_passwords
.create_nc(user.id, "Nextcloud")
.await
{
Ok((_id, password)) => password,
Err(e) => {
tracing::error!(error = %e, user = %user.username, "Login Flow v2: failed to create app password");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let login_name = match drive_id {
Some(uuid) => format!("{}~{}", user.username, uuid),
None => user.username.clone(),
};
let base_url = state.core.config.base_url();
let completed = login_flow.complete(token, &login_name, &base_url, &app_password);
if completed {
tracing::info!(
user = %current_user.username,
user = %user.username,
login_name = %login_name,
base_url = %base_url,
"Login Flow v2: flow completed successfully"
);
// Redirect to nc:// deep link so the Nextcloud mobile app receives
// the credentials via Android/iOS intent. Desktop clients use polling
// instead, so they will pick up the result from the poll endpoint.
let nc_url = format!(
"nc://login/server:{}&user:{}&password:{}",
base_url, current_user.username, app_password
base_url, login_name, app_password
);
axum::response::Redirect::to(&nc_url).into_response()
} else {
tracing::error!(
user = %current_user.username,
user = %user.username,
"Login Flow v2: complete() returned false — flow token not found"
);
axum::response::Redirect::to("/nextcloud-error.html?type=session-expired").into_response()
}
}
/// POST `/login/v2/flow/{token}/drive` — finalise a paused login flow
/// after the user picks a drive on the picker page.
///
/// Auth model: the route is **public** (no Basic Auth — this is the
/// browser-side leg of Login Flow v2, before the app password is
/// issued). The proof of authentication is the single-use
/// `pending_user_id` slot on the flow, set by `handle_login_submit`
/// after password verification and consumed here. Replay is naturally
/// blocked: a second POST finds nothing to consume.
pub async fn handle_drive_pick(
State(state): State<Arc<AppState>>,
Path(token): Path<String>,
body: String,
) -> Response {
let nextcloud = match state.nextcloud.as_ref() {
Some(nc) => nc,
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
};
let drive_id = match parse_form_value(&body, "drive") {
Some(v) if !v.is_empty() => v,
_ => return StatusCode::BAD_REQUEST.into_response(),
};
let user_id = match nextcloud.login_flow.take_pending_user(&token) {
Some(uid) => uid,
None => {
tracing::warn!(
target: "audit",
event = "nc_login_flow.drive_pick_rejected",
reason = "no_pending_user",
"👮🏻‍♂️ NC drive pick rejected: flow has no pending user (replay or unknown token)"
);
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
.into_response();
}
};
// Resolve user (for username) and validate drive ownership in one
// service call each. `get_folder_with_perms` enforces that the
// caller can read the folder — covers "drive doesn't exist" and
// "drive belongs to someone else" with the same 404 to defeat
// enumeration. We additionally need to differentiate home vs.
// non-home so the NC login name carries `~{uuid}` only for
// non-home choices.
let auth = match state.auth_service.as_ref() {
Some(a) => a,
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
};
let user_dto = match auth.auth_application_service.get_user_by_id(user_id).await {
Ok(u) => u,
Err(e) => {
tracing::error!(error = %e, %user_id, "Login Flow v2: failed to fetch user for drive pick");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
// Username must be present — only password-login users reach this
// branch, and password login requires a claimed username. Defensive
// check anyway: a username-less user here means an upstream invariant
// broke, not something to silently paper over.
let Some(username) = user_dto.username.clone() else {
tracing::error!(%user_id, "Login Flow v2: pending user has no username — invariant violated");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let user = CurrentUser {
id: user_id,
username,
email: user_dto.email.clone(),
role: user_dto.role.clone(),
};
let _folder = match state
.applications
.folder_service
.get_folder_with_perms(&drive_id, user_id)
.await
{
Ok(f) => f,
Err(_) => {
tracing::warn!(
target: "audit",
event = "nc_login_flow.drive_pick_rejected",
reason = "drive_not_owned_or_missing",
%user_id,
drive_id = %drive_id,
"👮🏻‍♂️ NC drive pick rejected: folder missing or caller has no read access"
);
return StatusCode::NOT_FOUND.into_response();
}
};
// Determine if the pick is home. The previous "first row of
// list_folders_with_perms" heuristic was wrong: the underlying
// repo query orders by `name`, so any drive named alphabetically
// before "My Folder - {username}" stole the first slot and was
// mis-classified as home — `login_name` then dropped the `~uuid`
// marker and NC desktop rooted at the home folder regardless of
// the user's pick. `find_home_index` keys off the registered
// home-folder name, which extra drives (POC SQL-seeded) don't
// share, so it disambiguates cleanly.
let drives = match state
.applications
.folder_service
.list_folders_with_perms(None, user_id)
.await
{
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, %user_id, "Login Flow v2: failed to list drives for home detection");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let home_id = crate::domain::repositories::drive_repository::position_of_user_home_root_folder(
state.drive_repo.as_ref(),
user.id,
&drives,
|f| uuid::Uuid::parse_str(&f.id).ok(),
)
.await
.map(|i| drives[i].id.as_str());
let is_home = home_id == Some(drive_id.as_str());
let drive_marker = if is_home {
None
} else {
Some(drive_id.as_str())
};
complete_flow(&state, &nextcloud.login_flow, &token, &user, drive_marker).await
}
/// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is
/// tied to a Nextcloud Login Flow v2 session. After successful IdP
/// authentication the regular `/api/auth/oidc/callback` endpoint will detect
+1
View File
@@ -5,6 +5,7 @@ pub mod ocs_handler;
pub mod preview_handler;
pub mod report_handler;
pub mod routes;
pub mod session;
pub mod status_handler;
pub mod trashbin_handler;
pub mod uploads_handler;
+37 -12
View File
@@ -46,9 +46,12 @@ pub async fn handle_capabilities_v2(State(state): State<Arc<AppState>>) -> Respo
Json(payload).into_response()
}
pub async fn handle_user_info(State(state): State<Arc<AppState>>, user: AuthUser) -> Response {
pub async fn handle_user_info(
State(state): State<Arc<AppState>>,
session: crate::interfaces::nextcloud::session::NcSession,
) -> Response {
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
Some(service) => match service.get_user_storage_info(user.id).await {
Some(service) => match service.get_user_storage_info(session.user.id).await {
Ok((used, total)) => (used, total),
Err(_) => (0, 0),
},
@@ -62,15 +65,36 @@ pub async fn handle_user_info(State(state): State<Arc<AppState>>, user: AuthUser
0.0
};
// `id` MUST echo the raw wire username the client used at Basic
// Auth time — NC desktop reads `data.id` from this endpoint and
// splices it into every subsequent WebDAV path it builds
// (`/remote.php/dav/files/{id}/…`). Returning the bare canonical
// username on a `~{uuid}` session would make the client strip
// the marker and revert to the home drive.
//
// Display fields stay short on the default drive (bare
// username); on a marker session we render `username@<drive>`
// using the resolved chroot's stored name, which is friendlier
// than the raw UUID the wire form carries.
let id = session.raw_username.clone();
let displayname = if session.is_home() {
session.user.username.clone()
} else {
match session.chroot.as_ref() {
Some(chroot) => format!("{}@{}", session.user.username, chroot.name),
None => session.user.username.clone(),
}
};
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
"data": {
"enabled": true,
"id": user.username,
"display-name": user.username,
"displayname": user.username,
"email": user.email,
"id": id,
"display-name": displayname,
"displayname": displayname,
"email": session.user.email,
"quota": {
"used": quota.0,
"total": quota.1,
@@ -399,11 +423,12 @@ pub async fn handle_search(
let mut entries: Vec<serde_json::Value> = 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.
for file in &results.files {
let display_path = file
.path
.strip_prefix(&format!("My Folder - {}/", user.username))
.unwrap_or(&file.path);
let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path);
let display_path = format!("/{}", display_path);
let numeric_id = file_id_map.get(&file.id).copied();
@@ -427,11 +452,11 @@ pub async fn handle_search(
}));
}
// Map folder results
// Map folder results — same TODO(D1) as above.
for folder in &results.folders {
let display_path = folder
.path
.strip_prefix(&format!("My Folder - {}/", user.username))
.strip_prefix("Personal/")
.unwrap_or(&folder.path);
let display_path = format!("/{}", display_path);
+75 -29
View File
@@ -22,7 +22,6 @@ use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
use crate::interfaces::nextcloud::webdav_handler::{
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
};
@@ -35,7 +34,7 @@ use crate::interfaces::nextcloud::webdav_handler::{
pub async fn handle_nc_report(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
_subpath: &str,
) -> Result<Response<Body>, AppError> {
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
@@ -45,9 +44,9 @@ pub async fn handle_nc_report(
let body_str = String::from_utf8_lossy(&body_bytes);
if body_str.contains("filter-files") {
handle_filter_files(state, &body_str, user).await
handle_filter_files(state, &body_str, session).await
} else if body_str.contains("searchrequest") {
handle_search(state, &body_str, user).await
handle_search(state, &body_str, session).await
} else {
// Unknown REPORT type -- return empty multistatus.
Ok(empty_multistatus())
@@ -59,8 +58,10 @@ pub async fn handle_nc_report(
async fn handle_filter_files(
state: Arc<AppState>,
_body: &str,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let url_user = &session.raw_username;
let fav_svc = match state.favorites_service.as_ref() {
Some(svc) => svc,
None => return Ok(empty_multistatus()),
@@ -83,7 +84,11 @@ async fn handle_filter_files(
// All items in this response are favorites.
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
let home_prefix = format!("My Folder - {}/", user.username);
// 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/";
// 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
@@ -145,9 +150,13 @@ async fn handle_filter_files(
write_multistatus_start(&mut xml)?;
// Keep main's batched-resolution structure (one batch query
// per type, not 2N round-trips). Hrefs use `url_user` so the
// 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);
let href = nc_href(&user.username, subpath);
let subpath = strip_home_prefix(&file.path, home_prefix);
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));
write_file_response(
@@ -163,8 +172,8 @@ async fn handle_filter_files(
}
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let subpath = strip_home_prefix(&folder.path, home_prefix);
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));
write_folder_response(
@@ -195,8 +204,13 @@ async fn handle_filter_files(
async fn handle_search(
state: Arc<AppState>,
body: &str,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, 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()?;
let url_user = &session.raw_username;
let search_svc = match state.applications.search_service.as_ref() {
Some(svc) => svc,
None => return Ok(empty_multistatus()),
@@ -210,7 +224,7 @@ async fn handle_search(
let nresults = parse_nresults(body).unwrap_or(100);
// Resolve folder scope from <d:href> inside <d:scope>.
let folder_id = resolve_scope_folder(&state, body, &user.username).await;
let folder_id = resolve_scope_folder(&state, body, session).await;
let criteria = SearchCriteriaDto {
name_contains: Some(term),
@@ -227,7 +241,10 @@ async fn handle_search(
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let home_prefix = format!("My Folder - {}/", user.username);
// 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/";
// No favorite checking for search results -- pass an empty set.
let favorite_ids: HashSet<String> = HashSet::new();
@@ -249,8 +266,8 @@ async fn handle_search(
// Files.
for file in &files {
let subpath = strip_home_prefix(&file.path, &home_prefix);
let href = nc_href(&user.username, subpath);
let subpath = strip_home_prefix(&file.path, home_prefix);
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));
write_file_response(
@@ -267,8 +284,8 @@ async fn handle_search(
// Folders.
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let subpath = strip_home_prefix(&folder.path, home_prefix);
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));
write_folder_response(
@@ -327,6 +344,9 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
sort_date: None,
content_hash: fr.blob_hash.clone(),
etag,
// §14 provenance not selected by the search result DTO.
created_by: None,
updated_by: None,
}
}
@@ -341,12 +361,19 @@ fn folder_dto_from_search(
path: sr.path.clone(),
parent_id: sr.parent_id.clone(),
owner_id: None,
// Search result — drive_id is informational. The search row
// doesn't currently SELECT it, and path-based lookups never
// enter this code path.
drive_id: uuid::Uuid::nil(),
created_at: sr.created_at,
modified_at: sr.modified_at,
is_root: sr.is_root,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
// §14 provenance not selected by search results.
created_by: None,
updated_by: None,
}
}
@@ -461,23 +488,39 @@ fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
}
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option<String> {
///
/// Pulls everything it needs from the `NcSession`: the caller's id (so
/// `get_folder_by_path` can be user-scoped — post-D0 paths like
/// `Personal/Docs` are not globally unique), the chroot (provides the
/// path prefix that `nc_to_internal_path` prepends), and the raw wire
/// `{user}` segment (bare or `admin~{uuid}`) so we strip the prefix the
/// NC client actually sent.
async fn resolve_scope_folder(
state: &AppState,
body: &str,
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Option<String> {
let chroot = session.require_chroot().ok()?;
let url_user = &session.raw_username;
let href = parse_scope_href(body)?;
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
let subpath = extract_subpath_from_scope(&href, username)?;
// The href is typically `/files/{url_user}/subpath` or
// `/remote.php/dav/files/{url_user}/subpath`. On a multi-drive
// session the `{url_user}` segment carries the `~{uuid}` marker,
// so we strip with the composite to find the real subpath. Using
// `user.username` here would fail to match for non-home drives.
let subpath = extract_subpath_from_scope(&href, url_user)?;
if subpath.is_empty() {
// Root scope -- no folder_id filter needed.
return None;
}
let internal_path =
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath)
.ok()?;
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &subpath).ok()?;
let folder_service = &state.applications.folder_service;
folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
.ok()
.map(|f| f.id)
@@ -486,13 +529,16 @@ async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> O
/// Extract the subpath portion from a scope href.
///
/// Handles both short form `/files/{user}/sub` and full
/// `/remote.php/dav/files/{user}/sub`.
fn extract_subpath_from_scope(href: &str, username: &str) -> Option<String> {
/// `/remote.php/dav/files/{user}/sub`. `url_user` is the literal URL
/// `{user}` segment — bare for legacy single-drive sync, composite
/// `admin~{uuid}` for multi-drive — so this matches whichever shape
/// the NC client actually sent.
fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option<String> {
let patterns = [
format!("/remote.php/dav/files/{}/", username),
format!("/files/{}/", username),
format!("/remote.php/dav/files/{}", username),
format!("/files/{}", username),
format!("/remote.php/dav/files/{}/", url_user),
format!("/files/{}/", url_user),
format!("/remote.php/dav/files/{}", url_user),
format!("/files/{}", url_user),
];
for pat in &patterns {
+28 -35
View File
@@ -10,13 +10,14 @@ use axum::{
use std::sync::Arc;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::middleware::auth::AuthUser;
use crate::interfaces::middleware::rate_limit::{RateLimiter, rate_limit_login};
use crate::interfaces::nextcloud::avatar_handler;
use crate::interfaces::nextcloud::basic_auth_middleware::basic_auth_middleware;
use crate::interfaces::nextcloud::login_v2_handler;
use crate::interfaces::nextcloud::ocs_handler;
use crate::interfaces::nextcloud::preview_handler;
use crate::interfaces::nextcloud::session::NcSession;
use crate::interfaces::nextcloud::status_handler;
use crate::interfaces::nextcloud::trashbin_handler;
use crate::interfaces::nextcloud::uploads_handler;
@@ -58,6 +59,14 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
rate_limit_login,
)),
)
// Drive picker submission — finalises a multi-drive flow that
// paused after password verification. Public route by design:
// the flow token + single-use `pending_user_id` slot is the
// proof of authentication. See `login_v2_handler::handle_drive_pick`.
.route(
"/login/v2/flow/{token}/drive",
post(login_v2_handler::handle_drive_pick),
)
// OIDC initiation from Nextcloud login page
.route(
"/login/v2/flow/{token}/oidc",
@@ -204,60 +213,46 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
// ──────────────── Handler glue ────────────────
/// Reject requests where the URL `{user}` doesn't match the authenticated user.
#[allow(clippy::result_large_err)]
fn verify_url_user(url_user: &str, auth_user: &CurrentUser) -> Result<(), Response> {
if url_user != auth_user.username {
Err(StatusCode::FORBIDDEN.into_response())
} else {
Ok(())
}
}
async fn handle_dav_files(
State(state): State<Arc<AppState>>,
Path((url_user, subpath)): Path<(String, String)>,
user_ext: AuthUser,
Path((_url_user, subpath)): Path<(String, String)>,
session: NcSession,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
webdav_handler::handle_nc_webdav(state, req, user_ext, subpath)
webdav_handler::handle_nc_webdav(state, req, session, subpath)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_files_root(
State(state): State<Arc<AppState>>,
Path(url_user): Path<String>,
user_ext: AuthUser,
Path(_url_user): Path<String>,
session: NcSession,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
webdav_handler::handle_nc_webdav(state, req, user_ext, String::new())
webdav_handler::handle_nc_webdav(state, req, session, String::new())
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_uploads(
State(state): State<Arc<AppState>>,
Path((url_user, upload_id, rest)): Path<(String, String, String)>,
user_ext: AuthUser,
Path((_url_user, upload_id, rest)): Path<(String, String, String)>,
session: NcSession,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, rest)
uploads_handler::handle_nc_uploads(state, req, session, upload_id, rest)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_uploads_root(
State(state): State<Arc<AppState>>,
Path((url_user, upload_id)): Path<(String, String)>,
user_ext: AuthUser,
Path((_url_user, upload_id)): Path<(String, String)>,
session: NcSession,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, String::new())
uploads_handler::handle_nc_uploads(state, req, session, upload_id, String::new())
.await
.map_err(|e| e.into_response())
}
@@ -283,24 +278,22 @@ async fn handle_legacy_webdav_root(user_ext: AuthUser) -> Response {
async fn handle_dav_trashbin(
State(state): State<Arc<AppState>>,
Path((url_user, subpath)): Path<(String, String)>,
user_ext: AuthUser,
Path((_url_user, subpath)): Path<(String, String)>,
session: NcSession,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
trashbin_handler::handle_nc_trashbin(state, req, user_ext, subpath)
trashbin_handler::handle_nc_trashbin(state, req, session, subpath)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_trashbin_root(
State(state): State<Arc<AppState>>,
Path(url_user): Path<String>,
user_ext: AuthUser,
Path(_url_user): Path<String>,
session: NcSession,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
trashbin_handler::handle_nc_trashbin(state, req, user_ext, String::new())
trashbin_handler::handle_nc_trashbin(state, req, session, String::new())
.await
.map_err(|e| e.into_response())
}
+134
View File
@@ -0,0 +1,134 @@
//! Per-request NextCloud session context.
//!
//! Bundles WHO the caller is, the raw wire username they presented,
//! and (for path-scoped endpoints) WHERE they're confined to. Built
//! by `basic_auth_middleware` and stashed in request extensions as
//! `Arc<NcSession>`; handlers extract it via the [`FromRequestParts`]
//! impl below — just declare `session: NcSession` in the signature.
//!
//! ## Source of truth
//!
//! - `user`: authenticated identity (id, canonical username, role).
//! - `raw_username`: the opaque wire identifier from the Basic Auth
//! header. Today: plain `user` (single-drive) or `user~{drive_uuid}`
//! (multi-drive POC). May look different again when future auth
//! schemes land. **Handlers MUST NOT parse it** — it's used verbatim
//! only for echoing back into DAV/OCS URLs the client expects to
//! see (notably OCS `cloud/user`'s `id` field, which NC desktop
//! splices into every subsequent DAV path it builds) and for
//! audit logs.
//! - `chroot`: folder the request is jailed inside. `Some` for every
//! authenticated NC request today (the home folder when no drive
//! marker is present, or the resolved drive when one is). `None`
//! is reserved for future routes that don't operate on a single
//! folder (admin / cross-drive queries).
//!
//! ## Why this lives in middleware, not routes.rs
//!
//! The auth step already has every input needed (raw username from
//! header + drive marker after `~` + authenticated user). Resolving
//! the chroot there means every NC handler — DAV, OCS, uploads,
//! trashbin, sharees, … — gets a uniform `NcSession` regardless of
//! whether its URL carries a `{user}` segment. The URL `{user}`
//! segment becomes informational; the auth header is canonical.
use std::sync::Arc;
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
response::{IntoResponse, Response},
};
use crate::application::dtos::folder_dto::FolderDto;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
#[derive(Debug, Clone)]
pub struct NcSession {
pub user: CurrentUser,
pub raw_username: String,
pub chroot: Option<FolderDto>,
}
impl NcSession {
/// Return the chroot, or 500 if a path-scoped handler is reached
/// without one. Documents the invariant that every NC route
/// today is path-scoped — if this fires, route wiring is wrong.
pub fn require_chroot(&self) -> Result<&FolderDto, AppError> {
self.chroot.as_ref().ok_or_else(|| {
AppError::internal_error(
"NcSession: path-scoped handler reached without a chroot — route wiring bug",
)
})
}
/// True when the session is scoped to the user's home folder
/// (no drive marker in the Basic Auth username). Useful for
/// handlers that want to render a friendlier display when the
/// user is on their default drive.
pub fn is_home(&self) -> bool {
!self.raw_username.contains('~')
}
}
/// Pull the `{user}` segment out of a NC DAV URL.
///
/// Expected URL shapes:
/// - `/remote.php/dav/files/{user}` (root)
/// - `/remote.php/dav/files/{user}/{*subpath}`
/// - `/remote.php/dav/uploads/{user}/{upload_id}[/{*rest}]`
/// - `/remote.php/dav/trashbin/{user}[/{*subpath}]`
///
/// Returns `None` for anything that doesn't follow this shape (notably
/// the OCS surfaces, where there is no `{user}` segment to compare).
fn extract_url_user(path: &str) -> Option<String> {
let mut segments = path.split('/');
if !segments.next()?.is_empty() {
return None;
}
if segments.next()? != "remote.php" {
return None;
}
if segments.next()? != "dav" {
return None;
}
let _surface = segments.next()?; // files / uploads / trashbin
let user_seg = segments.next()?;
if user_seg.is_empty() {
return None;
}
urlencoding::decode(user_seg).ok().map(|s| s.into_owned())
}
/// Axum extractor: pulls the `Arc<NcSession>` that
/// `basic_auth_middleware` stashed in request extensions and clones
/// it (cheap — one `Arc` increment, no field copy) into an owned
/// `NcSession` for handler use.
///
/// On path-scoped DAV routes (`/remote.php/dav/{files,uploads,
/// trashbin}/{user}/…`), the URL `{user}` segment is cross-checked
/// against `session.raw_username` and 403'd on mismatch. This is a
/// consistency check, NOT a security boundary — the chroot ACL
/// (`get_folder_with_perms`) is what actually prevents cross-user
/// access. It just surfaces malformed requests early (403) instead
/// of silently letting them through.
impl<S: Send + Sync> FromRequestParts<S> for NcSession {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let session = parts
.extensions
.get::<Arc<NcSession>>()
.map(|arc| (**arc).clone())
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
if let Some(url_user) = extract_url_user(parts.uri.path())
&& url_user != session.raw_username
{
return Err(StatusCode::FORBIDDEN.into_response());
}
Ok(session)
}
}
+36 -18
View File
@@ -14,7 +14,6 @@ use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::nextcloud::webdav_handler::{
batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_to_internal_path,
write_text_element,
@@ -28,7 +27,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
pub async fn handle_nc_trashbin(
state: Arc<AppState>,
req: Request<Body>,
user: AuthUser,
session: crate::interfaces::nextcloud::session::NcSession,
subpath: String,
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
@@ -37,21 +36,25 @@ pub async fn handle_nc_trashbin(
match method.as_str() {
"OPTIONS" => handle_options(),
"PROPFIND" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
handle_propfind(state, &user).await
handle_propfind(state, &session).await
}
"MOVE" if subpath_trimmed.starts_with("trash/") => {
// Keep the destination-collision-check feature added on HEAD
// (RFC 4918 §9.9.4: refuse restore with 412 when the
// destination is taken by a live resource). The chroot lookup
// moves into `handle_restore` via the session.
let dest_header = req
.headers()
.get("destination")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
handle_restore(state, dest_header, &user, subpath_trimmed).await
handle_restore(state, dest_header, &session, subpath_trimmed).await
}
"DELETE" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
handle_empty_trash(state, &user).await
handle_empty_trash(state, &session).await
}
"DELETE" if subpath_trimmed.starts_with("trash/") => {
handle_delete_permanent(state, &user, subpath_trimmed).await
handle_delete_permanent(state, &session, subpath_trimmed).await
}
_ => Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
@@ -75,8 +78,9 @@ fn handle_options() -> Result<Response<Body>, AppError> {
async fn handle_propfind(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let trash_svc = state
.trash_service
.as_ref()
@@ -107,9 +111,11 @@ async fn handle_propfind(
async fn handle_restore(
state: Arc<AppState>,
dest_header: Option<String>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let chroot = session.require_chroot()?;
let id = extract_trash_id(subpath)?;
let trash_svc = state
@@ -128,12 +134,15 @@ async fn handle_restore(
if let Some(dest_header) = dest_header
&& let Some(dest_subpath) = extract_nc_subpath_from_dest(&dest_header, &user.username)
{
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok()
let dest_taken = file_service
.get_file_by_path(&dest_internal, chroot.drive_id)
.await
.is_ok()
|| folder_service
.get_folder_by_path(&dest_internal)
.get_folder_by_path(&dest_internal, chroot.drive_id)
.await
.is_ok();
if dest_taken {
@@ -184,8 +193,9 @@ async fn handle_restore(
async fn handle_empty_trash(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let trash_svc = state
.trash_service
.as_ref()
@@ -206,9 +216,10 @@ async fn handle_empty_trash(
async fn handle_delete_permanent(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let id = extract_trash_id(subpath)?;
let trash_svc = state
@@ -248,11 +259,18 @@ fn mime_from_name(name: &str) -> String {
.to_string()
}
/// Strip the "My Folder - {username}/" prefix from an original path to produce
/// the Nextcloud-relative original location.
fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
let prefix = format!("My Folder - {}/", username);
original_path.strip_prefix(&prefix).unwrap_or(original_path)
/// Strip the home-folder prefix from an original path to produce the
/// Nextcloud-relative original location.
///
/// 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)
}
// ────────────── Trashbin PROPFIND XML Generation ──────────────
+46 -25
View File
@@ -9,7 +9,6 @@ use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseC
use crate::common::di::AppState;
use crate::common::mime_detect::filename_from_path;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::upload_ingest::{
discard_ingested, ingest_stream_to_cas, stream_body_to_path, stream_from_files,
};
@@ -25,17 +24,17 @@ use crate::interfaces::upload_ingest::{
pub async fn handle_nc_uploads(
state: Arc<AppState>,
req: Request<Body>,
user: AuthUser,
session: crate::interfaces::nextcloud::session::NcSession,
upload_id: String,
rest: String, // chunk name or ".file" or empty
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
match method.as_str() {
"MKCOL" => handle_mkcol(state, &user, &upload_id).await,
"PUT" => handle_put_chunk(state, req, &user, &upload_id, &rest).await,
"MOVE" => handle_assemble(state, req, &user, &upload_id).await,
"DELETE" => handle_abort(state, &user, &upload_id).await,
"PROPFIND" => handle_propfind_session(state, &user, &upload_id).await,
"MKCOL" => handle_mkcol(state, &session, &upload_id).await,
"PUT" => handle_put_chunk(state, req, &session, &upload_id, &rest).await,
"MOVE" => handle_assemble(state, req, &session, &upload_id).await,
"DELETE" => handle_abort(state, &session, &upload_id).await,
"PROPFIND" => handle_propfind_session(state, &session, &upload_id).await,
_ => Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
@@ -60,9 +59,10 @@ pub async fn handle_nc_uploads(
/// which matches NC server behaviour.
async fn handle_propfind_session(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
upload_id: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let nc = state
.nextcloud
.as_ref()
@@ -147,9 +147,10 @@ fn xml_escape(s: &str) -> String {
/// MKCOL — create upload session directory.
async fn handle_mkcol(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
upload_id: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let nc = state
.nextcloud
.as_ref()
@@ -178,10 +179,11 @@ async fn handle_mkcol(
async fn handle_put_chunk(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
upload_id: &str,
chunk_name: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let nc = state
.nextcloud
.as_ref()
@@ -216,9 +218,10 @@ async fn handle_put_chunk(
async fn handle_assemble(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
upload_id: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let nc = state
.nextcloud
.as_ref()
@@ -256,11 +259,19 @@ async fn handle_assemble(
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
let internal_path = format!(
"My Folder - {}/{}",
user.username,
dest_subpath.trim_matches('/')
);
// Path-based lookups below scope by `drive_id`. The NC session's
// chroot is always populated for path-scoped handlers (see
// `NcSession::require_chroot`); the FolderDto carries `drive_id`
// post-D0.
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('/'));
let filename = filename_from_path(&dest_subpath).to_string();
let ingested = ingest_stream_to_cas(
@@ -275,11 +286,20 @@ async fn handle_assemble(
let content_type = ingested.content_type.clone();
// Check if file exists (update vs create).
let existing = file_service.get_file_by_path(&internal_path).await;
let existing = file_service
.get_file_by_path(&internal_path, drive_id)
.await;
let etag: Option<String> = if existing.is_ok() {
let dto = upload_service
.update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime)
.update_file_streaming(
&internal_path,
drive_id,
ingested.stored(),
&content_type,
oc_mtime,
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
@@ -291,15 +311,14 @@ async fn handle_assemble(
Some((p, n)) => (p, n),
None => ("", dest_subpath.as_str()),
};
let parent_internal = format!(
"My Folder - {}/{}",
user.username,
parent_sub.trim_matches('/')
);
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
let parent_internal = parent_internal.trim_end_matches('/');
use crate::application::ports::folder_ports::FolderUseCase;
let parent_folder = match folder_service.get_folder_by_path(parent_internal).await {
let parent_folder = match folder_service
.get_folder_by_path(parent_internal, drive_id)
.await
{
Ok(folder) => folder,
Err(e) => {
discard_ingested(&state.core.dedup_service, &ingested).await;
@@ -316,6 +335,7 @@ async fn handle_assemble(
Some(parent_folder.id),
content_type.to_string(),
ingested.stored(),
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
@@ -344,9 +364,10 @@ async fn handle_assemble(
/// DELETE — abort an upload session.
async fn handle_abort(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
upload_id: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let nc = state
.nextcloud
.as_ref()
+235 -79
View File
@@ -25,7 +25,6 @@ use crate::common::di::AppState;
use crate::common::mime_detect::filename_from_path;
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::range_requests::{not_modified_response, range_response};
use crate::interfaces::upload_ingest::ingest_body_to_cas;
@@ -47,23 +46,35 @@ fn timestamp_to_i64(ts: u64) -> i64 {
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Resolve the internal OxiCloud path from a Nextcloud DAV subpath.
/// Resolve the internal OxiCloud path from a NextCloud DAV subpath
/// and the storage chroot the request is confined to.
///
/// Nextcloud: /remote.php/dav/files/{user}/{subpath}
/// Internal: My Folder - {username}/{subpath}
/// `chroot` is the storage path the request is "jailed" inside —
/// the route glue (`routes.rs::handle_dav_*`) computes it once per
/// request:
/// - Legacy `/files/{user}/…` or explicit `~{home_folder_uuid}` →
/// `"My Folder - {username}"` (no DB lookup needed).
/// - `~{some_other_folder_uuid}` → the folder's stored `path` after
/// a `get_folder_with_perms` check (404 if missing / no access).
///
/// An empty subpath maps to the user's home folder root.
pub fn nc_to_internal_path(username: &str, subpath: &str) -> Result<String, AppError> {
let home = format!("My Folder - {}", username);
/// By the time we get here `chroot` is known to be a legitimate
/// target — validation and permission live in the route layer, not
/// in the path mapper. This function stays sync and free of any
/// folder-service handle. The chroot's `path` is the canonical root
/// segment (e.g. `"Personal"` for default personal drives provisioned
/// by D0, the original sibling-root folder name for secondary drives).
/// Replaces the pre-D0 hardcoded `"My Folder - {username}/"` prefix.
pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result<String, AppError> {
let subpath = subpath.trim_matches('/');
if subpath.is_empty() {
return Ok(home);
return Ok(chroot.path.clone());
}
// Reject path traversal attempts.
if subpath.split('/').any(|seg| seg == ".." || seg == ".") {
return Err(AppError::bad_request("Invalid path: traversal not allowed"));
}
Ok(format!("{}/{}", home, subpath))
Ok(format!("{}/{}", chroot.path, subpath))
}
/// Build the Nextcloud DAV href for a **collection** (folder). Always
@@ -113,26 +124,42 @@ pub fn nc_href(username: &str, subpath: &str) -> String {
/// Dispatch Nextcloud WebDAV request to the appropriate handler.
///
/// `subpath` is everything after `/remote.php/dav/files/{user}/`.
/// `session.chroot` is the storage path the request is confined to
/// — see [`nc_to_internal_path`] for what gets resolved upstream.
/// `session.raw_username` is the literal wire identifier — bare
/// `admin` for single-drive sync, composite `admin~{drive_uuid}` for
/// multi-drive. **Hrefs in every response MUST be built from
/// `session.raw_username`, not from `session.user.username`** — the
/// NC desktop client validates that PROPFIND/MOVE response hrefs
/// share the requested URL's prefix and aborts the parse otherwise
/// (`Invalid href "<…>" expected starting with "<requested-url>"`).
/// The bare `session.user.username` is still the right value for
/// the storage-side owner identity (`oc:owner-id`).
pub async fn handle_nc_webdav(
state: Arc<AppState>,
req: Request<Body>,
user: AuthUser,
session: crate::interfaces::nextcloud::session::NcSession,
subpath: String,
) -> Result<Response<Body>, AppError> {
// Validate up-front that we have a chroot — every method below is
// path-scoped, so a missing chroot is a route-wiring bug we want to
// surface as a 500 immediately rather than re-checking inside each
// handler.
session.require_chroot()?;
let method = req.method().clone();
match method.as_str() {
"OPTIONS" => handle_options(),
"GET" => handle_get(state, &user, &subpath, req.headers()).await,
"PROPFIND" => handle_propfind(state, req, &user, &subpath).await,
"PUT" => handle_put(state, req, &user, &subpath).await,
"MKCOL" => handle_mkcol(state, &user, &subpath).await,
"DELETE" => handle_delete(state, &user, &subpath).await,
"MOVE" => handle_move(state, req, &user, &subpath).await,
"HEAD" => handle_head(state, &user, &subpath).await,
"PROPPATCH" => handle_proppatch(state, req, &user, &subpath).await,
"PROPFIND" => handle_propfind(state, req, &session, &subpath).await,
"GET" => handle_get(state, &session, &subpath, req.headers()).await,
"PUT" => handle_put(state, req, &session, &subpath).await,
"MKCOL" => handle_mkcol(state, &session, &subpath).await,
"DELETE" => handle_delete(state, &session, &subpath).await,
"MOVE" => handle_move(state, req, &session, &subpath).await,
"HEAD" => handle_head(state, &session, &subpath).await,
"PROPPATCH" => handle_proppatch(state, req, &session, &subpath).await,
"REPORT" | "SEARCH" => {
crate::interfaces::nextcloud::report_handler::handle_nc_report(
state, req, &user, &subpath,
state, req, &session, &subpath,
)
.await
}
@@ -171,9 +198,12 @@ fn handle_options() -> Result<Response<Body>, AppError> {
async fn handle_propfind(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let chroot = session.require_chroot()?;
let url_user = &session.raw_username;
let depth = req
.headers()
.get("depth")
@@ -198,29 +228,41 @@ async fn handle_propfind(
.map_err(|e| AppError::bad_request(format!("Invalid PROPFIND XML: {}", e)))?
};
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Try to resolve as folder first.
let folder_result = folder_service.get_folder_by_path(&internal_path).await;
let folder_result = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
.await;
if let Ok(folder) = folder_result {
// It's a folder — stream the multistatus: children are fetched in
// pages and serialized chunk by chunk, so memory stays O(batch)
// regardless of how many entries the folder holds.
//
// Multi-drive POC: the hrefs in the response must echo the
// wire form (`{user}~{drive}`) the client requested, so we
// pass `url_user` (not `user.username`) as the streaming
// function's username arg. Refining the owner-id usages
// back to the canonical username is deferred to the
// NcSession commit.
return Ok(build_nc_streaming_propfind(
state.clone(),
folder,
depth,
user.id,
user.username.clone(),
url_user.to_string(),
subpath.to_string(),
));
}
// Not a folder — try as a file.
let file_result = file_service.get_file_by_path(&internal_path).await;
let file_result = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await;
if let Ok(file) = file_result {
// Batch-check favorites for this single file.
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
@@ -240,6 +282,7 @@ async fn handle_propfind(
write_nc_file_multistatus(
&mut buf,
&file,
url_user,
&user.username,
subpath,
file_id_svc,
@@ -262,10 +305,11 @@ async fn handle_propfind(
async fn handle_get(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
headers: &axum::http::HeaderMap,
) -> Result<Response<Body>, AppError> {
let chroot = session.require_chroot()?;
// GET on root folder — NC clients use this as an existence check
if subpath.is_empty() || subpath == "/" {
return Ok(Response::builder()
@@ -275,13 +319,13 @@ async fn handle_get(
.unwrap());
}
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder first (NC clients use GET as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
.is_ok()
{
@@ -293,7 +337,7 @@ async fn handle_get(
}
let file = file_service
.get_file_by_path(&internal_path)
.get_file_by_path(&internal_path, chroot.drive_id)
.await
.map_err(|_| AppError::not_found("File not found"))?;
@@ -340,9 +384,10 @@ async fn handle_get(
async fn handle_head(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let chroot = session.require_chroot()?;
// HEAD on root folder — NC clients use this as an existence check
if subpath.is_empty() || subpath == "/" {
return Ok(Response::builder()
@@ -352,13 +397,13 @@ async fn handle_head(
.unwrap());
}
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder (NC clients use HEAD as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
.is_ok()
{
@@ -370,7 +415,7 @@ async fn handle_head(
}
let file = file_service
.get_file_by_path(&internal_path)
.get_file_by_path(&internal_path, chroot.drive_id)
.await
.map_err(|_| AppError::not_found("File not found"))?;
@@ -404,9 +449,12 @@ async fn handle_head(
async fn handle_proppatch(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let chroot = session.require_chroot()?;
let url_user = &session.raw_username;
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
@@ -428,12 +476,18 @@ async fn handle_proppatch(
// PROPPATCH path (no favorite directive in the body) — matches
// the prior behaviour. A PROPPATCH that *does* try to set
// favorite on a missing resource still returns NotFound.
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
let resource = if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
let resource = if let Ok(file) = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
{
Some((file.id, "file"))
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
} else if let Ok(folder) = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
{
Some((folder.id, "folder"))
} else {
None
@@ -472,9 +526,9 @@ async fn handle_proppatch(
// type to satisfy the RFC 4918 §5.2 trailing-slash invariant —
// see the comment block at the top of this function.
let href = if is_collection {
nc_collection_href(&user.username, subpath)
nc_collection_href(url_user, subpath)
} else {
nc_href(&user.username, subpath)
nc_href(url_user, subpath)
};
let mut buf = Vec::new();
{
@@ -610,10 +664,11 @@ fn precondition_failed_response() -> Response<Body> {
async fn handle_put(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let chroot = session.require_chroot()?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let upload_service = &state.applications.file_upload_service;
@@ -635,7 +690,10 @@ async fn handle_put(
// bandwidth or disk I/O on a body the server is going to throw away.
// The lookup is reused for the create-vs-update distinction below,
// so this is also free of an extra DB hit.
let existing = file_service.get_file_by_path(&internal_path).await.ok();
let existing = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
.ok();
let current_etag = existing.as_ref().map(|f| f.etag.as_str());
if let Some(value) = req
@@ -690,7 +748,14 @@ 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(&internal_path, ingested.stored(), &content_type, oc_mtime)
.update_file_streaming(
&internal_path,
chroot.drive_id,
ingested.stored(),
&content_type,
oc_mtime,
session.user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?;
@@ -712,13 +777,15 @@ async fn handle_put(
async fn handle_mkcol(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let chroot = session.require_chroot()?;
use crate::application::dtos::folder_dto::CreateFolderDto;
let folder_service = &state.applications.folder_service;
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
// RFC 4918 §9.3.1:
// - target already exists → 405 Method Not Allowed
@@ -733,7 +800,7 @@ async fn handle_mkcol(
// auto-create doesn't break real clients.
if folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
.is_ok()
{
@@ -751,14 +818,21 @@ async fn handle_mkcol(
}
let (target_name, parent_segments) = segments.split_last().expect("checked non-empty above");
let user_root = nc_to_internal_path(&user.username, "")?;
// Take POC's `chroot`-based root resolution (drive-aware mount
// point) but keep HEAD's parent_path lookup pattern — the
// continuation below uses `get_folder_by_path(&parent_path,
// user.id)` (user-scoped lookup added in the D0 rewind).
let user_root = nc_to_internal_path(chroot, "")?;
let parent_path = if parent_segments.is_empty() {
user_root.clone()
} else {
format!("{}/{}", user_root, parent_segments.join("/"))
};
let parent_folder = match folder_service.get_folder_by_path(&parent_path).await {
let parent_folder = match folder_service
.get_folder_by_path(&parent_path, chroot.drive_id)
.await
{
Ok(folder) => folder,
Err(_) => {
return Ok(Response::builder()
@@ -787,17 +861,22 @@ async fn handle_mkcol(
async fn handle_delete(
state: Arc<AppState>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let user = &session.user;
let chroot = session.require_chroot()?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Prefer soft-delete (move to trash) when trash service is available.
// This is what Nextcloud clients expect — items appear in the trashbin.
if let Some(trash_svc) = state.trash_service.as_ref() {
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
if let Ok(folder) = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
{
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
@@ -807,7 +886,10 @@ async fn handle_delete(
.body(Body::empty())
.unwrap());
}
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
if let Ok(file) = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
{
trash_svc
.move_to_trash(&file.id, "file", user.id)
.await
@@ -823,7 +905,10 @@ async fn handle_delete(
// Fallback: hard delete when trash service is not available.
let file_mgmt = &state.applications.file_management_service;
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
if let Ok(folder) = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
{
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
@@ -835,7 +920,10 @@ async fn handle_delete(
.unwrap());
}
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
if let Ok(file) = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
{
file_mgmt
.delete_file_with_perms(&file.id, user.id)
.await
@@ -855,9 +943,12 @@ async fn handle_delete(
async fn handle_move(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
session: &crate::interfaces::nextcloud::session::NcSession,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let chroot = session.require_chroot()?;
let url_user = &session.raw_username;
let destination = req
.headers()
.get("destination")
@@ -879,10 +970,14 @@ async fn handle_move(
.unwrap_or(false);
// Parse destination path: extract subpath after /remote.php/dav/files/{user}/
let dest_subpath = extract_nc_subpath_from_dest(&destination, &user.username)
// — the URL user-segment carries the drive marker on multi-drive
// sessions, so we strip the *composite* prefix to find the real
// subpath. Using `user.username` here would fail to match for any
// request hitting a non-home drive.
let dest_subpath = extract_nc_subpath_from_dest(&destination, url_user)
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
let src_internal = nc_to_internal_path(&user.username, subpath)?;
let src_internal = nc_to_internal_path(chroot, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
let file_mgmt = &state.applications.file_management_service;
@@ -891,13 +986,13 @@ async fn handle_move(
// Resolved once up-front so the file/folder branches below don't
// each have to repeat the check. `dest_existed_before` becomes the
// 204-vs-201 selector at response time.
let dest_internal_precheck = nc_to_internal_path(&user.username, &dest_subpath)?;
let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?;
let dest_existing_file = file_service
.get_file_by_path(&dest_internal_precheck)
.get_file_by_path(&dest_internal_precheck, chroot.drive_id)
.await
.ok();
let dest_existing_folder = folder_service
.get_folder_by_path(&dest_internal_precheck)
.get_folder_by_path(&dest_internal_precheck, chroot.drive_id)
.await
.ok();
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
@@ -940,12 +1035,15 @@ async fn handle_move(
};
// Try as file first.
if let Ok(file) = file_service.get_file_by_path(&src_internal).await {
if let Ok(file) = file_service
.get_file_by_path(&src_internal, chroot.drive_id)
.await
{
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", dest_subpath.as_str()),
};
let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?;
let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?;
// Rename if only the name changes (same parent).
let src_parent_sub = match subpath.rsplit_once('/') {
@@ -962,7 +1060,7 @@ async fn handle_move(
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
.await
.map_err(|_| AppError::not_found("Destination folder not found"))?;
@@ -981,9 +1079,15 @@ async fn handle_move(
}
// Return ETag and OC-ETag so Nextcloud clients can track the moved file.
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
// Take POC's chroot-based path resolution; keep HEAD's
// final_status (201 vs 204 depending on whether the destination
// existed — RFC 4918 §9.9.4 distinguishes create vs overwrite).
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
let mut builder = Response::builder().status(final_status);
if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await {
if let Ok(moved) = file_service
.get_file_by_path(&dest_internal, chroot.drive_id)
.await
{
// Route through `FileDto::etag` so the MOVE response
// matches what a subsequent PROPFIND on the destination
// will return — `moved.id` (UUID) would differ from the
@@ -997,12 +1101,15 @@ async fn handle_move(
}
// Try as folder.
if let Ok(folder) = folder_service.get_folder_by_path(&src_internal).await {
if let Ok(folder) = folder_service
.get_folder_by_path(&src_internal, chroot.drive_id)
.await
{
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", dest_subpath.as_str()),
};
let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?;
let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?;
let src_parent_sub = match subpath.rsplit_once('/') {
Some((parent, _)) => parent,
@@ -1025,7 +1132,7 @@ async fn handle_move(
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
.await
.map_err(|_| AppError::not_found("Destination parent not found"))?;
@@ -1112,6 +1219,7 @@ fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(
async fn write_nc_file_multistatus<W: std::io::Write>(
writer: W,
file: &FileDto,
url_user: &str,
username: &str,
subpath: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
@@ -1124,7 +1232,11 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
write_nc_multistatus_open(&mut xml)?;
// Single-file PROPFIND — subpath already points to the file.
let href = nc_href(username, subpath);
// `url_user` is the wire identifier (may carry a `~{drive}`
// marker); the NC client validates that the returned `<d:href>`
// shares the requested URL's prefix. `username` is the canonical
// identity for the `oc:owner-id` field.
let href = nc_href(url_user, subpath);
let file_id = file_id_map.get(&file.id).copied();
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_file_response(
@@ -1530,39 +1642,83 @@ mod tests {
use super::*;
// ── nc_to_internal_path ──
//
// The route glue resolves the `chroot` FolderDto once per request
// (legacy/home → user's home folder DTO; explicit `~{folder_uuid}` →
// folder's stored DTO after permission check). These tests cover only
// the path-mapping function itself; the resolver logic lives in
// `routes.rs::verify_url_user_and_resolve_chroot`.
#[test]
fn test_empty_subpath_returns_home() {
assert_eq!(
nc_to_internal_path("alice", "").unwrap(),
"My Folder - alice"
);
/// Build a stub `FolderDto` carrying only the `path` field (all the
/// path mapper looks at). Keeps the tests focused on path mapping
/// without dragging in folder-construction machinery.
fn stub_folder(path: &str) -> FolderDto {
FolderDto {
id: "00000000-0000-0000-0000-000000000000".to_string(),
name: path.rsplit('/').next().unwrap_or("").to_string(),
path: path.to_string(),
parent_id: None,
owner_id: None,
// Test stub — path mapper doesn't read drive_id.
drive_id: uuid::Uuid::nil(),
created_at: 0,
modified_at: 0,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
etag: String::new(),
// §14 provenance not relevant to path-mapper tests.
created_by: None,
updated_by: None,
}
}
#[test]
fn test_subpath_appended() {
fn test_empty_subpath_returns_chroot() {
let home = stub_folder("My Folder - alice");
assert_eq!(nc_to_internal_path(&home, "").unwrap(), "My Folder - alice");
}
#[test]
fn test_subpath_appended_to_chroot() {
let home = stub_folder("My Folder - alice");
assert_eq!(
nc_to_internal_path("alice", "Documents/work").unwrap(),
nc_to_internal_path(&home, "Documents/work").unwrap(),
"My Folder - alice/Documents/work"
);
}
#[test]
fn test_strips_surrounding_slashes() {
let home = stub_folder("My Folder - alice");
assert_eq!(
nc_to_internal_path("alice", "/Photos/").unwrap(),
nc_to_internal_path(&home, "/Photos/").unwrap(),
"My Folder - alice/Photos"
);
}
#[test]
fn test_rejects_dot_dot_traversal() {
assert!(nc_to_internal_path("alice", "../etc/passwd").is_err());
let home = stub_folder("My Folder - alice");
assert!(nc_to_internal_path(&home, "../etc/passwd").is_err());
}
#[test]
fn test_rejects_single_dot() {
assert!(nc_to_internal_path("alice", "foo/./bar").is_err());
let home = stub_folder("My Folder - alice");
assert!(nc_to_internal_path(&home, "foo/./bar").is_err());
}
/// Confines a subfolder chroot (the multi-drive form once
/// resolved). Same path-mapping logic — only the chroot differs.
#[test]
fn test_subfolder_chroot_with_subpath() {
let chroot = stub_folder("My Folder - alice/ext");
assert_eq!(
nc_to_internal_path(&chroot, "report.pdf").unwrap(),
"My Folder - alice/ext/report.pdf"
);
}
// ── nc_href ──
+47
View File
@@ -803,3 +803,50 @@
font-weight: var(--weight-medium);
color: var(--color-warning-orange-text);
}
/* Nextcloud drive picker — radio list of drives the authenticated user
can select for this app-password binding. */
.auth-drive-option {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
margin-bottom: 8px;
border: 1px solid var(--color-border);
border-radius: 12px;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease;
}
.auth-drive-option:hover {
background: var(--color-bg-hover);
border-color: var(--color-accent);
}
.auth-drive-option input[type="radio"] {
margin: 0;
accent-color: var(--color-accent);
}
.auth-drive-option input[type="radio"]:checked ~ .auth-drive-name {
font-weight: 600;
}
.auth-drive-name {
flex: 1;
color: var(--color-text);
font-size: 15px;
}
.auth-drive-badge {
padding: 2px 10px;
border-radius: 999px;
background: var(--color-accent-gradient);
color: var(--color-danger-text);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Choose a drive - OxiCloud</title>
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
<script src="/js/core/theme-init.js"></script>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/views/auth.css">
</head>
<body>
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 500 500">
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
</div>
<h2 class="auth-title">Choose a drive</h2>
<p class="auth-subtitle">
Your account has access to several drives. Pick the one this Nextcloud client should sync.
</p>
<form class="auth-form" method="POST" action="{{ form_action }}">
{%- for drive in drives %}
<label class="auth-drive-option">
<input
type="radio"
name="drive"
value="{{ drive.id }}"
{%- if loop.first %} checked{% endif %}
required>
<span class="auth-drive-name">{{ drive.name }}</span>
{%- if loop.first %}
<span class="auth-drive-badge">Home</span>
{%- endif %}
</label>
{%- endfor %}
<button type="submit" class="auth-button">Continue</button>
</form>
</div>
</div>
</body>
</html>
+219
View File
@@ -0,0 +1,219 @@
# =============================================================
# OxiCloud — D0 drives foundation
# =============================================================
# Verifies the D0 server-side foundation lands end-to-end:
#
# 1. Every internal user gets exactly one default Personal drive
# (the M2 backfill + the on-login lifecycle hook).
# 2. `GET /api/drives` returns that drive with the right shape
# (kind='personal', default_for_user matches the caller).
# 3. New folder/file rows stamp `drive_id` (verified indirectly:
# uploads succeed against a NOT NULL drive_id column post-M3).
# 4. Cross-drive isolation in `/api/search` — user A's indexed
# content does NOT surface in user B's search (Tantivy
# Must-clause on drive_id + handler-side ReBAC re-check).
# 5. `created_by` / `updated_by` provenance — files surface a
# non-null `last_modified_by` (via the file metadata endpoint)
# proving the dual-write took effect.
#
# Self-contained: creates its own users + folders so it can run
# independently of other test files.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — admin login
# ─────────────────────────────────────────────────────────────
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 — Admin's GET /api/drives surfaces a default Personal drive
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
# Admin has at least one drive — the default Personal at index 0
# (DrivePgRepository orders default-first via `default_for_user IS NULL ASC`).
jsonpath "$" count >= 1
jsonpath "$[0].kind" == "personal"
jsonpath "$[0].default_for_user" == "{{admin_user_id}}"
jsonpath "$[0].name" == "Personal"
# root_folder_id surfaces the drive's mount-point folder. Sourced via
# JOIN from storage.folders.name — drives have no `name` column under
# the D0 design (docs/plan/drive.md §3). Folder API operations
# (create-in-drive, rename-drive) all key off this id.
jsonpath "$[0].root_folder_id" exists
[Captures]
admin_drive_id: jsonpath "$[0].id"
admin_root_folder_id: jsonpath "$[0].root_folder_id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Create two fresh users (drv_alice, drv_bob) so the
# cross-drive isolation test below uses fixtures that
# don't collide with other test files.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "drv_alice", "password": "DrvAlicePassword1!", "email": "drv_alice@example.com", "role": "user" }
HTTP 201
[Captures]
alice_user_id: jsonpath "$.id"
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "drv_bob", "password": "DrvBobPassword1!", "email": "drv_bob@example.com", "role": "user" }
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
# Alice's first login fires `PersonalDriveLifecycleHook::on_user_login`
# (since `on_user_created` may have provisioned already; the hook is
# idempotent either way). After this her default drive exists.
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "drv_alice", "password": "DrvAlicePassword1!" }
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "drv_bob", "password": "DrvBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 4 — Each non-admin user sees exactly their default drive.
# Confirms the lifecycle hook provisioned + drive listing
# is correctly scoped (no cross-user leak).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/drives
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
# Exactly one drive: the default Personal.
jsonpath "$" count == 1
jsonpath "$[0].kind" == "personal"
jsonpath "$[0].default_for_user" == "{{alice_user_id}}"
jsonpath "$[0].name" == "Personal"
jsonpath "$[0].root_folder_id" exists
[Captures]
alice_drive_id: jsonpath "$[0].id"
GET {{base_url}}/api/drives
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].kind" == "personal"
jsonpath "$[0].default_for_user" == "{{bob_user_id}}"
jsonpath "$[0].name" == "Personal"
jsonpath "$[0].root_folder_id" exists
[Captures]
bob_drive_id: jsonpath "$[0].id"
# Cross-user drive id distinctness — Alice's drive id ≠ Bob's drive id.
# Hurl can't assert via inter-capture; the search-isolation step below
# proves the same property functionally.
# ─────────────────────────────────────────────────────────────
# Step 5 — Each user's home folder works end-to-end. The lifecycle
# hook creates the drive; folder creation under the home
# uses the drive's id (M3 NOT NULL on storage.folders.drive_id
# enforces this — any code path that doesn't set drive_id
# would error out here).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
HTTP 200
[Captures]
alice_home_id: jsonpath "$[0].id"
POST {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "name": "drv-alice-folder", "parent_id": "{{alice_home_id}}" }
HTTP 201
[Captures]
alice_subfolder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 6 — Upload a small file via the multipart path so its
# drive_id and created_by/updated_by columns get stamped
# by the file repository's dual-write.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{alice_token}}
[MultipartFormData]
folder_id: {{alice_subfolder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
alice_file_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 7 — Cross-drive isolation in `/api/search`. Bob searches
# for a term that exists only in Alice's file. The
# response must be empty (no leak of either the existence
# or the snippet of Alice's content).
#
# The Tantivy worker may need a tick to drain the dirty
# queue + extract text before the term is indexed. In a
# synchronous test we tolerate either response shape
# (empty results vs. some results all of which are Bob's
# own files), as long as Alice's specific file_id is
# absent. The check is the file_id-absent assertion.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/search?query=hello
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
# Bob may have his own hits or none — what matters is that
# Alice's file_id never appears in his result set.
jsonpath "$.files[?(@.id=='{{alice_file_id}}')]" not exists
# ─────────────────────────────────────────────────────────────
# Step 8 — Anti-enum cleanup: drop Alice's file + folder so the
# shared test storage doesn't accumulate cross-test state.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{alice_subfolder_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
DELETE {{base_url}}/api/trash/empty
Authorization: Bearer {{alice_token}}
HTTP 200
+8
View File
@@ -19,9 +19,11 @@ Content-Type: application/json
HTTP 200
[Captures]
token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
[Asserts]
jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer"
jsonpath "$.user.id" isString
# ─────────────────────────────────────────────────────────────
@@ -102,6 +104,9 @@ test1_id: jsonpath "$.id"
jsonpath "$.id" isString
jsonpath "$.name" == "test1"
jsonpath "$.parent_id" == {{home_folder_id}}
# D0 §14 provenance — self-creation: both fields stamp the caller.
jsonpath "$.created_by" == "{{admin_user_id}}"
jsonpath "$.updated_by" == "{{admin_user_id}}"
# ─────────────────────────────────────────────────────────────
@@ -169,6 +174,9 @@ jsonpath "$.name" == "hello.txt"
jsonpath "$.folder_id" == {{test2_id}}
jsonpath "$.size" == 32
jsonpath "$.mime_type" == "text/plain"
# D0 §14 provenance — uploader's id stamps both fields on a fresh upload.
jsonpath "$.created_by" == "{{admin_user_id}}"
jsonpath "$.updated_by" == "{{admin_user_id}}"
# ─────────────────────────────────────────────────────────────
+72 -4
View File
@@ -13,6 +13,8 @@
# ─────────────────────────────────────────────────────────────
# Step 1 — Login as admin (Alice), capture token + home folder.
# `alice_user_id` is captured for the D0 §14 provenance assertions
# that compare `created_by` / `updated_by` on resources Alice owns.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
@@ -21,6 +23,7 @@ Content-Type: application/json
HTTP 200
[Captures]
alice_token: jsonpath "$.access_token"
alice_user_id: jsonpath "$.user.id"
GET {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
@@ -85,6 +88,10 @@ Content-Type: application/json
HTTP 201
[Captures]
shared_folder_id: jsonpath "$.id"
[Asserts]
# D0 §14 provenance — Alice creates, so both fields stamp Alice.
jsonpath "$.created_by" == "{{alice_user_id}}"
jsonpath "$.updated_by" == "{{alice_user_id}}"
POST {{base_url}}/api/folders
Authorization: Bearer {{alice_token}}
@@ -294,7 +301,15 @@ Authorization: Bearer {{dave_token}}
HTTP 200
[Asserts]
jsonpath "$" count == 0
# Post-D0 every user carries an incoming Owner grant on their own
# personal drive (provisioned by the lifecycle hook). The pre-D0
# assertion was "no grants at all" (count == 0); the post-D0
# equivalent is "exactly the self-drive grant remains" (count == 1).
# Hurl's JSONPath filter returns "no value" — not an empty array —
# when nothing matches, so a `count == 0` over a negative filter
# fails to evaluate; the positive-count form sidesteps that quirk.
jsonpath "$" count == 1
jsonpath "$[0].resource.type" == "drive"
# ─────────────────────────────────────────────────────────────
@@ -305,7 +320,15 @@ Authorization: Bearer {{eve_token}}
HTTP 200
[Asserts]
jsonpath "$" count == 0
# Post-D0 every user carries an incoming Owner grant on their own
# personal drive (provisioned by the lifecycle hook). The pre-D0
# assertion was "no grants at all" (count == 0); the post-D0
# equivalent is "exactly the self-drive grant remains" (count == 1).
# Hurl's JSONPath filter returns "no value" — not an empty array —
# when nothing matches, so a `count == 0` over a negative filter
# fails to evaluate; the positive-count form sidesteps that quirk.
jsonpath "$" count == 1
jsonpath "$[0].resource.type" == "drive"
# ════════════════════════════════════════════════════════════════════
@@ -651,6 +674,12 @@ Content-Type: application/json
{ "name": "renamed-by-adam-as-editor" }
HTTP 200
[Asserts]
# D0 §14 provenance — folder counterpart of the file rename below.
# Adam (Editor) mutates Alice's folder; `updated_by` becomes Adam,
# `created_by` stays Alice.
jsonpath "$.created_by" == "{{alice_user_id}}"
jsonpath "$.updated_by" == "{{adam_user_id}}"
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
Authorization: Bearer {{adam_token}}
@@ -658,6 +687,16 @@ Content-Type: application/json
{ "name": "adam-renamed-logo.jpg" }
HTTP 200
[Asserts]
# D0 §14 provenance — Adam (an Editor, not the owner) mutates the
# file, so `updated_by` switches to Adam's id while `created_by`
# stays Alice (the original uploader). This is the canonical
# cross-user provenance check: distinguishes "who first put this
# here" from "who last touched it" and proves the mutator's id
# overrides the row's `user_id` (pre-D0 they were silently the
# same; post-D0 they can diverge once a non-owner mutates).
jsonpath "$.created_by" == "{{alice_user_id}}"
jsonpath "$.updated_by" == "{{adam_user_id}}"
# ── Thumbnail push (Update) succeeds ────────────────────────
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview
@@ -674,6 +713,15 @@ Content-Type: application/json
{ "name": "adam-created-child", "parent_id": "{{perm_folder_id}}" }
HTTP 201
[Asserts]
# D0 §14 provenance — Adam (Editor on Alice's folder) creates a
# child folder inside it. Both `created_by` and `updated_by` stamp
# Adam: he's the original author AND the last toucher of this
# fresh row. The parent's owner (Alice) doesn't appear anywhere on
# the new row's provenance — content authored in a shared scope
# belongs to its author.
jsonpath "$.created_by" == "{{adam_user_id}}"
jsonpath "$.updated_by" == "{{adam_user_id}}"
POST {{base_url}}/api/files/upload
Authorization: Bearer {{adam_token}}
@@ -682,6 +730,10 @@ folder_id: {{perm_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Asserts]
# Same shape for a file upload: Adam authored, Adam touched last.
jsonpath "$.created_by" == "{{adam_user_id}}"
jsonpath "$.updated_by" == "{{adam_user_id}}"
# ── Chunked upload full lifecycle as Editor ─────────────────
# 1. Open session (server pre-checks Create on folder)
@@ -809,7 +861,15 @@ Authorization: Bearer {{adam_token}}
HTTP 200
[Asserts]
jsonpath "$" count == 0
# Post-D0 every user carries an incoming Owner grant on their own
# personal drive (provisioned by the lifecycle hook). The pre-D0
# assertion was "no grants at all" (count == 0); the post-D0
# equivalent is "exactly the self-drive grant remains" (count == 1).
# Hurl's JSONPath filter returns "no value" — not an empty array —
# when nothing matches, so a `count == 0` over a negative filter
# fails to evaluate; the positive-count form sidesteps that quirk.
jsonpath "$" count == 1
jsonpath "$[0].resource.type" == "drive"
# ════════════════════════════════════════════════════════════════════
@@ -1233,4 +1293,12 @@ Authorization: Bearer {{frank_token}}
HTTP 200
[Asserts]
jsonpath "$" count == 0
# Post-D0 every user carries an incoming Owner grant on their own
# personal drive (provisioned by the lifecycle hook). The pre-D0
# assertion was "no grants at all" (count == 0); the post-D0
# equivalent is "exactly the self-drive grant remains" (count == 1).
# Hurl's JSONPath filter returns "no value" — not an empty array —
# when nothing matches, so a `count == 0` over a negative filter
# fails to evaluate; the positive-count form sidesteps that quirk.
jsonpath "$" count == 1
jsonpath "$[0].resource.type" == "drive"
+16 -7
View File
@@ -281,12 +281,16 @@ jsonpath "$.items[*].resource.name" not contains "bob-attack-2"
# WebDAV MKCOL — namespace isolation
# ═════════════════════════════════════════════════════════════
# WebDAV requests are isolated per-user by `resolve_webdav_path`
# (webdav_handler.rs:189). If the requested path doesn't begin
# with the caller's home folder name ("My Folder - <username>"),
# the handler silently prefixes the caller's home folder path
# onto the front. Effect: any WebDAV path a client sends is
# always resolved INSIDE the caller's own tree, regardless of
# what they wrote.
# (webdav_handler.rs:235). If the requested path doesn't begin
# with the caller's home folder name (the drive's root folder
# name — "Personal" by default post-D0), the handler silently
# prefixes the caller's home folder path onto the front. Effect:
# any WebDAV path a client sends is always resolved INSIDE the
# caller's own tree, regardless of what they wrote.
# The test URLs below use "My Folder - <username>" as a path
# segment that's GUARANTEED not to match any caller's home name
# (all home folders are "Personal" post-D0), so the resolver's
# prepend branch always fires.
#
# These tests assert the isolation works (regression guard) and
# that the service-level verify_owner still acts as
@@ -307,8 +311,13 @@ HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 17 – Positive control: bob MKCOL inside his own home.
# Uses "Personal" — bob's home folder name post-D0
# (docs/plan/drive.md §3, the canonical default). The resolver
# detects the URL already starts with the caller's home name and
# does NOT prepend again, so the new folder lands directly in
# bob's home rather than in a fresh intermediate.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/My%20Folder%20-%20bob/bob-webdav-own
MKCOL {{base_url}}/webdav/Personal/bob-webdav-own
Authorization: Bearer {{bob_token}}
HTTP 201
+1
View File
@@ -150,6 +150,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/subject_groups.hurl" \
"$API_DIR/groups_effective_members.hurl" \
"$API_DIR/grants_nested_groups.hurl" \
"$API_DIR/drives_foundation.hurl" \
"$API_DIR/external_users.hurl" \
"$API_DIR/search_basic.hurl" \
"$API_DIR/nc_second_user_setup.hurl" \
+77 -1
View File
@@ -153,7 +153,83 @@ body not contains "{{needle_file_id}}"
# ─────────────────────────────────────────────────────────────
# 6 — Teardown: removing the folder recursively takes the file
# 6 — CONTENT-search cross-drive isolation (docs/plan/drive.md §11).
# The cross-user check above (step 5) verifies the NAME-search
# path. The Tantivy content index is a separate code path with
# its own filter: `Must drive_id ∈ accessible_drives`. This
# block pins it.
#
# Sequence:
# 6a. Admin uploads `content-canary.txt` whose body contains
# the distinctive phrase `ContentIndexCanaryXyzzy2026Drive`.
# 6b. Wait ~2s for the async content-index worker
# (`OXICLOUD_CONTENT_SEARCH_FLUSH_INTERVAL_MS` defaults
# to 1500ms) to drain the dirty queue and apply the
# Tantivy mutation.
# 6c. Admin searches for the phrase → MUST hit the file
# (the index works).
# 6d. Bob searches for the same phrase → MUST be empty,
# AND the response shape MUST carry no hidden-count
# leak (no `total`/`hidden`/etc. field that could
# reveal "you have N matches you can't see"). The
# pivot from `Must user_id = caller` to `Must drive_id
# ∈ accessible_drives` is the §11 security primitive;
# a regression here would be a cross-drive leak.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{search_folder_id}}
file: file,fixtures/content-canary.txt; text/plain
HTTP 201
[Captures]
canary_file_id: jsonpath "$.id"
# Drain the content-index worker. 2s exceeds the 1500ms flush
# interval comfortably; raise if a slower CI machine flakes.
GET {{base_url}}/api/search?query=ContentIndexCanaryXyzzy2026Drive
Authorization: Bearer {{admin_token}}
[Options]
delay: 2500ms
HTTP 200
[Asserts]
# Admin sees the content match — proves indexing landed.
jsonpath "$.files" count >= 1
body contains "{{canary_file_id}}"
GET {{base_url}}/api/search?query=ContentIndexCanaryXyzzy2026Drive
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
# Bob has no access to admin's drive → Tantivy's Must-clause
# filters every doc that doesn't carry one of Bob's drive_ids,
# so the file vanishes entirely.
jsonpath "$.files" count == 0
jsonpath "$.folders" count == 0
body not contains "{{canary_file_id}}"
body not contains "ContentIndexCanaryXyzzy2026Drive"
# Anti-enum: every count the response surfaces must reflect the
# FILTERED set — i.e. zero when the caller has no accessible
# hits. The §11 rule is "no 'you have N hidden matches' field
# anywhere". `total_count` is a legitimate pagination count and
# is OK as long as it equals the filtered total (zero here). The
# other field names below MUST stay absent: a future field
# called `hidden_count`/`filtered`/etc. that reveals matches
# Bob can't see would be the regression.
jsonpath "$.total_count" == 0
jsonpath "$.has_more" == false
jsonpath "$.hidden_count" not exists
jsonpath "$.filtered" not exists
jsonpath "$.total" not exists
# ─────────────────────────────────────────────────────────────
# 7 — Teardown: removing the folder recursively takes the files
# with it, so a single DELETE is enough.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{search_folder_id}}
+13
View File
@@ -13,6 +13,19 @@
# bash tests/api/storage_cleanup_check.sh
# =============================================================
cat <<EOF
XXX
storage_cleanup_check.sh is disabled due to GC strategy change
may need to add an admin api call to trigger the GC and validate the correct cleanup of resources
XXX
EOF
exit 0
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+47
View File
@@ -42,4 +42,51 @@ psql -v ON_ERROR_STOP=1 -c "
ON CONFLICT (username) DO NOTHING;
" >/dev/null
# The OxiCloud server normally provisions a default Personal drive +
# its root folder + Owner role_grant on user creation via
# PersonalDriveLifecycleHook (D0). This script bypasses that pipeline
# — it INSERTs directly into auth.users — so we mirror the hook's
# behaviour here. Without it, integration test fixtures that hand-roll
# INSERTs into storage.files fail with "drive_id not-null violation"
# (M3 made the column mandatory), and helpers that JOIN auth.users
# with storage.drives return RowNotFound.
#
# Four sequential writes inside one transaction (docs/plan/drive.md §3):
# drive + root folder + drives.root_folder_id wire-up + Owner role_grant.
# A single CTE would be more compact but doesn't work — PG's CTE
# sub-statements share an MVCC snapshot, so a later branch's UPDATE
# can't match a row inserted by an earlier branch. The transaction
# form is the production path's shape (DrivePgRepository::create_personal_drive_atomic).
# Idempotency: skipped on retry by the `default_for_user` precondition.
echo "[init-schema] provisioning ci-admin's default Personal drive (idempotent)"
psql -v ON_ERROR_STOP=1 <<'SQL' >/dev/null
DO $$
DECLARE
admin_id uuid;
drive_id uuid;
folder_id uuid;
BEGIN
SELECT id INTO admin_id FROM auth.users WHERE username = 'ci-admin';
IF EXISTS (SELECT 1 FROM storage.drives WHERE default_for_user = admin_id) THEN
RETURN; -- already provisioned, idempotent no-op
END IF;
INSERT INTO storage.drives (kind, default_for_user, quota_bytes)
VALUES ('personal', admin_id, NULL)
RETURNING id INTO drive_id;
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, admin_id, drive_id, admin_id, admin_id)
RETURNING id INTO folder_id;
UPDATE storage.drives SET root_folder_id = folder_id WHERE id = drive_id;
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
VALUES ('user', admin_id, 'drive', drive_id, 'owner', admin_id);
END
$$;
SQL
echo "[init-schema] done"

Some files were not shown because too many files have changed in this diff Show More