feat(drive): improve Drive model
now Drive is purely a metadata
each drive has always a root folder
this model minimize Oxicloud changes, and simplify
the Drive name is simply the folder's root's name
note: owner of Drive has more permission that an owner of the root folder
This commit is contained in:
+432
-156
@@ -176,17 +176,35 @@ layer (`DriveService` enforces the personal-drive invariants, etc.)
|
|||||||
- A shared drive can have **0 viewers** and **0 editors** — only
|
- A shared drive can have **0 viewers** and **0 editors** — only
|
||||||
the ≥1-owner invariant matters.
|
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
|
```sql
|
||||||
storage.drives
|
storage.drives
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
|
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'))
|
kind text NOT NULL CHECK (kind IN ('personal','shared'))
|
||||||
default_for_user uuid NULL FK → auth.users(id) ON DELETE CASCADE
|
default_for_user uuid NULL FK → auth.users(id) ON DELETE CASCADE
|
||||||
quota_bytes bigint NULL -- NULL = unlimited
|
quota_bytes bigint NULL -- NULL = unlimited
|
||||||
used_bytes bigint NOT NULL DEFAULT 0
|
used_bytes bigint NOT NULL DEFAULT 0
|
||||||
policies jsonb NOT NULL DEFAULT '{}'
|
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()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
-- `default_for_user` may ONLY be set on personal drives.
|
-- `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
|
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`
|
#### Two orthogonal properties: `kind` and `default_for_user`
|
||||||
|
|
||||||
- **`kind`** = drive capability shape (see §2 for the rules):
|
- **`kind`** = drive capability shape (see §2 for the rules):
|
||||||
@@ -229,24 +276,123 @@ There is no constraint to write — externals simply have no row
|
|||||||
in `storage.drives` with `default_for_user = <their id>`, and
|
in `storage.drives` with `default_for_user = <their id>`, and
|
||||||
nothing tries to create one.
|
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
|
A drive and its root folder reference each other circularly:
|
||||||
user who renames their drive from "Personal" → "Ed's space" does
|
`storage.drives.root_folder_id` points at `storage.folders.id`, and
|
||||||
**not** stop having a personal drive, and does not stop having a
|
`storage.folders.drive_id` points at `storage.drives.id`. Creating
|
||||||
default. The `kind` flag + `default_for_user` pointer carry the
|
them naively could leave inconsistent half-state on a server crash
|
||||||
identity; the name is purely a display label.
|
mid-sequence: drive without folder, folder without drive, or either
|
||||||
|
without an owner role_grant.
|
||||||
|
|
||||||
Why this matters:
|
The repo's `create_personal_drive_atomic` wraps the four writes in
|
||||||
- UI and NC default-drive resolution MUST query on `kind` /
|
a single transaction so they commit together or not at all:
|
||||||
`default_for_user`, never on `name = 'Personal'`. The latter
|
|
||||||
would silently break the moment the user renames.
|
1. INSERT drive (with `root_folder_id = NULL`) → returns drive id.
|
||||||
- The initial migration sets `name = 'Personal'` on the default
|
2. INSERT folder (with `drive_id` = the drive's id) → returns folder id.
|
||||||
personal drive for back-compat with the label users see today;
|
3. UPDATE drive SET `root_folder_id` = the folder id.
|
||||||
further renames go through the normal drive-rename endpoint
|
4. INSERT role_grant (owner, subject = caller, resource = drive).
|
||||||
and persist on the same row. Secondary personal drives carry
|
5. COMMIT.
|
||||||
whatever name the sibling root folder had (e.g. `Archive`,
|
|
||||||
`2024 Projects`).
|
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.
|
||||||
|
|
||||||
#### Capabilities matrix
|
#### Capabilities matrix
|
||||||
|
|
||||||
@@ -258,11 +404,12 @@ Why this matters:
|
|||||||
| Rename | allowed (by the owner) | allowed (by the owner) | allowed (by any owner) |
|
| 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) |
|
| 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 |
|
| 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 |
|
| Group ownership | no | no | yes |
|
||||||
| Per-resource grant outward | yes (subject to drive policies) | yes | yes |
|
| Per-resource grant outward | yes (subject to drive policies) | yes | yes |
|
||||||
| Cross-drive move | yes (subject to `forbid_cross_drive_move`) | 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 |
|
| 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
|
### 4. Roles → permission bundles
|
||||||
|
|
||||||
@@ -273,7 +420,7 @@ expansion:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `viewer` | `Read` |
|
| `viewer` | `Read` |
|
||||||
| `editor` | `Read`, `Create`, `Update`, `Comment` |
|
| `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`
|
### 5. Permission resolution — additive over `role_grants`
|
||||||
|
|
||||||
@@ -305,16 +452,16 @@ against the same table.
|
|||||||
|
|
||||||
| Event | Behaviour |
|
| 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 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. |
|
| 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.) |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 drive_members`. |
|
| 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
|
### 7. Quota model
|
||||||
|
|
||||||
@@ -336,6 +483,38 @@ After the cutover:
|
|||||||
(plus a periodic reconciliation job to fix drift, similar to the
|
(plus a periodic reconciliation job to fix drift, similar to the
|
||||||
existing per-user accounting).
|
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
|
**Chunk dedup vs per-drive quota.** With the CDC chunk store landed
|
||||||
in v0.7.0 (see `delta_upload_service`, `upload_ingest`, instant
|
in v0.7.0 (see `delta_upload_service`, `upload_ingest`, instant
|
||||||
upload by hash), a single chunk can be referenced by files in
|
upload by hash), a single chunk can be referenced by files in
|
||||||
@@ -479,34 +658,57 @@ discriminator is the literal segment (`files` vs `drives`), never
|
|||||||
the value of `<x>`. A user happening to have a UUID-shaped username
|
the value of `<x>`. A user happening to have a UUID-shaped username
|
||||||
is no longer a problem.
|
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
|
Today `storage.folders.path` is e.g. `My Folder - admin/Docs`. The
|
||||||
"My Folder - admin" wrapper is the user's home folder, created at
|
"My Folder - admin" wrapper is the user's home folder, created at
|
||||||
registration via `format!("My Folder - {}", username)`.
|
registration via `format!("My Folder - {}", username)`.
|
||||||
|
|
||||||
Post-drives, **the wrapper goes away**. The drive itself is the
|
Post-drives, **the wrapper isn't deleted — it's *adopted* as the
|
||||||
root; folders and files that used to live inside the wrapper sit
|
drive's root folder** (§3). The drive row is created alongside it
|
||||||
directly under the drive with no intermediate folder:
|
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
|
Drive (uuid=…, kind=personal, default_for_user=admin)
|
||||||
|
└── root folder (parent_id=NULL, drive_id=<drive_uuid>, name="Personal") ← was "My Folder - admin"
|
||||||
├── Docs/
|
├── Docs/
|
||||||
└── aa.pdf
|
└── 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)
|
Drive (uuid=…, kind=shared, owners=group:engineering)
|
||||||
|
└── root folder (parent_id=NULL, drive_id=<drive_uuid>, name="Engineering")
|
||||||
├── Specs/
|
├── Specs/
|
||||||
├── Roadmap.md
|
├── Roadmap.md
|
||||||
└── archive/
|
└── archive/
|
||||||
```
|
```
|
||||||
|
|
||||||
The two surfaces share one rule: **drive root = `parent_id IS NULL`
|
One rule, one model: **every drive has exactly one folder where
|
||||||
within the drive's `drive_id`**. The "personal vs shared" branch
|
`parent_id IS NULL` AND `drive_id = <the drive>`**.
|
||||||
disappears from path resolution — both kinds resolve the same way.
|
|
||||||
|
#### 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
|
#### Why this is client-safe
|
||||||
|
|
||||||
@@ -519,22 +721,48 @@ The wrapper was already invisible to WebDAV / NC clients pre-drive:
|
|||||||
- Native `/webdav/<path>` was implicitly chrooted to the user's
|
- Native `/webdav/<path>` was implicitly chrooted to the user's
|
||||||
home by `resolve_webdav_path`. Same story.
|
home by `resolve_webdav_path`. Same story.
|
||||||
|
|
||||||
So URL-level back-compat is preserved trivially — clients keep
|
Post-migration the resolver chroot becomes `<drive_root_folder.path>/`
|
||||||
asking for `/remote.php/dav/files/admin/Docs/foo.pdf`, the
|
instead of `My Folder - <user>/`. The drive's root folder name
|
||||||
resolver no longer prepends the wrapper, and the storage row's
|
(e.g. `Personal`) replaces the wrapper name in the materialised
|
||||||
path is now `Docs/foo.pdf` instead of `My Folder - admin/Docs/foo.pdf`.
|
`path` column; the client's URL still doesn't carry it
|
||||||
Net effect on the wire: zero.
|
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
|
Pre-drive, two indexes enforce "no duplicate folder names under the
|
||||||
no value beyond the storage layer (clients never see it, the
|
same parent for the same user":
|
||||||
filesystem mirror is happy either way). Keeping it forced path
|
|
||||||
resolution to always know whether the caller is in a personal or
|
```sql
|
||||||
shared drive and conditionally prepend a segment. Dropping it
|
CREATE UNIQUE INDEX idx_folders_unique_name
|
||||||
collapses that branch and makes the personal-vs-shared distinction
|
ON storage.folders(parent_id, name, user_id)
|
||||||
purely a metadata concern (kind, quota source, member shape) —
|
WHERE NOT is_trashed AND parent_id IS NOT NULL;
|
||||||
**not** a path-shape concern.
|
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
|
#### Sibling root folders also become drives
|
||||||
|
|
||||||
@@ -551,23 +779,26 @@ Post-migration, the model has to absorb them. Rule:
|
|||||||
- For each user, find every row with `parent_id IS NULL AND user_id
|
- For each user, find every row with `parent_id IS NULL AND user_id
|
||||||
= <this user>`.
|
= <this user>`.
|
||||||
- The one named `My Folder - <username>` becomes the **default
|
- The one named `My Folder - <username>` becomes the **default
|
||||||
personal drive**: `kind='personal'`,
|
personal drive's root folder**: a new drive row is created with
|
||||||
`default_for_user=<user>`, sole member = the user.
|
`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
|
- Every **other** sibling becomes a fresh **secondary personal
|
||||||
drive**: `kind='personal'`, `default_for_user=NULL`, sole
|
drive's root folder**: a new drive row with `kind='personal'`,
|
||||||
member = the user, name carried over from the folder's `name`
|
`default_for_user=NULL`, the existing folder row gets its
|
||||||
column, quota initialised from the user's quota
|
`drive_id` set and *keeps its name* (`Archive`, `2024 Projects`,
|
||||||
(`auth.users.storage_quota_bytes`) — same default as the
|
whatever), quota initialised from the user's quota
|
||||||
user's primary personal drive. Membership rules from §2 apply:
|
(`auth.users.storage_quota_bytes`) — same default as the user's
|
||||||
the user cannot invite co-owners while the drive remains
|
primary personal drive. Membership rules from §2 apply: the user
|
||||||
personal. To open the silo up, the user can later **convert**
|
cannot invite co-owners while the drive remains personal. To open
|
||||||
the secondary personal to `kind='shared'` (an
|
the silo up, the user can later **convert** the secondary
|
||||||
application-layer operation that flips the kind and lifts the
|
personal to `kind='shared'` (an application-layer operation that
|
||||||
single-user restriction so the membership API can add other
|
flips the kind and lifts the single-user restriction so the
|
||||||
users / groups).
|
membership API can add other users / groups).
|
||||||
- The folder row is deleted (the drive replaces it as the root).
|
- **No folder row is deleted.** The wrapper rows survive the
|
||||||
Its children get their `parent_id` set to `NULL` *within their
|
migration as drive root folders; only their `drive_id` is set and
|
||||||
new `drive_id`*.
|
(for the default-Personal case) their `name` is updated.
|
||||||
|
|
||||||
The chroot POC's "pick a drive at login" picker on
|
The chroot POC's "pick a drive at login" picker on
|
||||||
`feat/nextcloud-drive` already produces the right shape for this:
|
`feat/nextcloud-drive` already produces the right shape for this:
|
||||||
@@ -918,79 +1149,99 @@ every storage query. We phase it for safety:
|
|||||||
|
|
||||||
### Phase A — additive (PR D0)
|
### 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`.
|
2. Add `drive_id uuid NULL` to `storage.folders` and `storage.files`.
|
||||||
3. **Per-user root-folder sweep**: for each internal user, list
|
3. **Per-user root-folder adoption sweep**: for each internal user,
|
||||||
every `storage.folders` row where `parent_id IS NULL AND
|
list every `storage.folders` row where `parent_id IS NULL AND
|
||||||
user_id = <this user>`. Exactly one is expected to be
|
user_id = <this user>`. Exactly one is expected to be
|
||||||
`My Folder - <username>`; any extras are SQL-created siblings
|
`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
|
- The `My Folder - <username>` row → becomes the **default
|
||||||
personal drive**: `INSERT INTO storage.drives (name='Personal',
|
personal drive's root folder**. In one CTE statement per
|
||||||
kind='personal', default_for_user=<user>, quota_bytes=<user.storage_quota_bytes>)`
|
user (same shape as §3's `create_personal_drive`):
|
||||||
and insert one `(drive_id, user=<user>, role='owner')`
|
- INSERT into `storage.drives` with `kind='personal'`,
|
||||||
member row.
|
`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
|
- Every other sibling row → becomes a fresh **secondary
|
||||||
personal drive**: `kind='personal'`, `default_for_user=NULL`,
|
personal drive's root folder**, same four-write CTE shape
|
||||||
name carried over from the folder's `name`,
|
except: `default_for_user=NULL`, no `name` change (the
|
||||||
`quota_bytes=<user.storage_quota_bytes>`, and one
|
sibling keeps its original name), and the Owner grant points
|
||||||
`(drive_id, user=<user>, role='owner')` member row.
|
at the same user. Membership rules from §2 apply
|
||||||
Membership rules from §2 apply (single-owner, no `add_member`);
|
(single-owner, no `add_member`); the user can later promote
|
||||||
the user can later promote one to `kind='shared'` to invite
|
one to `kind='shared'` to invite collaborators.
|
||||||
collaborators.
|
4. **Cascade `drive_id` down the tree** — for every folder/file
|
||||||
4. **Promote children, drop the wrapper**: for every folder/file
|
row, set `drive_id` by walking the ancestry up to whichever
|
||||||
row that has `parent_id = <a root row from step 3>`, set
|
adopted root the row descends from. After this step every row
|
||||||
`drive_id = <that root's new drive id>` and `parent_id = NULL`
|
has the same `drive_id` as its `parent_id`'s row, which chains
|
||||||
(the drive itself is the new root, not a folder). Then DELETE
|
up to a root folder whose `drive_id` was set in step 3.
|
||||||
the root folder rows from step 3 — they no longer exist as
|
Reuse the existing ltree-aware recursive helper
|
||||||
folders, the drive replaces them.
|
(`storage.copy_folder_tree`-style descent) — single
|
||||||
5. **Cascade `drive_id` down the tree** — for every remaining
|
`UPDATE … WHERE` per drive, not a row-at-a-time loop.
|
||||||
folder/file row, set `drive_id` by walking the ancestry to
|
5. **No bulk path rewrite.** The `path` column on descendants is
|
||||||
whichever root the row descends from. After this step every
|
untouched by this migration. The wrapper rename in step 3
|
||||||
row has the same `drive_id` as its `parent_id`'s row, which
|
(`My Folder - admin` → `Personal`) is the only path-affecting
|
||||||
chains up to a drive set in step 3/4.
|
change; the BEFORE-UPDATE folder trigger rewrites the wrapper
|
||||||
6. **Full path-metadata reconstruction**. The `path` column on
|
row's own `path` / `lpath`, and the AFTER-UPDATE cascade
|
||||||
**every** row in `storage.folders` and `storage.files` gets
|
trigger propagates the new path prefix to every descendant
|
||||||
rewritten. For rows that descended from `My Folder - <username>`,
|
automatically.
|
||||||
strip that prefix; for rows that descended from a sibling root,
|
- **Downstream caches and indexes** — most are unaffected
|
||||||
strip that sibling's `name`. The path column now contains only
|
because path *content* changes only inside the renamed
|
||||||
the in-drive path (e.g. `Docs/foo.pdf`, never
|
wrapper segment (descendants reflect "Personal/…" instead of
|
||||||
`My Folder - admin/Docs/foo.pdf`).
|
"My Folder - admin/…"). Audit:
|
||||||
- **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:
|
|
||||||
- **Tantivy content index (§11)** — the index does NOT
|
- **Tantivy content index (§11)** — the index does NOT
|
||||||
store paths (see `tantivy_content_index.rs`: indexed
|
store paths (see `tantivy_content_index.rs`: indexed
|
||||||
fields are `file_id`, `user_id`, `name` (basename only),
|
fields are `file_id`, `user_id`, `name` (basename only),
|
||||||
`content`. No `path` field, the wrapper folder name was
|
`content`. No `path` field). Reindex IS still required —
|
||||||
never a term). So the wrapper removal alone requires no
|
not because of paths but because the schema gains
|
||||||
reindex. The reindex §11 calls for is driven by the
|
`drive_id` and the query filter pivots from `user_id` to
|
||||||
schema gaining `drive_id` and the query filter pivoting
|
`drive_id`. Same migration window, different reason.
|
||||||
from `user_id` to `drive_id` — NOT by the path rewrite.
|
- **Thumbnail cache** — file_id-keyed: unaffected by the
|
||||||
Same migration window, but for a different reason.
|
wrapper rename. Path-keyed entries (if any) invalidate on
|
||||||
- **Thumbnail cache** — if keyed by path rather than
|
any path change in the wrapper; flush as a precaution and
|
||||||
file_id, invalidate; preferably switch to file_id-keyed
|
switch to file_id keying during this migration if not
|
||||||
during this migration so the issue doesn't recur. Audit
|
already done.
|
||||||
before D0 starts.
|
|
||||||
- **Folder ETag queue (`async_tree_etag_queue`,** see Open
|
- **Folder ETag queue (`async_tree_etag_queue`,** see Open
|
||||||
Question 8) — flush or recompute; ETags derived from old
|
Question 8) — recompute. The wrapper rename touches the
|
||||||
paths are stale.
|
wrapper's own ETag at minimum; ancestors-of-ancestors
|
||||||
|
below the wrapper are structurally unchanged.
|
||||||
- **Recent-items / favorites** — referenced by file_id, not
|
- **Recent-items / favorites** — referenced by file_id, not
|
||||||
path; probably fine. Verify.
|
path; unaffected.
|
||||||
- **On-disk storage mirror** — see Open Question 10. If the
|
- **On-disk storage mirror** — see Open Question 10. If the
|
||||||
filesystem layout is path-mirrored, every file moves on
|
filesystem layout mirrors `path`, the wrapper directory
|
||||||
disk too; if content-addressable, the FS is untouched.
|
itself is renamed (one `mv`) and the descendant directories
|
||||||
Audit before D0 starts.
|
don't move; the rename is atomic on the filesystem. If
|
||||||
7. Verify: every row has `drive_id IS NOT NULL`, no row has
|
content-addressable, the FS is untouched.
|
||||||
`parent_id` pointing at a non-existent folder, no `path` value
|
6. Verify: every row has `drive_id IS NOT NULL`; every drive has
|
||||||
contains the legacy `My Folder - ` prefix.
|
`root_folder_id IS NOT NULL` and pointing at a real folder row
|
||||||
8. Add `NOT NULL` constraint on `drive_id`.
|
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.
|
||||||
|
|
||||||
**Keep `user_id`** on resources alongside `drive_id` for the entire
|
**Keep `user_id`** on resources alongside `drive_id` for the entire
|
||||||
Phase A release cycle. Code is updated to read `drive_id` everywhere;
|
Phase A release cycle. Code is updated to read `drive_id` everywhere;
|
||||||
@@ -1129,18 +1380,26 @@ place on `storage.files` / `storage.folders`, which already carry
|
|||||||
not.
|
not.
|
||||||
|
|
||||||
10. **On-disk storage mirror — does the file path under
|
10. **On-disk storage mirror — does the file path under
|
||||||
`OXICLOUD_STORAGE_PATH` change too?** Phase A step 6 strips
|
`OXICLOUD_STORAGE_PATH` change too?** Phase A step 3 renames
|
||||||
the `My Folder - <username>/` prefix from `storage.folders.path`
|
the wrapper folder row (`My Folder - admin` → `Personal`) for
|
||||||
/ `storage.files.path` columns. If the on-disk layout mirrors
|
each default personal drive; the AFTER-UPDATE trigger
|
||||||
these paths (`<storage>/<user_id>/My Folder - admin/Docs/foo.pdf`),
|
rewrites descendant `storage.folders.path` /
|
||||||
the migration also has to `mv` every file on disk. If on-disk
|
`storage.files.path` values automatically (no bulk UPDATE in
|
||||||
is content-addressable (BLAKE3-keyed), the columns can be
|
the migration script). If the on-disk layout mirrors these
|
||||||
rewritten without touching the filesystem. **Audit the
|
paths (`<storage>/<user_id>/My Folder - admin/Docs/foo.pdf`),
|
||||||
storage adapter before starting D0** and decide whether the
|
the migration ALSO has to rename the wrapper directory on
|
||||||
migration script:
|
disk — **but only the wrapper directory itself**, one `mv`
|
||||||
- just renames the path columns (CAS layout — cheap), or
|
per drive, atomic on the filesystem; no descendant `mv`
|
||||||
- renames the path columns AND issues a `mv` per file
|
needed. If on-disk is content-addressable (BLAKE3-keyed),
|
||||||
(path-mirrored layout — expensive on big instances).
|
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
|
The blob store is content-addressable as of v0.7.0 so most
|
||||||
file content lives under `.blobs/<hash[..2]>/<hash>` and is
|
file content lives under `.blobs/<hash[..2]>/<hash>` and is
|
||||||
already wrapper-agnostic; the concern is only the
|
already wrapper-agnostic; the concern is only the
|
||||||
@@ -1186,11 +1445,13 @@ place on `storage.files` / `storage.folders`, which already carry
|
|||||||
check uses this to resolve group-owner subjects.
|
check uses this to resolve group-owner subjects.
|
||||||
- **`folder_service::create_home_folder`** at
|
- **`folder_service::create_home_folder`** at
|
||||||
`src/application/services/folder_service.rs:644` is where the
|
`src/application/services/folder_service.rs:644` is where the
|
||||||
per-user wrapper folder is created today. Post-migration this
|
per-user wrapper folder is created today. Post-migration the
|
||||||
function **goes away** — there is no wrapper folder anymore. The
|
function is **replaced** by a single `create_personal_drive`
|
||||||
user-create lifecycle hook now creates a Drive row directly and
|
call against `DriveRepository` that runs the §3 atomic CTE:
|
||||||
inserts the owner-role member row. The lifecycle path is the same;
|
drive + root folder (named "Personal", `parent_id=NULL`,
|
||||||
the work it does shrinks.
|
`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
|
- **NC path resolver `nc_to_internal_path`** at
|
||||||
`src/interfaces/nextcloud/webdav_handler.rs:51` and the native
|
`src/interfaces/nextcloud/webdav_handler.rs:51` and the native
|
||||||
resolver `resolve_webdav_path` at
|
resolver `resolve_webdav_path` at
|
||||||
@@ -1198,11 +1459,12 @@ place on `storage.files` / `storage.folders`, which already carry
|
|||||||
callsites that learn about drives. Both gain a "drive context"
|
callsites that learn about drives. Both gain a "drive context"
|
||||||
parameter resolved from the URL prefix (`/files/<u>/` or
|
parameter resolved from the URL prefix (`/files/<u>/` or
|
||||||
`{user}~{uuid}` for NC; `/webdav/` or `/webdav/drives/<uuid>/`
|
`{user}~{uuid}` for NC; `/webdav/` or `/webdav/drives/<uuid>/`
|
||||||
for native). **Neither resolver prepends `My Folder - <user>/`
|
for native). Each resolves to the drive's root folder via
|
||||||
anymore** — the storage path IS the in-drive path. Both
|
`drives.root_folder_id` and prepends that folder's `path`
|
||||||
functions also get simpler, not more complex, despite gaining
|
(after the migration this is `Personal/…` for default personal
|
||||||
the drive parameter (the personal-vs-shared branch is now a
|
drives, the original sibling-root name for secondaries, the
|
||||||
metadata lookup, not a path-shape decision).
|
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
|
- **`MagicLinkInviteService`** and the share-notification pipeline
|
||||||
(`RecipientNotificationService`) need the new policy checks
|
(`RecipientNotificationService`) need the new policy checks
|
||||||
(`forbid_external_sharing`, `forbid_sharing`) wired in at their
|
(`forbid_external_sharing`, `forbid_sharing`) wired in at their
|
||||||
@@ -1258,12 +1520,19 @@ test`), **(c)** `cargo fmt && cargo clippy --all-features
|
|||||||
cleanly with the expected `DomainError`.
|
cleanly with the expected `DomainError`.
|
||||||
- **Migration round-trip**: roll forward against a populated DB →
|
- **Migration round-trip**: roll forward against a populated DB →
|
||||||
every existing folder/file row has `drive_id` set (no NULLs);
|
every existing folder/file row has `drive_id` set (no NULLs);
|
||||||
every user has exactly one drive with `default_for_user` set;
|
every drive has `root_folder_id IS NOT NULL` and the row it
|
||||||
sibling root folders became secondary `kind='personal'` drives;
|
points at has `parent_id IS NULL AND drive_id = <self>` (the
|
||||||
every `storage.folders.path` and `storage.files.path` value has
|
1:1 invariant from §3); every user has exactly one drive with
|
||||||
the `My Folder - <username>/` prefix stripped → roll back via
|
`default_for_user` set; sibling root folders became secondary
|
||||||
`sqlx migrate revert` → `drive_id` column gone, `user_id` intact
|
`kind='personal'` drives whose root folders kept their original
|
||||||
thanks to dual-write, original paths recovered.
|
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`
|
- **Storage check**: post-migration `bash tests/api/storage_cleanup_check.sh`
|
||||||
still reports a clean tree (no orphans).
|
still reports a clean tree (no orphans).
|
||||||
- **Tantivy reindex**: every indexed doc carries a `drive_id`;
|
- **Tantivy reindex**: every indexed doc carries a `drive_id`;
|
||||||
@@ -1498,6 +1767,13 @@ static/css/components/driveSwitcher.css ← D1
|
|||||||
the `auth.app_passwords.drive_id` binding — see §9).
|
the `auth.app_passwords.drive_id` binding — see §9).
|
||||||
- **Wrapper folder** — historical name for
|
- **Wrapper folder** — historical name for
|
||||||
`My Folder - <username>`, the folder created at registration
|
`My Folder - <username>`, the folder created at registration
|
||||||
via `format!("My Folder - {}", username)`. **Retired** in the
|
via `format!("My Folder - {}", username)`. **Adopted** in the
|
||||||
Drive migration: drive root replaces it. Every reference to
|
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.
|
"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.
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS storage.drives (
|
CREATE TABLE IF NOT EXISTS storage.drives (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Discriminant. Two kinds today; extending the set is a DROP + ADD
|
-- Discriminant. Two kinds today; extending the set is a DROP + ADD
|
||||||
-- CHECK constraint pair (no separate lookup table).
|
-- CHECK constraint pair (no separate lookup table).
|
||||||
@@ -36,10 +35,24 @@ CREATE TABLE IF NOT EXISTS storage.drives (
|
|||||||
default_for_user UUID
|
default_for_user UUID
|
||||||
REFERENCES auth.users(id) ON DELETE CASCADE,
|
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
|
-- Storage quota in bytes. NULL = no quota (admin override / system
|
||||||
-- drives). Initial value on personal-drive creation is taken from
|
-- drives). Initial value on personal-drive creation is taken from
|
||||||
-- the owner's `auth.users.storage_quota_bytes` at the application
|
-- the owner's `auth.users.storage_quota_bytes` at the application
|
||||||
-- layer.
|
-- layer. **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) —
|
||||||
|
-- not in the drive `owner` role bundle.
|
||||||
quota_bytes BIGINT,
|
quota_bytes BIGINT,
|
||||||
|
|
||||||
-- Running total of bytes consumed. Maintained by D4's incremental
|
-- Running total of bytes consumed. Maintained by D4's incremental
|
||||||
@@ -59,13 +72,19 @@ CREATE TABLE IF NOT EXISTS storage.drives (
|
|||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE storage.drives IS
|
COMMENT ON TABLE storage.drives IS
|
||||||
'Drive entity. Top-level container that owns a tree of folders/files; '
|
'Drive entity — pure metadata. The display name and mount point live '
|
||||||
'membership lives in storage.role_grants with resource_type=''drive''. '
|
'on the root folder (root_folder_id). Membership lives in '
|
||||||
'Replaced the per-user My Folder wrapper at D0 (see docs/plan/drive.md).';
|
'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
|
COMMENT ON COLUMN storage.drives.kind IS
|
||||||
'personal = single-owner (no add_member); shared = multi-member with full role roster.';
|
'personal = single-owner (no add_member); shared = multi-member with full role roster.';
|
||||||
COMMENT ON COLUMN storage.drives.default_for_user IS
|
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.';
|
'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
|
COMMENT ON COLUMN storage.drives.policies IS
|
||||||
'JSONB capability-flag bag; see docs/plan/drive.md §8 §15 for known keys.';
|
'JSONB capability-flag bag; see docs/plan/drive.md §8 §15 for known keys.';
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
-- ════════════════════════════════════════════════════════════════════════════
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
-- D0 / M2 — Drive backfill: create drives + stamp drive_id + provenance
|
-- D0 / M2 — Drive backfill: adopt wrappers + stamp drive_id + provenance
|
||||||
-- ════════════════════════════════════════════════════════════════════════════
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
-- Second of the D0 migration trio. Half (1) of the §A backfill — the safe,
|
-- Second of the D0 migration trio. Implements §A of the migration plan in
|
||||||
-- focused half:
|
-- docs/plan/drive.md — the "rename-and-adopt" model:
|
||||||
--
|
--
|
||||||
-- * For every internal user with a root folder, create a Personal drive.
|
-- * For every internal user with a root folder, create a Personal drive
|
||||||
-- * The folder literally named `My Folder - <username>` becomes the
|
-- (metadata only — no `name` column; the display name lives on the
|
||||||
-- user's default Personal drive (`default_for_user = <uid>`).
|
-- root folder).
|
||||||
-- * Any sibling root folders become secondary Personal drives
|
-- * The folder literally named `My Folder - <username>` is **adopted
|
||||||
-- (`default_for_user = NULL`, name carried over verbatim).
|
-- 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.
|
-- * One owner role_grants row per new drive.
|
||||||
-- * Every existing folder/file row gets a `drive_id` (cascaded down the
|
-- * Every existing folder/file row gets a `drive_id` (cascaded down the
|
||||||
-- ltree from the wrapper).
|
-- ltree from the wrapper).
|
||||||
-- * Every existing folder/file row gets `created_by` and `updated_by`
|
-- * Every existing folder/file row gets `created_by` and `updated_by`
|
||||||
-- backfilled from the existing `user_id` column.
|
-- backfilled from the existing `user_id` column.
|
||||||
--
|
--
|
||||||
-- The aggressive half (drop the wrapper folder, rewrite path columns,
|
|
||||||
-- strip the `My Folder - <username>/` prefix from every path/lpath value)
|
|
||||||
-- lands in M2b — kept separate so the tree-shape rewrite can be reviewed
|
|
||||||
-- in isolation.
|
|
||||||
--
|
|
||||||
-- External users (`auth.users.is_external = TRUE`) are intentionally
|
-- External users (`auth.users.is_external = TRUE`) are intentionally
|
||||||
-- skipped — they have no root folder of their own, only role_grants
|
-- skipped — they have no root folder of their own, only role_grants
|
||||||
-- against other users' resources.
|
-- against other users' resources.
|
||||||
@@ -56,6 +60,49 @@ BEGIN
|
|||||||
END $BODY$;
|
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) ─────────
|
-- ── Pre-flight 2: report sibling-root distribution (informational) ─────────
|
||||||
-- Most users have exactly one root (`My Folder - <username>`). Some may
|
-- Most users have exactly one root (`My Folder - <username>`). Some may
|
||||||
-- have SQL-added siblings — those become secondary drives. Surface the
|
-- have SQL-added siblings — those become secondary drives. Surface the
|
||||||
@@ -173,16 +220,15 @@ BEGIN
|
|||||||
END $BODY$;
|
END $BODY$;
|
||||||
|
|
||||||
|
|
||||||
-- ── 2. Insert the drive rows ───────────────────────────────────────────────
|
-- ── 2. Insert the drive rows (metadata only — no `name` column) ───────────
|
||||||
-- Default drives carry the i18n-neutral name 'Personal' (renameable
|
-- Drives are pure metadata under the new design (docs/plan/drive.md §3).
|
||||||
-- later via the drive settings panel). Secondary drives carry their
|
-- The display name lives on the root folder; the wrapper is renamed in
|
||||||
-- original folder name verbatim.
|
-- step 4b for default drives and kept as-is for secondaries.
|
||||||
|
|
||||||
INSERT INTO storage.drives
|
INSERT INTO storage.drives
|
||||||
(id, name, kind, default_for_user, quota_bytes)
|
(id, kind, default_for_user, quota_bytes)
|
||||||
SELECT
|
SELECT
|
||||||
p.new_drive_id,
|
p.new_drive_id,
|
||||||
CASE WHEN p.is_default THEN 'Personal' ELSE p.wrapper_name END,
|
|
||||||
'personal',
|
'personal',
|
||||||
CASE WHEN p.is_default THEN p.user_id ELSE NULL END,
|
CASE WHEN p.is_default THEN p.user_id ELSE NULL END,
|
||||||
p.quota
|
p.quota
|
||||||
@@ -199,16 +245,29 @@ SELECT 'user', p.user_id, 'drive', p.new_drive_id, 'owner', p.user_id
|
|||||||
FROM _drive_plan p;
|
FROM _drive_plan p;
|
||||||
|
|
||||||
|
|
||||||
-- ── 4. Stamp drive_id on each wrapper folder ──────────────────────────────
|
-- ── 4. Adopt the wrapper as the drive's root folder ───────────────────────
|
||||||
-- The wrapper still exists as a folder during M2 (the wrapper-drop lives
|
-- 4a. Stamp drive_id on each wrapper so the cascade in §5 can walk the
|
||||||
-- in M2b). Setting drive_id on the wrapper lets the cascade in §5 walk
|
-- ltree subtree without a separate index.
|
||||||
-- the ltree subtree without needing 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
|
UPDATE storage.folders f
|
||||||
SET drive_id = p.new_drive_id
|
SET drive_id = p.new_drive_id,
|
||||||
|
name = CASE WHEN p.is_default THEN 'Personal' ELSE f.name END
|
||||||
FROM _drive_plan p
|
FROM _drive_plan p
|
||||||
WHERE f.id = p.wrapper_id;
|
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 ──────────────────────────────
|
-- ── 5. Cascade drive_id down the folder tree ──────────────────────────────
|
||||||
-- For every folder descended from a wrapper, set drive_id to that
|
-- For every folder descended from a wrapper, set drive_id to that
|
||||||
@@ -284,6 +343,7 @@ DECLARE
|
|||||||
grantless_drives BIGINT;
|
grantless_drives BIGINT;
|
||||||
null_folder_drive_id BIGINT;
|
null_folder_drive_id BIGINT;
|
||||||
null_file_drive_id BIGINT;
|
null_file_drive_id BIGINT;
|
||||||
|
rootless_drives BIGINT;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT count(*) INTO missing_default
|
SELECT count(*) INTO missing_default
|
||||||
FROM auth.users u
|
FROM auth.users u
|
||||||
@@ -320,6 +380,29 @@ BEGIN
|
|||||||
grantless_drives;
|
grantless_drives;
|
||||||
END IF;
|
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
|
SELECT count(*) INTO null_folder_drive_id
|
||||||
FROM storage.folders f
|
FROM storage.folders f
|
||||||
WHERE f.drive_id IS NULL
|
WHERE f.drive_id IS NULL
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ 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);
|
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 ────────────────────────────
|
-- ── 4. Post-flight: confirm constraints landed ────────────────────────────
|
||||||
-- Belt-and-suspenders verification that the NOT NULL + FK actually
|
-- Belt-and-suspenders verification that the NOT NULL + FK actually
|
||||||
-- exist after the ALTERs above. Any failure here means PostgreSQL
|
-- exist after the ALTERs above. Any failure here means PostgreSQL
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::domain::entities::drive::{Drive, DriveKind};
|
use crate::domain::entities::drive::DriveKind;
|
||||||
|
use crate::domain::repositories::drive_repository::DriveWithRootName;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -34,12 +35,22 @@ impl From<DriveKind> for DriveKindDto {
|
|||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
pub struct DriveDto {
|
pub struct DriveDto {
|
||||||
pub id: Uuid,
|
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 name: String,
|
||||||
pub kind: DriveKindDto,
|
pub kind: DriveKindDto,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub default_for_user: Option<Uuid>,
|
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 /
|
/// Storage cap in bytes. `None` means "no quota" (admin override /
|
||||||
/// future system drives).
|
/// future system drives). Mutation is OxiCloud-admin only — drive
|
||||||
|
/// owners cannot self-grant capacity.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub quota_bytes: Option<i64>,
|
pub quota_bytes: Option<i64>,
|
||||||
/// Running total of bytes consumed. Maintained incrementally in D4;
|
/// Running total of bytes consumed. Maintained incrementally in D4;
|
||||||
@@ -53,18 +64,19 @@ pub struct DriveDto {
|
|||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Drive> for DriveDto {
|
impl From<DriveWithRootName> for DriveDto {
|
||||||
fn from(d: Drive) -> Self {
|
fn from(d: DriveWithRootName) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: d.id,
|
id: d.drive.id,
|
||||||
name: d.name,
|
name: d.root_folder_name,
|
||||||
kind: d.kind.into(),
|
kind: d.drive.kind.into(),
|
||||||
default_for_user: d.default_for_user,
|
default_for_user: d.drive.default_for_user,
|
||||||
quota_bytes: d.quota_bytes,
|
root_folder_id: d.drive.root_folder_id,
|
||||||
used_bytes: d.used_bytes,
|
quota_bytes: d.drive.quota_bytes,
|
||||||
policies: d.policies,
|
used_bytes: d.drive.used_bytes,
|
||||||
created_at: d.created_at,
|
policies: d.drive.policies,
|
||||||
updated_at: d.updated_at,
|
created_at: d.drive.created_at,
|
||||||
|
updated_at: d.drive.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,19 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
|||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
) -> Result<FolderDto, DomainError>;
|
) -> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
/// Gets a folder by its path
|
/// Gets a folder by its path within the caller's tree.
|
||||||
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
|
///
|
||||||
|
/// Scoped by `user_id` because `storage.folders.path` is unique
|
||||||
|
/// only within a single user's drive after D0 — multiple users
|
||||||
|
/// share names like `"Personal"` for their default-drive root
|
||||||
|
/// folder (docs/plan/drive.md §10). Pre-D0 the wrapper name
|
||||||
|
/// embedded the username and made the path globally unique;
|
||||||
|
/// post-D0 the caller_id filter is required.
|
||||||
|
async fn get_folder_by_path(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
/// Lists folders within a parent folder
|
/// Lists folders within a parent folder
|
||||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
|
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ impl FolderService {
|
|||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_by_path(
|
||||||
|
&self,
|
||||||
|
_path: &str,
|
||||||
|
_user_id: Uuid,
|
||||||
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,14 +297,17 @@ impl FolderUseCase for FolderService {
|
|||||||
self.get_folder(id).await
|
self.get_folder(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets a folder by its path
|
/// Gets a folder by its path, scoped to the caller's tree.
|
||||||
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_by_path(
|
||||||
// Convert the string path to StoragePath
|
&self,
|
||||||
|
path: &str,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<FolderDto, DomainError> {
|
||||||
let storage_path = StoragePath::from_string(path);
|
let storage_path = StoragePath::from_string(path);
|
||||||
|
|
||||||
let folder = self
|
let folder = self
|
||||||
.folder_storage
|
.folder_storage
|
||||||
.get_folder_by_path(&storage_path)
|
.get_folder_by_path(&storage_path, user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
@@ -766,23 +773,22 @@ use crate::domain::entities::user::User;
|
|||||||
/// when the Owner row is already present.
|
/// when the Owner row is already present.
|
||||||
pub struct PersonalDriveLifecycleHook {
|
pub struct PersonalDriveLifecycleHook {
|
||||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||||
folder_service: Arc<FolderService>,
|
|
||||||
// The `AuthorizationEngine` trait isn't `dyn`-compatible (native
|
// The `AuthorizationEngine` trait isn't `dyn`-compatible (native
|
||||||
// async-fn-in-trait methods are not object-safe), so we hold the
|
// async-fn-in-trait methods are not object-safe), so we hold the
|
||||||
// concrete engine. This matches the convention already used by
|
// concrete engine. This matches the convention already used by
|
||||||
// `AppState.authorization`.
|
// `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>,
|
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PersonalDriveLifecycleHook {
|
impl PersonalDriveLifecycleHook {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||||
folder_service: Arc<FolderService>,
|
|
||||||
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
drive_repo,
|
drive_repo,
|
||||||
folder_service,
|
|
||||||
authorization,
|
authorization,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -792,9 +798,7 @@ impl PersonalDriveLifecycleHook {
|
|||||||
/// trait docstring — they have no resources of their own, only
|
/// trait docstring — they have no resources of their own, only
|
||||||
/// grants on other users' resources.
|
/// grants on other users' resources.
|
||||||
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
|
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
|
||||||
use crate::domain::repositories::drive_repository::{
|
use crate::domain::repositories::drive_repository::DriveRepositoryError;
|
||||||
CreatePersonalDriveInput, DriveRepositoryError,
|
|
||||||
};
|
|
||||||
use crate::domain::services::authorization::{Resource, Role, Subject};
|
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||||
|
|
||||||
if user.is_external() {
|
if user.is_external() {
|
||||||
@@ -802,20 +806,19 @@ impl PersonalDriveLifecycleHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent shortcut: if the user already has a default drive,
|
// Idempotent shortcut: if the user already has a default drive,
|
||||||
// nothing to do. Covers re-runs from `on_user_login` plus the
|
// the atomic CTE already ran on a prior turn. The CTE writes
|
||||||
// case where `on_user_created` ran successfully but logged in
|
// the Owner role_grant inline, so there's nothing to repair —
|
||||||
// before reaching the role_grant step (next-login retry lands
|
// but we still re-emit the grant via `set_role` (UPSERT-safe)
|
||||||
// here and finds the drive, completing the role_grant if missing).
|
// 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 {
|
match self.drive_repo.find_default_for_user(user.id()).await {
|
||||||
Ok(drive) => {
|
Ok(drive_with_name) => {
|
||||||
// Drive exists; ensure the Owner role_grant is in
|
|
||||||
// place too. `set_role` is an UPSERT — safe to re-run.
|
|
||||||
self.authorization
|
self.authorization
|
||||||
.set_role(
|
.set_role(
|
||||||
user.id(),
|
user.id(),
|
||||||
Subject::User(user.id()),
|
Subject::User(user.id()),
|
||||||
Role::Owner,
|
Role::Owner,
|
||||||
Resource::Drive(drive.id),
|
Resource::Drive(drive_with_name.drive.id),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -831,49 +834,28 @@ impl PersonalDriveLifecycleHook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the drive.
|
// One atomic CTE — drive row + root folder ("Personal",
|
||||||
let drive = self
|
// 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
|
.drive_repo
|
||||||
.create_personal(CreatePersonalDriveInput {
|
.create_personal_drive_atomic(user.id(), Some(user.storage_quota_bytes()))
|
||||||
name: "Personal".to_owned(),
|
|
||||||
owner_id: user.id(),
|
|
||||||
is_default: true,
|
|
||||||
quota_bytes: Some(user.storage_quota_bytes()),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::internal_error("PersonalDriveHook", format!("create_personal: {e}"))
|
DomainError::internal_error(
|
||||||
})?;
|
"PersonalDriveHook",
|
||||||
|
format!("create_personal_drive_atomic: {e}"),
|
||||||
// Stamp the Owner role_grant.
|
|
||||||
self.authorization
|
|
||||||
.set_role(
|
|
||||||
user.id(),
|
|
||||||
Subject::User(user.id()),
|
|
||||||
Role::Owner,
|
|
||||||
Resource::Drive(drive.id),
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
})?;
|
||||||
.map(|_grant| ())?;
|
|
||||||
|
|
||||||
// Provision the wrapper `My Folder - <username>` folder under
|
|
||||||
// the new drive. The wrapper is retained through the D0 dual-
|
|
||||||
// write window (M2b retires it later); without it, existing API
|
|
||||||
// surfaces that assume `GET /api/folders` returns a root folder
|
|
||||||
// (the UI listing, the WebDAV resolver, the Hurl baselines) all
|
|
||||||
// break for newly-provisioned users.
|
|
||||||
self.folder_service
|
|
||||||
.ensure_home_folder(user.id(), drive.id, user.username())
|
|
||||||
.await
|
|
||||||
.map(|_created| ())?;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "user_lifecycle",
|
target: "user_lifecycle",
|
||||||
hook = "personal_drive",
|
hook = "personal_drive",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
drive_id = %drive.id,
|
drive_id = %drive_with_name.drive.id,
|
||||||
"Default personal drive + wrapper folder provisioned"
|
root_folder_id = %drive_with_name.drive.root_folder_id,
|
||||||
|
"Default personal drive + root folder + owner grant provisioned (atomic CTE)"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ impl SearchService {
|
|||||||
.list_for_subjects(&subject_types, &subject_ids)
|
.list_for_subjects(&subject_types, &subject_ids)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(drives) => drives.into_iter().map(|d| d.id).collect(),
|
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
|
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|||||||
@@ -925,6 +925,7 @@ mod tests {
|
|||||||
async fn get_folder_by_path(
|
async fn get_folder_by_path(
|
||||||
&self,
|
&self,
|
||||||
_storage_path: &crate::domain::services::path_service::StoragePath,
|
_storage_path: &crate::domain::services::path_service::StoragePath,
|
||||||
|
_user_id: uuid::Uuid,
|
||||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -709,6 +709,7 @@ impl FolderRepository for MockFolderRepository {
|
|||||||
async fn get_folder_by_path(
|
async fn get_folder_by_path(
|
||||||
&self,
|
&self,
|
||||||
_storage_path: &StoragePath,
|
_storage_path: &StoragePath,
|
||||||
|
_user_id: Uuid,
|
||||||
) -> std::result::Result<Folder, DomainError> {
|
) -> std::result::Result<Folder, DomainError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1202,7 +1202,6 @@ impl AppServiceFactory {
|
|||||||
.with_hook(Arc::new(
|
.with_hook(Arc::new(
|
||||||
crate::application::services::folder_service::PersonalDriveLifecycleHook::new(
|
crate::application::services::folder_service::PersonalDriveLifecycleHook::new(
|
||||||
drive_repo.clone(),
|
drive_repo.clone(),
|
||||||
apps.folder_service_concrete.clone(),
|
|
||||||
authorization.clone(),
|
authorization.clone(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|||||||
+10
-2
@@ -242,7 +242,11 @@ impl FolderRepository for StubFolderStoragePort {
|
|||||||
Ok(Folder::default())
|
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,
|
||||||
|
_user_id: Uuid,
|
||||||
|
) -> Result<Folder, DomainError> {
|
||||||
Ok(Folder::default())
|
Ok(Folder::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +402,11 @@ impl FolderUseCase for StubFolderUseCase {
|
|||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_by_path(
|
||||||
|
&self,
|
||||||
|
_path: &str,
|
||||||
|
_user_id: Uuid,
|
||||||
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,29 +76,39 @@ impl DriveKind {
|
|||||||
|
|
||||||
/// Domain entity for a row in `storage.drives`.
|
/// 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
|
/// Field-level constraints are enforced at the SQL layer (CHECK on
|
||||||
/// `kind`, partial UNIQUE on `default_for_user`). The struct mirrors
|
/// `kind`, partial UNIQUE on `default_for_user`). The struct mirrors
|
||||||
/// the column set 1:1; behaviour beyond field access lives in
|
/// the column set 1:1; behaviour beyond field access lives in
|
||||||
/// `DriveRepository` (D0-5) and `DriveService` (post-D0).
|
/// `DriveRepository` and `DriveService` (post-D0).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Drive {
|
pub struct Drive {
|
||||||
/// Stable identifier. Generated server-side at creation.
|
/// Stable identifier. Generated server-side at creation.
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
/// Display name. Renameable by owners; defaults to "Personal" for
|
|
||||||
/// the user's default personal drive, or the original sibling-root
|
|
||||||
/// folder name for secondaries promoted by the D0 backfill.
|
|
||||||
pub name: String,
|
|
||||||
/// Discriminant — see [`DriveKind`].
|
/// Discriminant — see [`DriveKind`].
|
||||||
pub kind: DriveKind,
|
pub kind: DriveKind,
|
||||||
/// Set iff this is the user's default personal drive (UNIQUE in SQL
|
/// 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
|
/// via a partial index `WHERE default_for_user IS NOT NULL`). NULL
|
||||||
/// on shared drives and on secondary personal drives.
|
/// on shared drives and on secondary personal drives.
|
||||||
pub default_for_user: Option<Uuid>,
|
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
|
/// Soft cap on this drive's storage usage, in bytes. `None` means
|
||||||
/// "no quota" (rare; reserved for admin overrides). The default
|
/// "no quota" (rare; reserved for admin overrides). The default
|
||||||
/// initial quota for a fresh personal drive is taken from the
|
/// initial quota for a fresh personal drive is taken from the
|
||||||
/// owner's `auth.users.storage_quota_bytes` at creation time (see
|
/// owner's `auth.users.storage_quota_bytes` at creation time.
|
||||||
/// Open Question 2 in `docs/plan/drive.md`).
|
/// **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) —
|
||||||
|
/// not in the drive `owner` role bundle.
|
||||||
pub quota_bytes: Option<i64>,
|
pub quota_bytes: Option<i64>,
|
||||||
/// Running total of bytes consumed. Maintained incrementally by
|
/// Running total of bytes consumed. Maintained incrementally by
|
||||||
/// upload/delete paths in D4; on D0 still reflects the pre-Drive
|
/// upload/delete paths in D4; on D0 still reflects the pre-Drive
|
||||||
|
|||||||
@@ -37,52 +37,56 @@ pub enum DriveRepositoryError {
|
|||||||
StorageError(String),
|
StorageError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input parameters for creating a new personal drive.
|
/// A drive paired with the display name from its root folder.
|
||||||
///
|
///
|
||||||
/// Shared drives land in D3 with their own creation surface
|
/// `storage.drives` has no `name` column under the D0 design
|
||||||
/// (`create_shared_drive`). For now D0 only mints personal drives —
|
/// (docs/plan/drive.md §3) — the display name lives on
|
||||||
/// either as the default for a fresh user (via the lifecycle hook) or
|
/// `storage.folders.name` of the row pointed at by `drive.root_folder_id`.
|
||||||
/// as a secondary promoted by the M2 backfill.
|
/// Read paths join the two tables and hand callers this view-model so the
|
||||||
#[derive(Debug, Clone)]
|
/// API surface can continue to expose a single "drive with name" shape
|
||||||
pub struct CreatePersonalDriveInput {
|
/// without a follow-up query per drive.
|
||||||
/// Display name. The lifecycle hook passes `"Personal"`; the M2
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// backfill carries over the original sibling-root folder name for
|
pub struct DriveWithRootName {
|
||||||
/// secondaries.
|
pub drive: Drive,
|
||||||
pub name: String,
|
/// The drive's display name. Sourced from `storage.folders.name`
|
||||||
/// The owner. For personal drives the owner is exactly one user.
|
/// of the root folder via JOIN at read time.
|
||||||
pub owner_id: Uuid,
|
pub root_folder_name: String,
|
||||||
/// `true` when this is the user's default drive (sets the partial-
|
|
||||||
/// unique `default_for_user` column). `false` for secondaries.
|
|
||||||
pub is_default: bool,
|
|
||||||
/// Initial storage quota in bytes. `None` defers to admin policy
|
|
||||||
/// (typically copied from `auth.users.storage_quota_bytes` at the
|
|
||||||
/// call site).
|
|
||||||
pub quota_bytes: Option<i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait DriveRepository: Send + Sync + 'static {
|
pub trait DriveRepository: Send + Sync + 'static {
|
||||||
/// Insert a personal drive row. The caller is responsible for
|
/// Atomically create a personal drive together with its root folder
|
||||||
/// inserting the matching owner row in `storage.role_grants` in the
|
/// and the owner role_grant — all four DB writes in a single SQL
|
||||||
/// same transaction (the lifecycle hook handles this; M2's backfill
|
/// statement (docs/plan/drive.md §3 "Atomic creation"). The
|
||||||
/// did it directly in SQL).
|
/// statement runs as its own implicit transaction in autocommit mode
|
||||||
|
/// so a server crash mid-statement leaves no half-row state.
|
||||||
///
|
///
|
||||||
/// Returns `DefaultDriveAlreadyExists` when `is_default=true` and the
|
/// The root folder is created with name `"Personal"` (the canonical
|
||||||
/// owner already has a default drive — relies on the partial UNIQUE
|
/// default) and `parent_id IS NULL`. The drive's `root_folder_id`
|
||||||
/// index on `default_for_user`.
|
/// is wired to point at it before the statement commits.
|
||||||
async fn create_personal(
|
///
|
||||||
|
/// 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,
|
&self,
|
||||||
input: CreatePersonalDriveInput,
|
owner_id: Uuid,
|
||||||
) -> Result<Drive, DriveRepositoryError>;
|
quota_bytes: Option<i64>,
|
||||||
|
) -> Result<DriveWithRootName, DriveRepositoryError>;
|
||||||
|
|
||||||
/// Fetch a drive by id. `NotFound` when no row matches.
|
/// Fetch a drive by id together with its display name. `NotFound`
|
||||||
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError>;
|
/// when no row matches.
|
||||||
|
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError>;
|
||||||
|
|
||||||
/// Return the caller's default personal drive, or `NotFound` if they
|
/// Return the caller's default personal drive paired with its
|
||||||
/// don't have one (e.g. external users; users created before the
|
/// display name, or `NotFound` if they don't have one (e.g.
|
||||||
/// lifecycle hook fired). Drives the Photos timeline scope, the
|
/// external users; users created before the lifecycle hook fired).
|
||||||
/// `/api/recent/*` scope, and D1's redirect-from-`/`.
|
/// Drives the Photos timeline scope, the `/api/recent/*` scope, and
|
||||||
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError>;
|
/// D1's redirect-from-`/`.
|
||||||
|
async fn find_default_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<DriveWithRootName, DriveRepositoryError>;
|
||||||
|
|
||||||
/// List drives the caller can read, resolved via `role_grants` for
|
/// List drives the caller can read, resolved via `role_grants` for
|
||||||
/// `resource_type='drive'`. The caller's group memberships are
|
/// `resource_type='drive'`. The caller's group memberships are
|
||||||
@@ -90,13 +94,13 @@ pub trait DriveRepository: Send + Sync + 'static {
|
|||||||
/// is what this method's `subject_ids` argument carries.
|
/// is what this method's `subject_ids` argument carries.
|
||||||
///
|
///
|
||||||
/// Returns rows in a stable order: default drive first (if any),
|
/// Returns rows in a stable order: default drive first (if any),
|
||||||
/// then by name. The `/api/drives` handler relies on that order for
|
/// then by display name. The `/api/drives` handler relies on that
|
||||||
/// the picker UI without a follow-up sort.
|
/// order for the picker UI without a follow-up sort.
|
||||||
async fn list_for_subjects(
|
async fn list_for_subjects(
|
||||||
&self,
|
&self,
|
||||||
subject_types: &[&str],
|
subject_types: &[&str],
|
||||||
subject_ids: &[Uuid],
|
subject_ids: &[Uuid],
|
||||||
) -> Result<Vec<Drive>, DriveRepositoryError>;
|
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: convert the canonical kind discriminator from its SQL
|
/// Convenience: convert the canonical kind discriminator from its SQL
|
||||||
|
|||||||
@@ -28,8 +28,18 @@ pub trait FolderRepository: Send + Sync + 'static {
|
|||||||
/// Gets a folder by its ID
|
/// Gets a folder by its ID
|
||||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
||||||
|
|
||||||
/// Gets a folder by its storage path
|
/// Gets a folder by its storage path within the caller's tree.
|
||||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError>;
|
///
|
||||||
|
/// Post-D0, `storage.folders.path` is no longer globally unique —
|
||||||
|
/// multiple users share root-folder names like `"Personal"`. The
|
||||||
|
/// `user_id` filter scopes the lookup to the caller's own folders
|
||||||
|
/// (the equivalent of the pre-D0 implicit user-namespacing that
|
||||||
|
/// came from `My Folder - <username>` paths).
|
||||||
|
async fn get_folder_by_path(
|
||||||
|
&self,
|
||||||
|
storage_path: &StoragePath,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Folder, DomainError>;
|
||||||
|
|
||||||
/// Lists folders within a parent folder
|
/// Lists folders within a parent folder
|
||||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
|
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use sqlx::{PgPool, Row, types::Uuid};
|
|||||||
|
|
||||||
use crate::domain::entities::drive::{Drive, DriveKind};
|
use crate::domain::entities::drive::{Drive, DriveKind};
|
||||||
use crate::domain::repositories::drive_repository::{
|
use crate::domain::repositories::drive_repository::{
|
||||||
CreatePersonalDriveInput, DriveRepository, DriveRepositoryError,
|
DriveRepository, DriveRepositoryError, DriveWithRootName,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct DrivePgRepository {
|
pub struct DrivePgRepository {
|
||||||
@@ -35,67 +35,159 @@ impl DrivePgRepository {
|
|||||||
// unique_violation. With drives, the only relevant unique is
|
// unique_violation. With drives, the only relevant unique is
|
||||||
// the partial index `idx_drives_default_for_user_unique` —
|
// the partial index `idx_drives_default_for_user_unique` —
|
||||||
// surface the typed variant so the lifecycle hook can detect
|
// surface the typed variant so the lifecycle hook can detect
|
||||||
// idempotent re-runs (D0-9 calls create_personal during
|
// idempotent re-runs (D0-9 calls create_personal_drive_atomic
|
||||||
// user provisioning).
|
// during user provisioning).
|
||||||
return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string());
|
return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string());
|
||||||
}
|
}
|
||||||
DriveRepositoryError::StorageError(format!("{context}: {e}"))
|
DriveRepositoryError::StorageError(format!("{context}: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_drive(row: &sqlx::postgres::PgRow) -> Result<Drive, DriveRepositoryError> {
|
/// 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_str: String = row.get("kind");
|
||||||
let kind = DriveKind::from_sql(&kind_str)?;
|
let kind = DriveKind::from_sql(&kind_str)?;
|
||||||
Ok(Drive {
|
let drive = Drive {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
|
||||||
kind,
|
kind,
|
||||||
default_for_user: row.get("default_for_user"),
|
default_for_user: row.get("default_for_user"),
|
||||||
|
root_folder_id: row.get("root_folder_id"),
|
||||||
quota_bytes: row.get("quota_bytes"),
|
quota_bytes: row.get("quota_bytes"),
|
||||||
used_bytes: row.get("used_bytes"),
|
used_bytes: row.get("used_bytes"),
|
||||||
policies: row.get("policies"),
|
policies: row.get("policies"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
|
};
|
||||||
|
Ok(DriveWithRootName {
|
||||||
|
drive,
|
||||||
|
root_folder_name: row.get("root_folder_name"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl DriveRepository for DrivePgRepository {
|
impl DriveRepository for DrivePgRepository {
|
||||||
async fn create_personal(
|
async fn create_personal_drive_atomic(
|
||||||
&self,
|
&self,
|
||||||
input: CreatePersonalDriveInput,
|
owner_id: Uuid,
|
||||||
) -> Result<Drive, DriveRepositoryError> {
|
quota_bytes: Option<i64>,
|
||||||
let default_for_user = if input.is_default {
|
) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||||
Some(input.owner_id)
|
// Four writes wrapped in a single transaction so either all
|
||||||
} else {
|
// commit or none does (docs/plan/drive.md §3). A single CTE
|
||||||
None
|
// statement would be cleaner on paper but doesn't work in
|
||||||
};
|
// PostgreSQL: CTE sub-statements share an MVCC snapshot, so
|
||||||
let row = sqlx::query(
|
// `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#"
|
r#"
|
||||||
INSERT INTO storage.drives
|
INSERT INTO storage.drives
|
||||||
(name, kind, default_for_user, quota_bytes, policies)
|
(kind, default_for_user, quota_bytes, policies)
|
||||||
VALUES ($1, 'personal', $2, $3, '{}'::jsonb)
|
VALUES ('personal', $1, $2, '{}'::jsonb)
|
||||||
RETURNING id, name, kind, default_for_user, quota_bytes,
|
RETURNING id
|
||||||
used_bytes, policies, created_at, updated_at
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&input.name)
|
.bind(owner_id)
|
||||||
.bind(default_for_user)
|
.bind(quota_bytes)
|
||||||
.bind(input.quota_bytes)
|
.fetch_one(&mut *tx)
|
||||||
.fetch_one(self.pool.as_ref())
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Self::map_sqlx_err("create_personal", e))?;
|
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.drive", e))?;
|
||||||
|
|
||||||
Self::row_to_drive(&row)
|
// 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))?;
|
||||||
|
|
||||||
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError> {
|
// 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(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, kind, default_for_user, quota_bytes,
|
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||||
used_bytes, policies, created_at, updated_at
|
d.quota_bytes, d.used_bytes, d.policies,
|
||||||
FROM storage.drives
|
d.created_at, d.updated_at,
|
||||||
WHERE id = $1
|
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)
|
.bind(id)
|
||||||
@@ -104,16 +196,22 @@ impl DriveRepository for DrivePgRepository {
|
|||||||
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?
|
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?
|
||||||
.ok_or_else(|| DriveRepositoryError::NotFound(id.to_string()))?;
|
.ok_or_else(|| DriveRepositoryError::NotFound(id.to_string()))?;
|
||||||
|
|
||||||
Self::row_to_drive(&row)
|
Self::row_to_drive_with_name(&row)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError> {
|
async fn find_default_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, kind, default_for_user, quota_bytes,
|
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||||
used_bytes, policies, created_at, updated_at
|
d.quota_bytes, d.used_bytes, d.policies,
|
||||||
FROM storage.drives
|
d.created_at, d.updated_at,
|
||||||
WHERE default_for_user = $1
|
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)
|
.bind(user_id)
|
||||||
@@ -122,39 +220,41 @@ impl DriveRepository for DrivePgRepository {
|
|||||||
.map_err(|e| Self::map_sqlx_err("find_default_for_user", e))?
|
.map_err(|e| Self::map_sqlx_err("find_default_for_user", e))?
|
||||||
.ok_or_else(|| DriveRepositoryError::NotFound(user_id.to_string()))?;
|
.ok_or_else(|| DriveRepositoryError::NotFound(user_id.to_string()))?;
|
||||||
|
|
||||||
Self::row_to_drive(&row)
|
Self::row_to_drive_with_name(&row)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_for_subjects(
|
async fn list_for_subjects(
|
||||||
&self,
|
&self,
|
||||||
subject_types: &[&str],
|
subject_types: &[&str],
|
||||||
subject_ids: &[Uuid],
|
subject_ids: &[Uuid],
|
||||||
) -> Result<Vec<Drive>, DriveRepositoryError> {
|
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
|
||||||
// Joining `role_grants` → `storage.drives` returns every drive
|
// Joining role_grants → drives → folders returns every drive the
|
||||||
// the expanded subject set can read. ORDER BY puts default
|
// expanded subject set can read, paired with its display name.
|
||||||
// drives first (so the picker UI doesn't need a follow-up
|
// ORDER BY puts default drives first (so the picker UI doesn't
|
||||||
// sort), then alphabetical by name. DISTINCT collapses the
|
// need a follow-up sort), then alphabetical by name. GROUP BY
|
||||||
// case where a caller has multiple role_grants on the same
|
// collapses duplicate role_grants on the same drive (direct +
|
||||||
// drive (e.g. direct + group-mediated); a GROUP BY on the
|
// group-mediated) and sidesteps PostgreSQL's "ORDER BY
|
||||||
// drive id sidesteps PostgreSQL's "ORDER BY expression must
|
// expression must appear in select list" rule that SELECT
|
||||||
// appear in select list" rule that `SELECT DISTINCT` imposes.
|
// DISTINCT imposes.
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT d.id, d.name, d.kind, d.default_for_user,
|
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||||
d.quota_bytes, d.used_bytes, d.policies,
|
d.quota_bytes, d.used_bytes, d.policies,
|
||||||
d.created_at, d.updated_at
|
d.created_at, d.updated_at,
|
||||||
|
f.name AS root_folder_name
|
||||||
FROM storage.drives d
|
FROM storage.drives d
|
||||||
|
JOIN storage.folders f ON f.id = d.root_folder_id
|
||||||
JOIN storage.role_grants g
|
JOIN storage.role_grants g
|
||||||
ON g.resource_type = 'drive'
|
ON g.resource_type = 'drive'
|
||||||
AND g.resource_id = d.id
|
AND g.resource_id = d.id
|
||||||
WHERE g.subject_type = ANY($1)
|
WHERE g.subject_type = ANY($1)
|
||||||
AND g.subject_id = ANY($2)
|
AND g.subject_id = ANY($2)
|
||||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||||
GROUP BY d.id, d.name, d.kind, d.default_for_user,
|
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||||
d.quota_bytes, d.used_bytes, d.policies,
|
d.quota_bytes, d.used_bytes, d.policies,
|
||||||
d.created_at, d.updated_at
|
d.created_at, d.updated_at, f.name
|
||||||
ORDER BY (d.default_for_user IS NULL) ASC,
|
ORDER BY (d.default_for_user IS NULL) ASC,
|
||||||
LOWER(d.name) ASC
|
LOWER(f.name) ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
@@ -168,6 +268,6 @@ impl DriveRepository for DrivePgRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
|
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
|
||||||
|
|
||||||
rows.iter().map(Self::row_to_drive).collect()
|
rows.iter().map(Self::row_to_drive_with_name).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,11 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
async fn get_folder_by_path(
|
||||||
|
&self,
|
||||||
|
storage_path: &StoragePath,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Folder, DomainError> {
|
||||||
let path_str = storage_path.to_string();
|
let path_str = storage_path.to_string();
|
||||||
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
||||||
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
||||||
@@ -245,6 +249,13 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
return Err(DomainError::not_found("Folder", "empty path"));
|
return Err(DomainError::not_found("Folder", "empty path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scoped by user_id: post-D0 the wrapper folder is named
|
||||||
|
// "Personal" for every user, so `path = 'Personal'` matches
|
||||||
|
// every user's root folder. Without the user_id filter, this
|
||||||
|
// returns a non-deterministic row (whichever the planner emits
|
||||||
|
// first) — which broke owner-short-circuit checks for the
|
||||||
|
// caller whose folder wasn't returned. See bug-fix on rewind
|
||||||
|
// commit.
|
||||||
let row = sqlx::query_as::<_, FolderRow>(
|
let row = sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
@@ -252,10 +263,11 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
WHERE path = $1 AND NOT is_trashed
|
WHERE path = $1 AND user_id = $2 AND NOT is_trashed
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(lookup)
|
.bind(lookup)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_optional(self.pool())
|
.fetch_optional(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ async fn handle_propfind(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: legacy double-query path when PathResolver is unavailable
|
// Fallback: legacy double-query path when PathResolver is unavailable
|
||||||
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await {
|
||||||
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||||
let folder_id = folder.id.clone();
|
let folder_id = folder.id.clone();
|
||||||
return build_streaming_propfind_response(
|
return build_streaming_propfind_response(
|
||||||
@@ -656,7 +656,7 @@ async fn handle_proppatch(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Response<Body>, AppError> {
|
) -> 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,
|
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
|
||||||
// so a lock on the target must release them via `If:`. Captured
|
// so a lock on the target must release them via `If:`. Captured
|
||||||
@@ -691,7 +691,7 @@ async fn handle_proppatch(
|
|||||||
state
|
state
|
||||||
.applications
|
.applications
|
||||||
.folder_service
|
.folder_service
|
||||||
.get_folder_by_path(&path)
|
.get_folder_by_path(&path, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
};
|
};
|
||||||
@@ -877,7 +877,7 @@ async fn handle_head(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: legacy double-query path (with ownership check)
|
// Fallback: legacy double-query path (with ownership check)
|
||||||
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await {
|
||||||
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
@@ -941,7 +941,7 @@ async fn resolve_or_legacy(
|
|||||||
|
|
||||||
let user_id_str = user_id.to_string();
|
let user_id_str = user_id.to_string();
|
||||||
let folder_service = &state.applications.folder_service;
|
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, user_id).await
|
||||||
&& folder.owner_id.as_deref() == Some(&user_id_str)
|
&& folder.owner_id.as_deref() == Some(&user_id_str)
|
||||||
{
|
{
|
||||||
return Some(ResolvedResource::Folder(folder));
|
return Some(ResolvedResource::Folder(folder));
|
||||||
@@ -1208,7 +1208,7 @@ async fn handle_mkcol(
|
|||||||
}
|
}
|
||||||
accumulated_path.push_str(segment);
|
accumulated_path.push_str(segment);
|
||||||
|
|
||||||
match folder_service.get_folder_by_path(&accumulated_path).await {
|
match folder_service.get_folder_by_path(&accumulated_path, user.id).await {
|
||||||
Ok(existing) => {
|
Ok(existing) => {
|
||||||
parent_id = Some(existing.id);
|
parent_id = Some(existing.id);
|
||||||
}
|
}
|
||||||
@@ -1401,7 +1401,7 @@ async fn handle_move(
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
folder_service
|
folder_service
|
||||||
.get_folder_by_path(&destination_path)
|
.get_folder_by_path(&destination_path, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
|| file_retrieval_service
|
|| file_retrieval_service
|
||||||
@@ -1443,7 +1443,7 @@ async fn handle_move(
|
|||||||
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
|
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
|
||||||
parent_id: if dest_parent_path.is_empty() {
|
parent_id: if dest_parent_path.is_empty() {
|
||||||
None
|
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, user.id).await
|
||||||
{
|
{
|
||||||
assert_owner(
|
assert_owner(
|
||||||
parent.owner_id.as_deref(),
|
parent.owner_id.as_deref(),
|
||||||
@@ -1483,7 +1483,7 @@ async fn handle_move(
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let parent = folder_service
|
let parent = folder_service
|
||||||
.get_folder_by_path(dest_parent_path)
|
.get_folder_by_path(dest_parent_path, user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
AppError::not_found(format!(
|
AppError::not_found(format!(
|
||||||
@@ -1610,7 +1610,7 @@ async fn handle_copy(
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
folder_service
|
folder_service
|
||||||
.get_folder_by_path(&destination_path)
|
.get_folder_by_path(&destination_path, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
|| file_retrieval_service
|
|| file_retrieval_service
|
||||||
@@ -1644,7 +1644,7 @@ async fn handle_copy(
|
|||||||
|
|
||||||
let target_parent_id = if dest_parent_path.is_empty() {
|
let target_parent_id = if dest_parent_path.is_empty() {
|
||||||
None
|
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, user.id).await {
|
||||||
assert_owner(
|
assert_owner(
|
||||||
parent.owner_id.as_deref(),
|
parent.owner_id.as_deref(),
|
||||||
&user.id.to_string(),
|
&user.id.to_string(),
|
||||||
@@ -1743,7 +1743,7 @@ async fn handle_lock(
|
|||||||
state
|
state
|
||||||
.applications
|
.applications
|
||||||
.folder_service
|
.folder_service
|
||||||
.get_folder_by_path(&path)
|
.get_folder_by_path(&path, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -399,10 +399,14 @@ pub async fn handle_search(
|
|||||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
// Map file results
|
// 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 {
|
for file in &results.files {
|
||||||
let display_path = file
|
let display_path = file
|
||||||
.path
|
.path
|
||||||
.strip_prefix(&format!("My Folder - {}/", user.username))
|
.strip_prefix("Personal/")
|
||||||
.unwrap_or(&file.path);
|
.unwrap_or(&file.path);
|
||||||
let display_path = format!("/{}", display_path);
|
let display_path = format!("/{}", display_path);
|
||||||
|
|
||||||
@@ -427,11 +431,11 @@ pub async fn handle_search(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map folder results
|
// Map folder results — same TODO(D1) as above.
|
||||||
for folder in &results.folders {
|
for folder in &results.folders {
|
||||||
let display_path = folder
|
let display_path = folder
|
||||||
.path
|
.path
|
||||||
.strip_prefix(&format!("My Folder - {}/", user.username))
|
.strip_prefix("Personal/")
|
||||||
.unwrap_or(&folder.path);
|
.unwrap_or(&folder.path);
|
||||||
let display_path = format!("/{}", display_path);
|
let display_path = format!("/{}", display_path);
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ async fn handle_filter_files(
|
|||||||
// All items in this response are favorites.
|
// All items in this response are favorites.
|
||||||
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
|
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
|
// 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
|
// get_* per favorite — up to N serial round-trips on a sync client's
|
||||||
@@ -146,7 +150,7 @@ async fn handle_filter_files(
|
|||||||
write_multistatus_start(&mut xml)?;
|
write_multistatus_start(&mut xml)?;
|
||||||
|
|
||||||
for file in &files {
|
for file in &files {
|
||||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
let subpath = strip_home_prefix(&file.path, home_prefix);
|
||||||
let href = nc_href(&user.username, subpath);
|
let href = nc_href(&user.username, subpath);
|
||||||
let fid = file_id_map.get(&file.id).copied();
|
let fid = file_id_map.get(&file.id).copied();
|
||||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
@@ -163,7 +167,7 @@ async fn handle_filter_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for folder in &folders {
|
for folder in &folders {
|
||||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
let subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||||
let href = format!("{}/", nc_href(&user.username, subpath));
|
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||||
let fid = folder_id_map.get(&folder.id).copied();
|
let fid = folder_id_map.get(&folder.id).copied();
|
||||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
@@ -210,7 +214,7 @@ async fn handle_search(
|
|||||||
let nresults = parse_nresults(body).unwrap_or(100);
|
let nresults = parse_nresults(body).unwrap_or(100);
|
||||||
|
|
||||||
// Resolve folder scope from <d:href> inside <d:scope>.
|
// 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, &user.username, user.id).await;
|
||||||
|
|
||||||
let criteria = SearchCriteriaDto {
|
let criteria = SearchCriteriaDto {
|
||||||
name_contains: Some(term),
|
name_contains: Some(term),
|
||||||
@@ -227,7 +231,10 @@ async fn handle_search(
|
|||||||
|
|
||||||
let nc = state.nextcloud.as_ref();
|
let nc = state.nextcloud.as_ref();
|
||||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
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.
|
// No favorite checking for search results -- pass an empty set.
|
||||||
let favorite_ids: HashSet<String> = HashSet::new();
|
let favorite_ids: HashSet<String> = HashSet::new();
|
||||||
@@ -249,7 +256,7 @@ async fn handle_search(
|
|||||||
|
|
||||||
// Files.
|
// Files.
|
||||||
for file in &files {
|
for file in &files {
|
||||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
let subpath = strip_home_prefix(&file.path, home_prefix);
|
||||||
let href = nc_href(&user.username, subpath);
|
let href = nc_href(&user.username, subpath);
|
||||||
let fid = file_id_map.get(&file.id).copied();
|
let fid = file_id_map.get(&file.id).copied();
|
||||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
@@ -267,7 +274,7 @@ async fn handle_search(
|
|||||||
|
|
||||||
// Folders.
|
// Folders.
|
||||||
for folder in &folders {
|
for folder in &folders {
|
||||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
let subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||||
let href = format!("{}/", nc_href(&user.username, subpath));
|
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||||
let fid = folder_id_map.get(&folder.id).copied();
|
let fid = folder_id_map.get(&folder.id).copied();
|
||||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
@@ -461,7 +468,12 @@ fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
|
/// 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> {
|
async fn resolve_scope_folder(
|
||||||
|
state: &AppState,
|
||||||
|
body: &str,
|
||||||
|
username: &str,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Option<String> {
|
||||||
let href = parse_scope_href(body)?;
|
let href = parse_scope_href(body)?;
|
||||||
|
|
||||||
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
|
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
|
||||||
@@ -477,7 +489,7 @@ async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> O
|
|||||||
|
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
folder_service
|
folder_service
|
||||||
.get_folder_by_path(&internal_path)
|
.get_folder_by_path(&internal_path, user_id)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.map(|f| f.id)
|
.map(|f| f.id)
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ async fn handle_restore(
|
|||||||
let file_service = &state.applications.file_retrieval_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).await.is_ok()
|
||||||
|| folder_service
|
|| folder_service
|
||||||
.get_folder_by_path(&dest_internal)
|
.get_folder_by_path(&dest_internal, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok();
|
.is_ok();
|
||||||
if dest_taken {
|
if dest_taken {
|
||||||
@@ -248,11 +248,18 @@ fn mime_from_name(name: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the "My Folder - {username}/" prefix from an original path to produce
|
/// Strip the home-folder prefix from an original path to produce the
|
||||||
/// the Nextcloud-relative original location.
|
/// Nextcloud-relative original location.
|
||||||
fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
|
///
|
||||||
let prefix = format!("My Folder - {}/", username);
|
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
|
||||||
original_path.strip_prefix(&prefix).unwrap_or(original_path)
|
/// 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 ──────────────
|
// ────────────── Trashbin PROPFIND XML Generation ──────────────
|
||||||
|
|||||||
@@ -256,11 +256,12 @@ async fn handle_assemble(
|
|||||||
let file_service = &state.applications.file_retrieval_service;
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
|
|
||||||
let internal_path = format!(
|
// TODO(D1): read the caller's default-drive root folder name from
|
||||||
"My Folder - {}/{}",
|
// `drives.root_folder_id` instead of hardcoding "Personal". The
|
||||||
user.username,
|
// constant is correct for every default personal drive provisioned
|
||||||
dest_subpath.trim_matches('/')
|
// 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 filename = filename_from_path(&dest_subpath).to_string();
|
||||||
let ingested = ingest_stream_to_cas(
|
let ingested = ingest_stream_to_cas(
|
||||||
@@ -291,15 +292,11 @@ async fn handle_assemble(
|
|||||||
Some((p, n)) => (p, n),
|
Some((p, n)) => (p, n),
|
||||||
None => ("", dest_subpath.as_str()),
|
None => ("", dest_subpath.as_str()),
|
||||||
};
|
};
|
||||||
let parent_internal = format!(
|
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
|
||||||
"My Folder - {}/{}",
|
|
||||||
user.username,
|
|
||||||
parent_sub.trim_matches('/')
|
|
||||||
);
|
|
||||||
let parent_internal = parent_internal.trim_end_matches('/');
|
let parent_internal = parent_internal.trim_end_matches('/');
|
||||||
|
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
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, user.id).await {
|
||||||
Ok(folder) => folder,
|
Ok(folder) => folder,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
discard_ingested(&state.core.dedup_service, &ingested).await;
|
discard_ingested(&state.core.dedup_service, &ingested).await;
|
||||||
|
|||||||
@@ -53,8 +53,15 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
|||||||
/// Internal: My Folder - {username}/{subpath}
|
/// Internal: My Folder - {username}/{subpath}
|
||||||
///
|
///
|
||||||
/// An empty subpath maps to the user's home folder root.
|
/// An empty subpath maps to the user's home folder root.
|
||||||
pub fn nc_to_internal_path(username: &str, subpath: &str) -> Result<String, AppError> {
|
pub fn nc_to_internal_path(_username: &str, subpath: &str) -> Result<String, AppError> {
|
||||||
let home = format!("My Folder - {}", username);
|
// D0: every default personal drive's root folder is named "Personal"
|
||||||
|
// (docs/plan/drive.md §3 — the canonical post-D0 default). The NC
|
||||||
|
// dispatcher chroots into the caller's default drive, so the leading
|
||||||
|
// segment of the internal path is always the drive's root folder
|
||||||
|
// name. Hardcoded for now; a follow-up will read it from
|
||||||
|
// `drives.root_folder_id`'s name to support secondary drives with
|
||||||
|
// custom root-folder names.
|
||||||
|
let home = "Personal".to_string();
|
||||||
let subpath = subpath.trim_matches('/');
|
let subpath = subpath.trim_matches('/');
|
||||||
if subpath.is_empty() {
|
if subpath.is_empty() {
|
||||||
return Ok(home);
|
return Ok(home);
|
||||||
@@ -203,7 +210,7 @@ async fn handle_propfind(
|
|||||||
let file_service = &state.applications.file_retrieval_service;
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
|
|
||||||
// Try to resolve as folder first.
|
// 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, user.id).await;
|
||||||
|
|
||||||
if let Ok(folder) = folder_result {
|
if let Ok(folder) = folder_result {
|
||||||
// It's a folder — stream the multistatus: children are fetched in
|
// It's a folder — stream the multistatus: children are fetched in
|
||||||
@@ -281,7 +288,7 @@ async fn handle_get(
|
|||||||
|
|
||||||
// Check if path is a folder first (NC clients use GET as existence check)
|
// Check if path is a folder first (NC clients use GET as existence check)
|
||||||
if folder_service
|
if folder_service
|
||||||
.get_folder_by_path(&internal_path)
|
.get_folder_by_path(&internal_path, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
@@ -358,7 +365,7 @@ async fn handle_head(
|
|||||||
|
|
||||||
// Check if path is a folder (NC clients use HEAD as existence check)
|
// Check if path is a folder (NC clients use HEAD as existence check)
|
||||||
if folder_service
|
if folder_service
|
||||||
.get_folder_by_path(&internal_path)
|
.get_folder_by_path(&internal_path, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
@@ -433,7 +440,7 @@ async fn handle_proppatch(
|
|||||||
let folder_service = &state.applications.folder_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).await {
|
||||||
Some((file.id, "file"))
|
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, user.id).await {
|
||||||
Some((folder.id, "folder"))
|
Some((folder.id, "folder"))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -733,7 +740,7 @@ async fn handle_mkcol(
|
|||||||
// auto-create doesn't break real clients.
|
// auto-create doesn't break real clients.
|
||||||
|
|
||||||
if folder_service
|
if folder_service
|
||||||
.get_folder_by_path(&internal_path)
|
.get_folder_by_path(&internal_path, user.id)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
@@ -758,7 +765,7 @@ async fn handle_mkcol(
|
|||||||
format!("{}/{}", user_root, parent_segments.join("/"))
|
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, user.id).await {
|
||||||
Ok(folder) => folder,
|
Ok(folder) => folder,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
@@ -797,7 +804,7 @@ async fn handle_delete(
|
|||||||
// Prefer soft-delete (move to trash) when trash service is available.
|
// Prefer soft-delete (move to trash) when trash service is available.
|
||||||
// This is what Nextcloud clients expect — items appear in the trashbin.
|
// This is what Nextcloud clients expect — items appear in the trashbin.
|
||||||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
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, user.id).await {
|
||||||
trash_svc
|
trash_svc
|
||||||
.move_to_trash(&folder.id, "folder", user.id)
|
.move_to_trash(&folder.id, "folder", user.id)
|
||||||
.await
|
.await
|
||||||
@@ -823,7 +830,7 @@ async fn handle_delete(
|
|||||||
// Fallback: hard delete when trash service is not available.
|
// Fallback: hard delete when trash service is not available.
|
||||||
let file_mgmt = &state.applications.file_management_service;
|
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, user.id).await {
|
||||||
folder_service
|
folder_service
|
||||||
.delete_folder_with_perms(&folder.id, user.id)
|
.delete_folder_with_perms(&folder.id, user.id)
|
||||||
.await
|
.await
|
||||||
@@ -897,7 +904,7 @@ async fn handle_move(
|
|||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
let dest_existing_folder = folder_service
|
let dest_existing_folder = folder_service
|
||||||
.get_folder_by_path(&dest_internal_precheck)
|
.get_folder_by_path(&dest_internal_precheck, user.id)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
|
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
|
||||||
@@ -962,7 +969,7 @@ async fn handle_move(
|
|||||||
} else {
|
} else {
|
||||||
// Different parent → move.
|
// Different parent → move.
|
||||||
let dest_parent = folder_service
|
let dest_parent = folder_service
|
||||||
.get_folder_by_path(&dest_parent_internal)
|
.get_folder_by_path(&dest_parent_internal, user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::not_found("Destination folder not found"))?;
|
.map_err(|_| AppError::not_found("Destination folder not found"))?;
|
||||||
|
|
||||||
@@ -997,7 +1004,7 @@ async fn handle_move(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try as folder.
|
// 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, user.id).await {
|
||||||
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
|
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
|
||||||
Some((parent, name)) => (parent, name),
|
Some((parent, name)) => (parent, name),
|
||||||
None => ("", dest_subpath.as_str()),
|
None => ("", dest_subpath.as_str()),
|
||||||
@@ -1025,7 +1032,7 @@ async fn handle_move(
|
|||||||
} else {
|
} else {
|
||||||
// Different parent → move.
|
// Different parent → move.
|
||||||
let dest_parent = folder_service
|
let dest_parent = folder_service
|
||||||
.get_folder_by_path(&dest_parent_internal)
|
.get_folder_by_path(&dest_parent_internal, user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::not_found("Destination parent not found"))?;
|
.map_err(|_| AppError::not_found("Destination parent not found"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,14 @@ jsonpath "$" count >= 1
|
|||||||
jsonpath "$[0].kind" == "personal"
|
jsonpath "$[0].kind" == "personal"
|
||||||
jsonpath "$[0].default_for_user" == "{{admin_user_id}}"
|
jsonpath "$[0].default_for_user" == "{{admin_user_id}}"
|
||||||
jsonpath "$[0].name" == "Personal"
|
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]
|
[Captures]
|
||||||
admin_drive_id: jsonpath "$[0].id"
|
admin_drive_id: jsonpath "$[0].id"
|
||||||
|
admin_root_folder_id: jsonpath "$[0].root_folder_id"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -110,6 +116,8 @@ HTTP 200
|
|||||||
jsonpath "$" count == 1
|
jsonpath "$" count == 1
|
||||||
jsonpath "$[0].kind" == "personal"
|
jsonpath "$[0].kind" == "personal"
|
||||||
jsonpath "$[0].default_for_user" == "{{alice_user_id}}"
|
jsonpath "$[0].default_for_user" == "{{alice_user_id}}"
|
||||||
|
jsonpath "$[0].name" == "Personal"
|
||||||
|
jsonpath "$[0].root_folder_id" exists
|
||||||
[Captures]
|
[Captures]
|
||||||
alice_drive_id: jsonpath "$[0].id"
|
alice_drive_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
@@ -122,6 +130,8 @@ HTTP 200
|
|||||||
jsonpath "$" count == 1
|
jsonpath "$" count == 1
|
||||||
jsonpath "$[0].kind" == "personal"
|
jsonpath "$[0].kind" == "personal"
|
||||||
jsonpath "$[0].default_for_user" == "{{bob_user_id}}"
|
jsonpath "$[0].default_for_user" == "{{bob_user_id}}"
|
||||||
|
jsonpath "$[0].name" == "Personal"
|
||||||
|
jsonpath "$[0].root_folder_id" exists
|
||||||
[Captures]
|
[Captures]
|
||||||
bob_drive_id: jsonpath "$[0].id"
|
bob_drive_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
|||||||
@@ -281,12 +281,16 @@ jsonpath "$.items[*].resource.name" not contains "bob-attack-2"
|
|||||||
# WebDAV MKCOL — namespace isolation
|
# WebDAV MKCOL — namespace isolation
|
||||||
# ═════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════
|
||||||
# WebDAV requests are isolated per-user by `resolve_webdav_path`
|
# WebDAV requests are isolated per-user by `resolve_webdav_path`
|
||||||
# (webdav_handler.rs:189). If the requested path doesn't begin
|
# (webdav_handler.rs:235). If the requested path doesn't begin
|
||||||
# with the caller's home folder name ("My Folder - <username>"),
|
# with the caller's home folder name (the drive's root folder
|
||||||
# the handler silently prefixes the caller's home folder path
|
# name — "Personal" by default post-D0), the handler silently
|
||||||
# onto the front. Effect: any WebDAV path a client sends is
|
# prefixes the caller's home folder path onto the front. Effect:
|
||||||
# always resolved INSIDE the caller's own tree, regardless of
|
# any WebDAV path a client sends is always resolved INSIDE the
|
||||||
# what they wrote.
|
# 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
|
# These tests assert the isolation works (regression guard) and
|
||||||
# that the service-level verify_owner still acts as
|
# 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.
|
# 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}}
|
Authorization: Bearer {{bob_token}}
|
||||||
|
|
||||||
HTTP 201
|
HTTP 201
|
||||||
|
|||||||
@@ -43,39 +43,50 @@ psql -v ON_ERROR_STOP=1 -c "
|
|||||||
" >/dev/null
|
" >/dev/null
|
||||||
|
|
||||||
# The OxiCloud server normally provisions a default Personal drive +
|
# The OxiCloud server normally provisions a default Personal drive +
|
||||||
# Owner role_grant on user creation via PersonalDriveLifecycleHook
|
# its root folder + Owner role_grant on user creation via
|
||||||
# (D0). This script bypasses that pipeline — it INSERTs directly into
|
# PersonalDriveLifecycleHook (D0). This script bypasses that pipeline
|
||||||
# auth.users — so we mirror the hook's behaviour here. Without it,
|
# — it INSERTs directly into auth.users — so we mirror the hook's
|
||||||
# integration test fixtures that hand-roll INSERTs into storage.files
|
# behaviour here. Without it, integration test fixtures that hand-roll
|
||||||
# fail with "drive_id not-null violation" (M3 made the column
|
# INSERTs into storage.files fail with "drive_id not-null violation"
|
||||||
# mandatory), and helpers that JOIN auth.users with storage.drives
|
# (M3 made the column mandatory), and helpers that JOIN auth.users
|
||||||
# return RowNotFound.
|
# 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)"
|
echo "[init-schema] provisioning ci-admin's default Personal drive (idempotent)"
|
||||||
psql -v ON_ERROR_STOP=1 <<'SQL' >/dev/null
|
psql -v ON_ERROR_STOP=1 <<'SQL' >/dev/null
|
||||||
WITH admin AS (
|
DO $$
|
||||||
SELECT id FROM auth.users WHERE username = 'ci-admin'
|
DECLARE
|
||||||
),
|
admin_id uuid;
|
||||||
ins_drive AS (
|
drive_id uuid;
|
||||||
INSERT INTO storage.drives (name, kind, default_for_user, quota_bytes)
|
folder_id uuid;
|
||||||
SELECT 'Personal', 'personal', admin.id, NULL
|
BEGIN
|
||||||
FROM admin
|
SELECT id INTO admin_id FROM auth.users WHERE username = 'ci-admin';
|
||||||
WHERE NOT EXISTS (
|
IF EXISTS (SELECT 1 FROM storage.drives WHERE default_for_user = admin_id) THEN
|
||||||
SELECT 1 FROM storage.drives d WHERE d.default_for_user = admin.id
|
RETURN; -- already provisioned, idempotent no-op
|
||||||
)
|
END IF;
|
||||||
RETURNING id, default_for_user
|
|
||||||
)
|
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
|
INSERT INTO storage.role_grants
|
||||||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||||
SELECT 'user', ins_drive.default_for_user, 'drive', ins_drive.id, 'owner',
|
VALUES ('user', admin_id, 'drive', drive_id, 'owner', admin_id);
|
||||||
ins_drive.default_for_user
|
END
|
||||||
FROM ins_drive
|
$$;
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM storage.role_grants g
|
|
||||||
WHERE g.subject_type = 'user'
|
|
||||||
AND g.subject_id = ins_drive.default_for_user
|
|
||||||
AND g.resource_type = 'drive'
|
|
||||||
AND g.resource_id = ins_drive.id
|
|
||||||
);
|
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
echo "[init-schema] done"
|
echo "[init-schema] done"
|
||||||
|
|||||||
Reference in New Issue
Block a user