Merge pull request #479 from EdouardVanbelle/feat/drive-impl
feat/drive impl
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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$;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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, ¤t_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, ¤t_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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ──────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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,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)"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user