feat(drive): start implementation of drive
- add storage.drives
- prepare migration phase
- add created_by and updated_by on storage.folders
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- D0 / M1 — Drive foundation: additive schema only
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- First of three D0 migrations.
|
||||||
|
-- M1 (this file) additive — creates storage.drives + nullable columns.
|
||||||
|
-- M2 (next) backfill — promotes wrapper folders, fills drive_id.
|
||||||
|
-- M3 (final) constraints — NOT NULL + FKs + drive_id indexes.
|
||||||
|
--
|
||||||
|
-- This file is **safe to run on a populated database without an outage**.
|
||||||
|
-- It only ADDs structure (new table, new nullable columns, extended CHECK
|
||||||
|
-- constraints, new FK targets). No row is modified; no existing query
|
||||||
|
-- needs to be aware of the new columns yet.
|
||||||
|
--
|
||||||
|
-- The migration is reversible at this stage: dropping the new table and
|
||||||
|
-- the new columns leaves the database identical to its pre-D0 state. The
|
||||||
|
-- dual-write / data-movement phase (M2) is where rollback becomes
|
||||||
|
-- progressively harder.
|
||||||
|
|
||||||
|
-- ── 1. storage.drives — the central drive entity ────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.drives (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Discriminant. Two kinds today; extending the set is a DROP + ADD
|
||||||
|
-- CHECK constraint pair (no separate lookup table).
|
||||||
|
-- 'personal' = single-owner, no membership API
|
||||||
|
-- 'shared' = multi-member, full role roster, group-aware
|
||||||
|
kind TEXT NOT NULL
|
||||||
|
CHECK (kind IN ('personal', 'shared')),
|
||||||
|
|
||||||
|
-- Set iff this is the user's default personal drive. The partial
|
||||||
|
-- unique index below enforces "one default drive per user" without
|
||||||
|
-- blocking secondaries (NULL means "not the default"); shared drives
|
||||||
|
-- always have NULL here.
|
||||||
|
default_for_user UUID
|
||||||
|
REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Storage quota in bytes. NULL = no quota (admin override / system
|
||||||
|
-- drives). Initial value on personal-drive creation is taken from
|
||||||
|
-- the owner's `auth.users.storage_quota_bytes` at the application
|
||||||
|
-- layer.
|
||||||
|
quota_bytes BIGINT,
|
||||||
|
|
||||||
|
-- Running total of bytes consumed. Maintained by D4's incremental
|
||||||
|
-- counters; on D0 backfilled from the per-user counters as a
|
||||||
|
-- starting baseline.
|
||||||
|
used_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Capability flags / feature toggles bag (see docs/plan/drive.md §8
|
||||||
|
-- and §15 for the known keys: forbid_public_links,
|
||||||
|
-- forbid_external_sharing, include_in_photo_index, forbid_music_index,
|
||||||
|
-- etc.). Unknown keys preserved verbatim — the schema is
|
||||||
|
-- intentionally permissive so future flags land without migration.
|
||||||
|
policies JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE storage.drives IS
|
||||||
|
'Drive entity. Top-level container that owns a tree of folders/files; '
|
||||||
|
'membership lives in storage.role_grants with resource_type=''drive''. '
|
||||||
|
'Replaced the per-user My Folder wrapper at D0 (see docs/plan/drive.md).';
|
||||||
|
COMMENT ON COLUMN storage.drives.kind IS
|
||||||
|
'personal = single-owner (no add_member); shared = multi-member with full role roster.';
|
||||||
|
COMMENT ON COLUMN storage.drives.default_for_user IS
|
||||||
|
'Set iff this is the user''s default personal drive. NULL on secondaries and shared drives.';
|
||||||
|
COMMENT ON COLUMN storage.drives.policies IS
|
||||||
|
'JSONB capability-flag bag; see docs/plan/drive.md §8 §15 for known keys.';
|
||||||
|
|
||||||
|
-- "One default drive per user." Partial unique index — NULLs (every
|
||||||
|
-- non-default row) are excluded from the constraint surface.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_drives_default_for_user_unique
|
||||||
|
ON storage.drives (default_for_user)
|
||||||
|
WHERE default_for_user IS NOT NULL;
|
||||||
|
|
||||||
|
-- Hot-path "what's this drive?" lookup by kind for admin / D3 flows
|
||||||
|
-- ("list every shared drive").
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_drives_kind ON storage.drives (kind);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 2. drive_id columns on folders + files (NULL during M1) ────────────────
|
||||||
|
-- M2 fills these in for every existing row; M3 promotes them to NOT NULL
|
||||||
|
-- and adds the FK + index. Keep nullable here so the migration runs on a
|
||||||
|
-- populated DB without violating a constraint.
|
||||||
|
|
||||||
|
ALTER TABLE storage.folders ADD COLUMN IF NOT EXISTS drive_id UUID;
|
||||||
|
ALTER TABLE storage.files ADD COLUMN IF NOT EXISTS drive_id UUID;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 3. Provenance columns: created_by / updated_by ─────────────────────────
|
||||||
|
-- D0 adds these on folders + files (see docs/plan/drive.md §14). FKs use
|
||||||
|
-- ON DELETE SET NULL so deleting a user nulls these out instead of
|
||||||
|
-- cascading the resource away. M2 backfills from the existing `user_id`
|
||||||
|
-- column so pre-Drive content carries authentic provenance from day one.
|
||||||
|
|
||||||
|
ALTER TABLE storage.folders
|
||||||
|
ADD COLUMN IF NOT EXISTS created_by UUID
|
||||||
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE storage.folders
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_by UUID
|
||||||
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE storage.files
|
||||||
|
ADD COLUMN IF NOT EXISTS created_by UUID
|
||||||
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE storage.files
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_by UUID
|
||||||
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN storage.folders.created_by IS
|
||||||
|
'Who originally created the folder. NULL when the original creator''s '
|
||||||
|
'auth.users row has since been deleted.';
|
||||||
|
COMMENT ON COLUMN storage.folders.updated_by IS
|
||||||
|
'Who last touched the folder (rename, move, metadata change). Same '
|
||||||
|
'write-path discipline as updated_at.';
|
||||||
|
COMMENT ON COLUMN storage.files.created_by IS
|
||||||
|
'Who originally uploaded the file. NULL when the original uploader''s '
|
||||||
|
'auth.users row has since been deleted.';
|
||||||
|
COMMENT ON COLUMN storage.files.updated_by IS
|
||||||
|
'Who last touched the file (rename, move, overwrite, restore). Same '
|
||||||
|
'write-path discipline as updated_at.';
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 4. role_grants resource_type CHECK — admit 'drive' ─────────────────────
|
||||||
|
-- The D-Prep migration's CHECK only listed 'folder' and 'file'. Drives
|
||||||
|
-- need to be a valid resource_type so the lifecycle hook (D0-9) and the
|
||||||
|
-- membership API (D2) can write `role_grants` rows with
|
||||||
|
-- resource_type='drive'.
|
||||||
|
|
||||||
|
ALTER TABLE storage.role_grants
|
||||||
|
DROP CONSTRAINT IF EXISTS role_grants_resource_type_check;
|
||||||
|
ALTER TABLE storage.role_grants
|
||||||
|
ADD CONSTRAINT role_grants_resource_type_check
|
||||||
|
CHECK (resource_type IN ('folder', 'file', 'drive'));
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 5. updated_at trigger for storage.drives ───────────────────────────────
|
||||||
|
-- Mirror the convention from auth.users / storage.folders / storage.files
|
||||||
|
-- so rename / quota-change / policy-toggle bumps updated_at automatically.
|
||||||
|
-- Drive owners shouldn't have to remember to maintain this.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.drives_touch_updated_at()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at := now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_drives_touch_updated_at ON storage.drives;
|
||||||
|
CREATE TRIGGER trg_drives_touch_updated_at
|
||||||
|
BEFORE UPDATE ON storage.drives
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.drives_touch_updated_at();
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- D0 / M2 — Drive backfill: create drives + stamp drive_id + provenance
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Second of the D0 migration trio. Half (1) of the §A backfill — the safe,
|
||||||
|
-- focused half:
|
||||||
|
--
|
||||||
|
-- * For every internal user with a root folder, create a Personal drive.
|
||||||
|
-- * The folder literally named `My Folder - <username>` becomes the
|
||||||
|
-- user's default Personal drive (`default_for_user = <uid>`).
|
||||||
|
-- * Any sibling root folders become secondary Personal drives
|
||||||
|
-- (`default_for_user = NULL`, name carried over verbatim).
|
||||||
|
-- * One owner role_grants row per new drive.
|
||||||
|
-- * Every existing folder/file row gets a `drive_id` (cascaded down the
|
||||||
|
-- ltree from the wrapper).
|
||||||
|
-- * Every existing folder/file row gets `created_by` and `updated_by`
|
||||||
|
-- backfilled from the existing `user_id` column.
|
||||||
|
--
|
||||||
|
-- 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
|
||||||
|
-- skipped — they have no root folder of their own, only role_grants
|
||||||
|
-- against other users' resources.
|
||||||
|
|
||||||
|
-- ── Pre-flight 1: refuse on sibling root literally named 'drives' ──────────
|
||||||
|
-- 'drives' is a reserved URL segment on the native WebDAV surface
|
||||||
|
-- (`/webdav/drives/<uuid>/`). A folder named 'drives' would shadow the
|
||||||
|
-- drive-listing route once D1 ships. Surface the conflict now — operator
|
||||||
|
-- renames before retrying.
|
||||||
|
|
||||||
|
DO $BODY$
|
||||||
|
DECLARE
|
||||||
|
bad_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO bad_count
|
||||||
|
FROM storage.folders f
|
||||||
|
JOIN auth.users u ON u.id = f.user_id
|
||||||
|
WHERE f.parent_id IS NULL
|
||||||
|
AND NOT f.is_trashed
|
||||||
|
AND lower(f.name) = 'drives'
|
||||||
|
AND NOT u.is_external;
|
||||||
|
|
||||||
|
IF bad_count > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'D0 backfill refused: % root folder(s) literally named ''drives'' '
|
||||||
|
'would collide with the reserved /webdav/drives/<uuid>/ URL segment '
|
||||||
|
'once D1 ships. Rename the offending folders, then retry the '
|
||||||
|
'migration. Query to inspect: SELECT f.id, f.user_id, f.name '
|
||||||
|
'FROM storage.folders f JOIN auth.users u ON u.id = f.user_id '
|
||||||
|
'WHERE f.parent_id IS NULL AND NOT f.is_trashed AND lower(f.name) '
|
||||||
|
'= ''drives'' AND NOT u.is_external;',
|
||||||
|
bad_count;
|
||||||
|
END IF;
|
||||||
|
END $BODY$;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Pre-flight 2: report sibling-root distribution (informational) ─────────
|
||||||
|
-- Most users have exactly one root (`My Folder - <username>`). Some may
|
||||||
|
-- have SQL-added siblings — those become secondary drives. Surface the
|
||||||
|
-- count so operators can sanity-check before the migration commits.
|
||||||
|
|
||||||
|
DO $BODY$
|
||||||
|
DECLARE
|
||||||
|
extras BIGINT;
|
||||||
|
BEGIN
|
||||||
|
WITH counts AS (
|
||||||
|
SELECT u.id AS user_id, count(*) AS root_count
|
||||||
|
FROM auth.users u
|
||||||
|
JOIN storage.folders f ON f.user_id = u.id
|
||||||
|
WHERE f.parent_id IS NULL
|
||||||
|
AND NOT f.is_trashed
|
||||||
|
AND NOT u.is_external
|
||||||
|
GROUP BY u.id
|
||||||
|
)
|
||||||
|
SELECT count(*) INTO extras FROM counts WHERE root_count > 1;
|
||||||
|
|
||||||
|
IF extras > 0 THEN
|
||||||
|
RAISE NOTICE
|
||||||
|
'D0 backfill: % user(s) have more than one root folder. Their '
|
||||||
|
'siblings will be promoted to secondary Personal drives. Inspect '
|
||||||
|
'with: WITH c AS (SELECT u.id, u.username, count(*) cnt FROM '
|
||||||
|
'auth.users u JOIN storage.folders f ON f.user_id=u.id WHERE '
|
||||||
|
'f.parent_id IS NULL AND NOT f.is_trashed AND NOT u.is_external '
|
||||||
|
'GROUP BY u.id, u.username) SELECT * FROM c WHERE cnt > 1;',
|
||||||
|
extras;
|
||||||
|
END IF;
|
||||||
|
END $BODY$;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 1. Plan every drive that needs to be created ──────────────────────────
|
||||||
|
-- Temp table is the cleanest way to pre-compute the new UUIDs once and
|
||||||
|
-- reuse them across the INSERT-drives, INSERT-grants, and UPDATE-folders
|
||||||
|
-- steps below. `gen_random_uuid()` in a CTE would re-evaluate on every
|
||||||
|
-- branch.
|
||||||
|
--
|
||||||
|
-- A row joins each existing root folder to its future drive_id. The
|
||||||
|
-- `is_default` flag is computed per-user as a window function so EVERY
|
||||||
|
-- internal user with at least one root folder ends up with exactly one
|
||||||
|
-- default drive — even if the user's wrapper was renamed away from
|
||||||
|
-- `My Folder - <username>` at some point. Preference order:
|
||||||
|
-- 1. The folder literally named `My Folder - <username>` if it exists.
|
||||||
|
-- 2. Otherwise the oldest root by `created_at`, tiebroken by `id`.
|
||||||
|
|
||||||
|
-- `ON COMMIT DROP` would race with the `[init-schema]` CI flow that
|
||||||
|
-- runs migrations via `psql \i` in autocommit mode: the CREATE statement
|
||||||
|
-- commits, the table drops, and the next statement (the DO block) can't
|
||||||
|
-- see it. The plain temp table survives until session end in autocommit
|
||||||
|
-- mode and until our explicit DROP at the bottom under `sqlx migrate`'s
|
||||||
|
-- single-tx mode. Works under both.
|
||||||
|
CREATE TEMPORARY TABLE _drive_plan AS
|
||||||
|
WITH root_folders AS (
|
||||||
|
SELECT
|
||||||
|
u.id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
u.storage_quota_bytes AS quota,
|
||||||
|
f.id AS wrapper_id,
|
||||||
|
f.name AS wrapper_name,
|
||||||
|
f.created_at AS created_at,
|
||||||
|
(f.name = 'My Folder - ' || u.username) AS name_matches_default
|
||||||
|
FROM auth.users u
|
||||||
|
JOIN storage.folders f
|
||||||
|
ON f.user_id = u.id
|
||||||
|
AND f.parent_id IS NULL
|
||||||
|
AND NOT f.is_trashed
|
||||||
|
WHERE NOT u.is_external
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
quota,
|
||||||
|
wrapper_id,
|
||||||
|
wrapper_name,
|
||||||
|
-- Rank candidates per user: name-matched root wins; otherwise oldest
|
||||||
|
-- by created_at then by id (stable, deterministic). ROW_NUMBER() = 1
|
||||||
|
-- becomes the default drive for that user.
|
||||||
|
(ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY user_id
|
||||||
|
ORDER BY name_matches_default DESC,
|
||||||
|
created_at ASC,
|
||||||
|
wrapper_id ASC
|
||||||
|
) = 1) AS is_default,
|
||||||
|
gen_random_uuid() AS new_drive_id
|
||||||
|
FROM root_folders;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 1b. Log which users got an auto-picked default (no name match) ────────
|
||||||
|
-- Operational nicety: if a user's default came from oldest-root fallback
|
||||||
|
-- rather than the canonical `My Folder - <username>`, surface it so an
|
||||||
|
-- operator can DM the user and confirm the migration picked the right
|
||||||
|
-- root. Not a failure — just visibility.
|
||||||
|
|
||||||
|
DO $BODY$
|
||||||
|
DECLARE
|
||||||
|
auto_picked BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO auto_picked
|
||||||
|
FROM _drive_plan p
|
||||||
|
WHERE p.is_default
|
||||||
|
AND p.wrapper_name <> 'My Folder - ' || p.username;
|
||||||
|
|
||||||
|
IF auto_picked > 0 THEN
|
||||||
|
RAISE NOTICE
|
||||||
|
'D0 backfill: % user(s) had no `My Folder - <username>` root; '
|
||||||
|
'the oldest sibling root was auto-picked as their default '
|
||||||
|
'Personal drive. Inspect with: SELECT user_id, username, '
|
||||||
|
'wrapper_name FROM _drive_plan WHERE is_default AND '
|
||||||
|
'wrapper_name <> ''My Folder - '' || username; '
|
||||||
|
'(temp table only exists during the migration transaction.)',
|
||||||
|
auto_picked;
|
||||||
|
END IF;
|
||||||
|
END $BODY$;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 2. Insert the drive rows ───────────────────────────────────────────────
|
||||||
|
-- Default drives carry the i18n-neutral name 'Personal' (renameable
|
||||||
|
-- later via the drive settings panel). Secondary drives carry their
|
||||||
|
-- original folder name verbatim.
|
||||||
|
|
||||||
|
INSERT INTO storage.drives
|
||||||
|
(id, name, kind, default_for_user, quota_bytes)
|
||||||
|
SELECT
|
||||||
|
p.new_drive_id,
|
||||||
|
CASE WHEN p.is_default THEN 'Personal' ELSE p.wrapper_name END,
|
||||||
|
'personal',
|
||||||
|
CASE WHEN p.is_default THEN p.user_id ELSE NULL END,
|
||||||
|
p.quota
|
||||||
|
FROM _drive_plan p;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 3. Insert one owner role_grants row per drive ─────────────────────────
|
||||||
|
-- Each user is the sole owner of every drive their wrappers produced.
|
||||||
|
-- The lifecycle hook (D0-9) will do the same for users created post-D0.
|
||||||
|
|
||||||
|
INSERT INTO storage.role_grants
|
||||||
|
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||||
|
SELECT 'user', p.user_id, 'drive', p.new_drive_id, 'owner', p.user_id
|
||||||
|
FROM _drive_plan p;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 4. Stamp drive_id on each wrapper folder ──────────────────────────────
|
||||||
|
-- The wrapper still exists as a folder during M2 (the wrapper-drop lives
|
||||||
|
-- in M2b). Setting drive_id on the wrapper lets the cascade in §5 walk
|
||||||
|
-- the ltree subtree without needing a separate index.
|
||||||
|
|
||||||
|
UPDATE storage.folders f
|
||||||
|
SET drive_id = p.new_drive_id
|
||||||
|
FROM _drive_plan p
|
||||||
|
WHERE f.id = p.wrapper_id;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 5. Cascade drive_id down the folder tree ──────────────────────────────
|
||||||
|
-- For every folder descended from a wrapper, set drive_id to that
|
||||||
|
-- wrapper's. Uses the existing GiST index `idx_folders_lpath` for the
|
||||||
|
-- @> (ancestor-of) lookup. Trashed descendants get a drive_id too —
|
||||||
|
-- soft-deleted folders need a drive_id once M3 makes the column NOT NULL.
|
||||||
|
|
||||||
|
UPDATE storage.folders sub
|
||||||
|
SET drive_id = wrapper.drive_id
|
||||||
|
FROM storage.folders wrapper
|
||||||
|
WHERE wrapper.id IN (SELECT wrapper_id FROM _drive_plan)
|
||||||
|
AND sub.lpath <@ wrapper.lpath
|
||||||
|
AND sub.id != wrapper.id
|
||||||
|
AND sub.drive_id IS NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 6. Cascade drive_id to files (via their folder) ───────────────────────
|
||||||
|
-- Files inherit drive_id from their containing folder. A NULL folder_id
|
||||||
|
-- file is an orphan — left with NULL drive_id here; M3's NOT NULL
|
||||||
|
-- constraint will refuse the migration if any such orphans remain,
|
||||||
|
-- which is the right outcome (forces operator inspection).
|
||||||
|
|
||||||
|
UPDATE storage.files fi
|
||||||
|
SET drive_id = fo.drive_id
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE fi.folder_id = fo.id
|
||||||
|
AND fi.drive_id IS NULL
|
||||||
|
AND fo.drive_id IS NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 7. Provenance backfill ────────────────────────────────────────────────
|
||||||
|
-- Every pre-Drive row carries authentic provenance from day one: created_by
|
||||||
|
-- and updated_by both default to the user_id that we know created the
|
||||||
|
-- resource (that's exactly what user_id meant pre-D0). New writes during
|
||||||
|
-- the dual-write window populate both columns explicitly.
|
||||||
|
|
||||||
|
UPDATE storage.folders
|
||||||
|
SET created_by = user_id,
|
||||||
|
updated_by = user_id
|
||||||
|
WHERE created_by IS NULL;
|
||||||
|
|
||||||
|
UPDATE storage.files
|
||||||
|
SET created_by = user_id,
|
||||||
|
updated_by = user_id
|
||||||
|
WHERE created_by IS NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 7b. Drop the planning temp table ──────────────────────────────────────
|
||||||
|
-- Explicit drop since we removed `ON COMMIT DROP` above. Idempotent
|
||||||
|
-- (`IF EXISTS`) so a partial re-run during development doesn't error.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS _drive_plan;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 8. Post-flight consistency check ──────────────────────────────────────
|
||||||
|
-- The checks here REFUSE to commit if any invariant is violated, so a
|
||||||
|
-- successful migration is a verifiable migration.
|
||||||
|
--
|
||||||
|
-- 8a. Every internal user with a root folder has exactly one
|
||||||
|
-- default drive.
|
||||||
|
-- 8b. Every drive has at least one owner role_grants row.
|
||||||
|
-- 8c. No NULL drive_id remains on a folder/file row whose owner is
|
||||||
|
-- a non-external user with a root folder (i.e. every row that
|
||||||
|
-- belongs to a drive must now declare which one).
|
||||||
|
--
|
||||||
|
-- M3 turns drive_id NOT NULL; the check below is a stricter pre-flight
|
||||||
|
-- so the failure mode is "migration refuses" rather than "M3 errors
|
||||||
|
-- with a NOT NULL violation halfway through".
|
||||||
|
|
||||||
|
DO $BODY$
|
||||||
|
DECLARE
|
||||||
|
missing_default BIGINT;
|
||||||
|
grantless_drives BIGINT;
|
||||||
|
null_folder_drive_id BIGINT;
|
||||||
|
null_file_drive_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO missing_default
|
||||||
|
FROM auth.users u
|
||||||
|
WHERE NOT u.is_external
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM storage.folders f
|
||||||
|
WHERE f.user_id = u.id AND f.parent_id IS NULL AND NOT f.is_trashed
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM storage.drives d
|
||||||
|
WHERE d.default_for_user = u.id
|
||||||
|
);
|
||||||
|
IF missing_default > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'D0 backfill consistency check failed: % internal user(s) with a '
|
||||||
|
'root folder have no default Personal drive. Investigate before '
|
||||||
|
'declaring the migration successful.',
|
||||||
|
missing_default;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(*) INTO grantless_drives
|
||||||
|
FROM storage.drives d
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM storage.role_grants g
|
||||||
|
WHERE g.resource_type = 'drive'
|
||||||
|
AND g.resource_id = d.id
|
||||||
|
AND g.role = 'owner'
|
||||||
|
);
|
||||||
|
IF grantless_drives > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'D0 backfill consistency check failed: % drive(s) have no owner '
|
||||||
|
'role_grants row. Investigate before declaring the migration '
|
||||||
|
'successful.',
|
||||||
|
grantless_drives;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(*) INTO null_folder_drive_id
|
||||||
|
FROM storage.folders f
|
||||||
|
WHERE f.drive_id IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM auth.users u
|
||||||
|
WHERE u.id = f.user_id AND NOT u.is_external
|
||||||
|
);
|
||||||
|
IF null_folder_drive_id > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'D0 backfill consistency check failed: % folder(s) belonging to '
|
||||||
|
'an internal user still have NULL drive_id. M3 will refuse to '
|
||||||
|
'add NOT NULL until these are resolved.',
|
||||||
|
null_folder_drive_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(*) INTO null_file_drive_id
|
||||||
|
FROM storage.files fi
|
||||||
|
WHERE fi.drive_id IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM auth.users u
|
||||||
|
WHERE u.id = fi.user_id AND NOT u.is_external
|
||||||
|
);
|
||||||
|
IF null_file_drive_id > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'D0 backfill consistency check failed: % file(s) belonging to an '
|
||||||
|
'internal user still have NULL drive_id (likely orphans — '
|
||||||
|
'folder_id pointing at a missing folder). Inspect with: SELECT '
|
||||||
|
'fi.id, fi.user_id, fi.folder_id FROM storage.files fi JOIN '
|
||||||
|
'auth.users u ON u.id = fi.user_id WHERE fi.drive_id IS NULL '
|
||||||
|
'AND NOT u.is_external;',
|
||||||
|
null_file_drive_id;
|
||||||
|
END IF;
|
||||||
|
END $BODY$;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- D0 / M3 — Drive constraints: NOT NULL, FK, indexes
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Third of the D0 migration trio. Runs only after M2 (the backfill) has
|
||||||
|
-- populated every folder/file row with a `drive_id`. This migration is
|
||||||
|
-- the point of no easy rollback — once `drive_id` is NOT NULL and
|
||||||
|
-- foreign-keyed to `storage.drives`, dropping the column requires the
|
||||||
|
-- application code to first stop reading it.
|
||||||
|
--
|
||||||
|
-- What lands here:
|
||||||
|
-- * NOT NULL on `storage.folders.drive_id` and `storage.files.drive_id`.
|
||||||
|
-- * Foreign keys from both to `storage.drives(id)` with ON DELETE
|
||||||
|
-- CASCADE (deleting a drive removes its tree — matches the
|
||||||
|
-- post-D2 lifecycle plan).
|
||||||
|
-- * Indexes on `drive_id` for both tables (hot path: list-by-drive,
|
||||||
|
-- drive-aware Tantivy reseed, drive-quota counters).
|
||||||
|
--
|
||||||
|
-- The `user_id` column is intentionally left in place: dual-write during
|
||||||
|
-- the D0 release cycle is the rollback safety net. D7 drops user_id once
|
||||||
|
-- the new model has baked.
|
||||||
|
|
||||||
|
-- ── 1. NOT NULL on drive_id ────────────────────────────────────────────────
|
||||||
|
-- M2's post-flight check refused to commit if any row was missing
|
||||||
|
-- drive_id, so this should never fail. The check at column promotion
|
||||||
|
-- time is the belt; M2's pre-commit assertion was the suspenders.
|
||||||
|
|
||||||
|
ALTER TABLE storage.folders
|
||||||
|
ALTER COLUMN drive_id SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE storage.files
|
||||||
|
ALTER COLUMN drive_id SET NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 2. Foreign keys to storage.drives ──────────────────────────────────────
|
||||||
|
-- ON DELETE CASCADE: when a drive is deleted (D3 ships the delete-drive
|
||||||
|
-- flow), every folder and file row carrying that drive_id is removed in
|
||||||
|
-- the same transaction. Trash retention does not apply — drive deletion
|
||||||
|
-- is the explicit "I'm done with this storage" gesture.
|
||||||
|
|
||||||
|
ALTER TABLE storage.folders
|
||||||
|
ADD CONSTRAINT folders_drive_id_fkey
|
||||||
|
FOREIGN KEY (drive_id) REFERENCES storage.drives(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE storage.files
|
||||||
|
ADD CONSTRAINT files_drive_id_fkey
|
||||||
|
FOREIGN KEY (drive_id) REFERENCES storage.drives(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 3. Indexes on drive_id ─────────────────────────────────────────────────
|
||||||
|
-- The hot path that ranks every drive-aware query: "list folders in
|
||||||
|
-- drive X", "files in drive X for Tantivy reindex", "per-drive quota
|
||||||
|
-- aggregation". The existing `user_id` indexes are kept during dual-
|
||||||
|
-- write and dropped in D7 alongside the column.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_drive_id ON storage.folders (drive_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_drive_id ON storage.files (drive_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 4. Post-flight: confirm constraints landed ────────────────────────────
|
||||||
|
-- Belt-and-suspenders verification that the NOT NULL + FK actually
|
||||||
|
-- exist after the ALTERs above. Any failure here means PostgreSQL
|
||||||
|
-- silently no-op'd one of the constraint changes, which would be a
|
||||||
|
-- bug worth surfacing immediately.
|
||||||
|
|
||||||
|
DO $BODY$
|
||||||
|
DECLARE
|
||||||
|
folder_not_null BOOLEAN;
|
||||||
|
file_not_null BOOLEAN;
|
||||||
|
folder_fk_exists BOOLEAN;
|
||||||
|
file_fk_exists BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
SELECT NOT is_nullable::boolean INTO folder_not_null
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'storage'
|
||||||
|
AND table_name = 'folders'
|
||||||
|
AND column_name = 'drive_id';
|
||||||
|
|
||||||
|
SELECT NOT is_nullable::boolean INTO file_not_null
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'storage'
|
||||||
|
AND table_name = 'files'
|
||||||
|
AND column_name = 'drive_id';
|
||||||
|
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = 'storage'
|
||||||
|
AND table_name = 'folders'
|
||||||
|
AND constraint_name = 'folders_drive_id_fkey'
|
||||||
|
AND constraint_type = 'FOREIGN KEY'
|
||||||
|
) INTO folder_fk_exists;
|
||||||
|
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = 'storage'
|
||||||
|
AND table_name = 'files'
|
||||||
|
AND constraint_name = 'files_drive_id_fkey'
|
||||||
|
AND constraint_type = 'FOREIGN KEY'
|
||||||
|
) INTO file_fk_exists;
|
||||||
|
|
||||||
|
IF NOT folder_not_null OR NOT file_not_null
|
||||||
|
OR NOT folder_fk_exists OR NOT file_fk_exists THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'D0 M3 post-flight failed: '
|
||||||
|
'folder NOT NULL=%, file NOT NULL=%, folder FK=%, file FK=%. '
|
||||||
|
'All four must be true after this migration commits.',
|
||||||
|
folder_not_null, file_not_null, folder_fk_exists, file_fk_exists;
|
||||||
|
END IF;
|
||||||
|
END $BODY$;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- D0 / M4 — tree_etag_dirty drive_id awareness
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Fourth D0 migration. The async tree-ETag queue (introduced in
|
||||||
|
-- `20260627000000_async_tree_etag_queue.sql`) walks `f.lpath @> t.lpath`
|
||||||
|
-- to bump every ancestor of a changed folder/file. Without a drive_id
|
||||||
|
-- filter, that walk can match folders in OTHER drives whose ltree
|
||||||
|
-- prefixes happen to align numerically — a silent cross-drive ETag
|
||||||
|
-- bump that nobody would notice until D2 ships shared drives.
|
||||||
|
--
|
||||||
|
-- This migration is preventive: it adds the column, teaches the
|
||||||
|
-- triggers to carry drive_id into the queue, and the Rust flush
|
||||||
|
-- service (`tree_etag_flush_service.rs`) gets the matching
|
||||||
|
-- `AND f.drive_id = t.drive_id` predicate so the cross-drive case is
|
||||||
|
-- closed end-to-end before drives can collide.
|
||||||
|
|
||||||
|
-- ── 1. drive_id column on the queue table ──────────────────────────────────
|
||||||
|
-- NULL-tolerant during the rollover: existing queue entries enqueued by
|
||||||
|
-- the old triggers have no drive_id. They drain on the next flush tick
|
||||||
|
-- with the old (no drive_id) semantics — which for D0 is still correct
|
||||||
|
-- because every personal drive's lpath is structurally disjoint from
|
||||||
|
-- every other user's. New entries enqueued by the updated triggers
|
||||||
|
-- below carry a non-NULL value.
|
||||||
|
|
||||||
|
ALTER TABLE storage.tree_etag_dirty ADD COLUMN IF NOT EXISTS drive_id UUID;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 2. File-side INSERT/DELETE trigger ─────────────────────────────────────
|
||||||
|
-- Source rows live in `storage.files` (changed_rows). Each file row
|
||||||
|
-- carries `drive_id` directly (D0-8 dual-write). Pull from the joined
|
||||||
|
-- folder row so the (lpath, folder_id, drive_id) triple is internally
|
||||||
|
-- consistent — a single source of truth per enqueued row.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.bump_tree_from_files_stmt()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF pg_trigger_depth() > 1 THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
|
||||||
|
SELECT DISTINCT fo.lpath, fo.id, fo.drive_id
|
||||||
|
FROM (SELECT DISTINCT folder_id
|
||||||
|
FROM changed_rows
|
||||||
|
WHERE folder_id IS NOT NULL) c
|
||||||
|
JOIN storage.folders fo ON fo.id = c.folder_id;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 3. File-side UPDATE trigger ────────────────────────────────────────────
|
||||||
|
-- Move case: the file changed parent. Bump both the old and the new
|
||||||
|
-- parent chains (each in its own drive — D0 has them equal, D2 can see
|
||||||
|
-- them diverge once cross-drive moves land).
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.bump_tree_from_files_stmt_upd()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF pg_trigger_depth() > 1 THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
WITH changed AS (
|
||||||
|
SELECT o.folder_id AS old_folder_id, n.folder_id AS new_folder_id
|
||||||
|
FROM old_rows o
|
||||||
|
JOIN new_rows n USING (id)
|
||||||
|
WHERE (o.name, o.folder_id, o.blob_hash, o.size,
|
||||||
|
o.mime_type, o.is_trashed, o.updated_at)
|
||||||
|
IS DISTINCT FROM
|
||||||
|
(n.name, n.folder_id, n.blob_hash, n.size,
|
||||||
|
n.mime_type, n.is_trashed, n.updated_at)
|
||||||
|
)
|
||||||
|
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
|
||||||
|
SELECT DISTINCT fo.lpath, fo.id, fo.drive_id
|
||||||
|
FROM (SELECT old_folder_id AS folder_id
|
||||||
|
FROM changed WHERE old_folder_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT new_folder_id
|
||||||
|
FROM changed WHERE new_folder_id IS NOT NULL) c
|
||||||
|
JOIN storage.folders fo ON fo.id = c.folder_id;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 4. Folder-side INSERT/DELETE trigger ──────────────────────────────────
|
||||||
|
-- changed_rows are storage.folders rows, which carry drive_id directly
|
||||||
|
-- post-D0-7.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.bump_tree_from_folders_stmt()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF pg_trigger_depth() > 1 THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
|
||||||
|
SELECT DISTINCT subpath(lpath, 0, nlevel(lpath) - 1), parent_id, drive_id
|
||||||
|
FROM changed_rows
|
||||||
|
WHERE lpath IS NOT NULL
|
||||||
|
AND nlevel(lpath) > 1;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── 5. Folder-side UPDATE trigger ──────────────────────────────────────────
|
||||||
|
-- Same shape as the INSERT/DELETE case; we union OLD and NEW parents,
|
||||||
|
-- carrying each chain's drive_id from the matching row side.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.bump_tree_from_folders_stmt_upd()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF pg_trigger_depth() > 1 THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
WITH changed AS (
|
||||||
|
SELECT o.lpath AS old_lpath,
|
||||||
|
o.parent_id AS old_parent_id,
|
||||||
|
o.drive_id AS old_drive_id,
|
||||||
|
n.lpath AS new_lpath,
|
||||||
|
n.parent_id AS new_parent_id,
|
||||||
|
n.drive_id AS new_drive_id
|
||||||
|
FROM old_rows o
|
||||||
|
JOIN new_rows n USING (id)
|
||||||
|
WHERE (o.name, o.parent_id, o.is_trashed, o.updated_at)
|
||||||
|
IS DISTINCT FROM
|
||||||
|
(n.name, n.parent_id, n.is_trashed, n.updated_at)
|
||||||
|
)
|
||||||
|
INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id)
|
||||||
|
SELECT DISTINCT subpath(c.lpath, 0, nlevel(c.lpath) - 1), c.parent_id, c.drive_id
|
||||||
|
FROM (SELECT old_lpath AS lpath,
|
||||||
|
old_parent_id AS parent_id,
|
||||||
|
old_drive_id AS drive_id
|
||||||
|
FROM changed WHERE old_lpath IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT new_lpath, new_parent_id, new_drive_id
|
||||||
|
FROM changed WHERE new_lpath IS NOT NULL) c
|
||||||
|
WHERE nlevel(c.lpath) > 1;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- D0 / M5 — storage.copy_folder_tree drive_id + provenance
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- The `copy_folder_tree` SQL function (initial_schema.sql) batches a
|
||||||
|
-- recursive folder copy in PL/pgSQL — its own INSERTs into
|
||||||
|
-- `storage.folders` and `storage.files`. D0's M3 made `drive_id` NOT
|
||||||
|
-- NULL on both tables; the function's pre-D0 body doesn't write it,
|
||||||
|
-- so any `/api/batch/folders/copy` call errors with "null value in
|
||||||
|
-- column drive_id" until this migration lands.
|
||||||
|
--
|
||||||
|
-- The replacement preserves every other semantic of the original:
|
||||||
|
-- - level-by-level INSERTs so the BEFORE INSERT trigger
|
||||||
|
-- (`trg_folders_path`) can resolve the parent's path/lpath from
|
||||||
|
-- rows inserted in the previous level.
|
||||||
|
-- - One batched file INSERT (zero-copy via blob hash) at the end.
|
||||||
|
-- - Returns the same shape: (new_root_id::text, folders_copied,
|
||||||
|
-- files_copied).
|
||||||
|
--
|
||||||
|
-- New columns written:
|
||||||
|
-- - drive_id: pulled from the source row (intra-drive copy — cross-
|
||||||
|
-- drive copies are a D2+ feature; the function preserves the
|
||||||
|
-- source's drive_id for both folders and files).
|
||||||
|
-- - created_by / updated_by: set to the source row's user_id,
|
||||||
|
-- matching the dual-write convention used by the Rust repos.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
|
||||||
|
p_source_id UUID,
|
||||||
|
p_target_parent_id UUID, -- NULL = copy to root
|
||||||
|
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
|
||||||
|
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_root_lpath ltree;
|
||||||
|
v_root_depth INT;
|
||||||
|
v_max_depth INT;
|
||||||
|
v_level INT;
|
||||||
|
v_folders BIGINT := 0;
|
||||||
|
v_files BIGINT := 0;
|
||||||
|
v_inserted BIGINT;
|
||||||
|
v_new_root UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Validate source exists
|
||||||
|
SELECT fo.lpath, nlevel(fo.lpath)
|
||||||
|
INTO v_root_lpath, v_root_depth
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
|
||||||
|
|
||||||
|
IF v_root_lpath IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Source folder not found: %', p_source_id
|
||||||
|
USING ERRCODE = 'P0002'; -- no_data_found
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Temp mapping: every folder in the subtree → new UUID
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
|
||||||
|
old_id UUID PRIMARY KEY,
|
||||||
|
new_id UUID NOT NULL DEFAULT gen_random_uuid()
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
TRUNCATE _copy_map;
|
||||||
|
|
||||||
|
INSERT INTO _copy_map(old_id)
|
||||||
|
SELECT fo.id
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE NOT fo.is_trashed
|
||||||
|
AND fo.lpath <@ v_root_lpath;
|
||||||
|
|
||||||
|
-- Remember new root ID
|
||||||
|
SELECT cm.new_id INTO v_new_root
|
||||||
|
FROM _copy_map cm WHERE cm.old_id = p_source_id;
|
||||||
|
|
||||||
|
-- Max depth for level iteration
|
||||||
|
SELECT MAX(nlevel(fo.lpath))
|
||||||
|
INTO v_max_depth
|
||||||
|
FROM storage.folders fo
|
||||||
|
JOIN _copy_map cm ON fo.id = cm.old_id;
|
||||||
|
|
||||||
|
-- ── Insert folders level by level ──
|
||||||
|
-- Each level is a separate INSERT so the BEFORE INSERT trigger
|
||||||
|
-- (trg_folders_path) can resolve the parent's path/lpath from rows
|
||||||
|
-- inserted in the previous level. drive_id + provenance threaded
|
||||||
|
-- through from the source row at each level.
|
||||||
|
FOR v_level IN v_root_depth .. v_max_depth LOOP
|
||||||
|
INSERT INTO storage.folders(
|
||||||
|
id, name, parent_id, user_id,
|
||||||
|
drive_id, created_by, updated_by
|
||||||
|
)
|
||||||
|
SELECT cm.new_id,
|
||||||
|
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
|
||||||
|
THEN p_dest_name ELSE fo.name END,
|
||||||
|
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
|
||||||
|
ELSE pm.new_id END,
|
||||||
|
fo.user_id,
|
||||||
|
fo.drive_id,
|
||||||
|
fo.user_id,
|
||||||
|
fo.user_id
|
||||||
|
FROM storage.folders fo
|
||||||
|
JOIN _copy_map cm ON fo.id = cm.old_id
|
||||||
|
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
|
||||||
|
WHERE NOT fo.is_trashed
|
||||||
|
AND nlevel(fo.lpath) = v_level;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_inserted = ROW_COUNT;
|
||||||
|
v_folders := v_folders + v_inserted;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- ── Batch copy all files (zero-copy: same blob_hash) ──
|
||||||
|
INSERT INTO storage.files(
|
||||||
|
name, folder_id, user_id, blob_hash, size, mime_type,
|
||||||
|
media_sort_date, drive_id, created_by, updated_by
|
||||||
|
)
|
||||||
|
SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type,
|
||||||
|
f.media_sort_date, f.drive_id, f.user_id, f.user_id
|
||||||
|
FROM storage.files f
|
||||||
|
JOIN _copy_map cm ON f.folder_id = cm.old_id
|
||||||
|
WHERE NOT f.is_trashed;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_files = ROW_COUNT;
|
||||||
|
|
||||||
|
-- ── Batch increment blob ref_counts ──
|
||||||
|
IF v_files > 0 THEN
|
||||||
|
UPDATE storage.blobs b
|
||||||
|
SET ref_count = ref_count + hc.cnt
|
||||||
|
FROM (
|
||||||
|
SELECT f.blob_hash, COUNT(*)::int AS cnt
|
||||||
|
FROM storage.files f
|
||||||
|
JOIN _copy_map cm ON f.folder_id = cm.new_id
|
||||||
|
WHERE NOT f.is_trashed
|
||||||
|
GROUP BY f.blob_hash
|
||||||
|
) hc
|
||||||
|
WHERE b.hash = hc.blob_hash;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! DTOs for the `/api/drives` endpoint surface.
|
||||||
|
//!
|
||||||
|
//! D0 surfaces only the read-only list. Mutating endpoints
|
||||||
|
//! (`POST /api/drives` for shared-drive creation, `PATCH` for rename /
|
||||||
|
//! policy edits, membership APIs) land in D2/D3.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::entities::drive::{Drive, DriveKind};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DriveKindDto {
|
||||||
|
Personal,
|
||||||
|
Shared,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DriveKind> for DriveKindDto {
|
||||||
|
fn from(k: DriveKind) -> Self {
|
||||||
|
match k {
|
||||||
|
DriveKind::Personal => DriveKindDto::Personal,
|
||||||
|
DriveKind::Shared => DriveKindDto::Shared,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row in `GET /api/drives` — a drive the caller can read.
|
||||||
|
///
|
||||||
|
/// `default_for_user` is `Some(<caller_id>)` for the caller's default
|
||||||
|
/// Personal drive and `None` otherwise. The picker UI uses this to put
|
||||||
|
/// the default at the top of the list and mark it as "your home".
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct DriveDto {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: DriveKindDto,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default_for_user: Option<Uuid>,
|
||||||
|
/// Storage cap in bytes. `None` means "no quota" (admin override /
|
||||||
|
/// future system drives).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub quota_bytes: Option<i64>,
|
||||||
|
/// Running total of bytes consumed. Maintained incrementally in D4;
|
||||||
|
/// on D0 this reflects the backfilled baseline.
|
||||||
|
pub used_bytes: i64,
|
||||||
|
/// Capability-flag bag — clients render UI affordances based on
|
||||||
|
/// known keys (`forbid_public_links`, `include_in_photo_index`,
|
||||||
|
/// `forbid_music_index`, …). Unknown keys preserved verbatim.
|
||||||
|
pub policies: serde_json::Value,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Drive> for DriveDto {
|
||||||
|
fn from(d: Drive) -> Self {
|
||||||
|
Self {
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
kind: d.kind.into(),
|
||||||
|
default_for_user: d.default_for_user,
|
||||||
|
quota_bytes: d.quota_bytes,
|
||||||
|
used_bytes: d.used_bytes,
|
||||||
|
policies: d.policies,
|
||||||
|
created_at: d.created_at,
|
||||||
|
updated_at: d.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ impl From<Subject> for SubjectDto {
|
|||||||
pub enum ResourceTypeDto {
|
pub enum ResourceTypeDto {
|
||||||
Folder,
|
Folder,
|
||||||
File,
|
File,
|
||||||
|
Drive,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -72,6 +73,7 @@ impl From<ResourceDto> for Resource {
|
|||||||
match dto.kind {
|
match dto.kind {
|
||||||
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
||||||
ResourceTypeDto::File => Resource::File(dto.id),
|
ResourceTypeDto::File => Resource::File(dto.id),
|
||||||
|
ResourceTypeDto::Drive => Resource::Drive(dto.id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,7 @@ impl From<Resource> for ResourceDto {
|
|||||||
let (kind, id) = match r {
|
let (kind, id) = match r {
|
||||||
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
||||||
Resource::File(id) => (ResourceTypeDto::File, id),
|
Resource::File(id) => (ResourceTypeDto::File, id),
|
||||||
|
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
|
||||||
};
|
};
|
||||||
ResourceDto { kind, id }
|
ResourceDto { kind, id }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod calendar_dto;
|
|||||||
pub mod contact_dto;
|
pub mod contact_dto;
|
||||||
pub mod device_auth_dto;
|
pub mod device_auth_dto;
|
||||||
pub mod display_helpers;
|
pub mod display_helpers;
|
||||||
|
pub mod drive_dto;
|
||||||
pub mod favorites_dto;
|
pub mod favorites_dto;
|
||||||
pub mod file_dto;
|
pub mod file_dto;
|
||||||
pub mod folder_dto;
|
pub mod folder_dto;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
let (kind, id) = match resource {
|
let (kind, id) = match resource {
|
||||||
Resource::Folder(id) => ("Folder", id),
|
Resource::Folder(id) => ("Folder", id),
|
||||||
Resource::File(id) => ("File", id),
|
Resource::File(id) => ("File", id),
|
||||||
|
Resource::Drive(id) => ("Drive", id),
|
||||||
};
|
};
|
||||||
// Audit-worthy: denials are the interesting signal. Routed
|
// Audit-worthy: denials are the interesting signal. Routed
|
||||||
// through the `audit` tracing target so log aggregators can
|
// through the `audit` tracing target so log aggregators can
|
||||||
|
|||||||
@@ -35,14 +35,26 @@ pub struct ContentHitDto {
|
|||||||
/// holds an `Option<Arc<dyn ContentIndexPort>>` (the feature is toggleable).
|
/// holds an `Option<Arc<dyn ContentIndexPort>>` (the feature is toggleable).
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ContentIndexPort: Send + Sync + 'static {
|
pub trait ContentIndexPort: Send + Sync + 'static {
|
||||||
/// Search indexed file names + content for `query`, scoped to `user_id`.
|
/// Search indexed file names + content for `query`, scoped to the drives
|
||||||
|
/// the caller can read.
|
||||||
///
|
///
|
||||||
/// Returns up to `limit` hits sorted by BM25 score descending. Matching is
|
/// The filter is applied as an `Occur::Must` set-membership clause on
|
||||||
/// tokenized (not substring): exact terms, typo-tolerant fuzzy terms
|
/// the `drive_id` field — Tantivy's collector only ever sees documents
|
||||||
/// (edit distance 1) and prefix expansion on the last query token.
|
/// in one of the accessible drives, so counts, snippets, and
|
||||||
|
/// pagination cursors all reflect the filtered set (no anti-
|
||||||
|
/// enumeration leak — see `docs/plan/drive.md` §11). Pass the
|
||||||
|
/// caller's full accessible-drive set; the engine already expands
|
||||||
|
/// group-mediated drive grants before this is called.
|
||||||
|
///
|
||||||
|
/// An empty `accessible_drive_ids` returns no hits — same semantics
|
||||||
|
/// as "no drives, no search" (e.g. external users with grants only).
|
||||||
|
/// Returns up to `limit` hits sorted by BM25 score descending.
|
||||||
|
/// Matching is tokenized (not substring): exact terms, typo-tolerant
|
||||||
|
/// fuzzy terms (edit distance 1) and prefix expansion on the last
|
||||||
|
/// query token.
|
||||||
async fn search_content(
|
async fn search_content(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
accessible_drive_ids: &[Uuid],
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ContentHitDto>, DomainError>;
|
) -> Result<Vec<ContentHitDto>, DomainError>;
|
||||||
|
|||||||
@@ -85,9 +85,12 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
|||||||
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
|
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Creates a root-level home folder for a user during registration.
|
/// Creates a root-level home folder for a user during registration.
|
||||||
|
/// `drive_id` is the user's personal drive; the wrapper folder stays
|
||||||
|
/// during the D0 dual-write window (retires in M2b later).
|
||||||
async fn create_home_folder(
|
async fn create_home_folder(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
drive_id: Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<FolderDto, DomainError>;
|
) -> Result<FolderDto, DomainError>;
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ impl FolderService {
|
|||||||
async fn create_home_folder(
|
async fn create_home_folder(
|
||||||
&self,
|
&self,
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
|
_drive_id: Uuid,
|
||||||
_name: String,
|
_name: String,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
@@ -235,14 +236,18 @@ impl FolderUseCase for FolderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a root-level home folder for a user during registration.
|
/// Creates a root-level home folder for a user during registration.
|
||||||
|
/// `drive_id` is the user's personal drive — the wrapper folder lives
|
||||||
|
/// inside it during the D0 dual-write window (M2b retires the wrapper
|
||||||
|
/// later).
|
||||||
async fn create_home_folder(
|
async fn create_home_folder(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
drive_id: Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
let folder = self
|
let folder = self
|
||||||
.folder_storage
|
.folder_storage
|
||||||
.create_home_folder(user_id, name)
|
.create_home_folder(user_id, drive_id, name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
@@ -632,6 +637,7 @@ impl FolderService {
|
|||||||
pub async fn ensure_home_folder(
|
pub async fn ensure_home_folder(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
drive_id: Uuid,
|
||||||
username: Option<&str>,
|
username: Option<&str>,
|
||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
let existing = self
|
let existing = self
|
||||||
@@ -653,7 +659,7 @@ impl FolderService {
|
|||||||
None => format!("My Folder - {}", user_id),
|
None => format!("My Folder - {}", user_id),
|
||||||
};
|
};
|
||||||
self.folder_storage
|
self.folder_storage
|
||||||
.create_home_folder(user_id, folder_name.clone())
|
.create_home_folder(user_id, drive_id, folder_name.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
@@ -743,36 +749,140 @@ use async_trait::async_trait;
|
|||||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||||
use crate::domain::entities::user::User;
|
use crate::domain::entities::user::User;
|
||||||
|
|
||||||
/// Lifecycle hook: provisions and (in PR 4) deprovisions a user's home folder.
|
/// Lifecycle hook: provisions a user's default Personal drive at first
|
||||||
pub struct HomeFolderLifecycleHook {
|
/// login (replaces the legacy `My Folder - <username>` wrapper as of D0).
|
||||||
|
///
|
||||||
|
/// Two writes happen on first provisioning:
|
||||||
|
/// 1. A row in `storage.drives` with `kind='personal'`,
|
||||||
|
/// `default_for_user=<uid>`, and the user's quota carried over from
|
||||||
|
/// `auth.users.storage_quota_bytes`.
|
||||||
|
/// 2. An Owner role grant in `storage.role_grants` so the user can
|
||||||
|
/// read/write/manage their own drive (the engine's owner short-
|
||||||
|
/// circuit applies to folders/files but not drives — see
|
||||||
|
/// `pg_acl_engine::check_inner` D0-6 rewrite).
|
||||||
|
///
|
||||||
|
/// Both writes are idempotent: `find_default_for_user` short-circuits
|
||||||
|
/// when the drive already exists; `set_role` is an UPSERT that no-ops
|
||||||
|
/// when the Owner row is already present.
|
||||||
|
pub struct PersonalDriveLifecycleHook {
|
||||||
|
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||||
folder_service: Arc<FolderService>,
|
folder_service: Arc<FolderService>,
|
||||||
|
// The `AuthorizationEngine` trait isn't `dyn`-compatible (native
|
||||||
|
// async-fn-in-trait methods are not object-safe), so we hold the
|
||||||
|
// concrete engine. This matches the convention already used by
|
||||||
|
// `AppState.authorization`.
|
||||||
|
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HomeFolderLifecycleHook {
|
impl PersonalDriveLifecycleHook {
|
||||||
pub fn new(folder_service: Arc<FolderService>) -> Self {
|
pub fn new(
|
||||||
Self { folder_service }
|
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||||
|
folder_service: Arc<FolderService>,
|
||||||
|
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
drive_repo,
|
||||||
|
folder_service,
|
||||||
|
authorization,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idempotent provisioning shared by `on_user_created` and
|
/// Idempotent provisioning shared by `on_user_created` and
|
||||||
/// `on_user_login`. External users are skipped per tip #2 in the
|
/// `on_user_login`. External users are skipped per tip #2 in the
|
||||||
/// trait docstring.
|
/// trait docstring — they have no resources of their own, only
|
||||||
|
/// grants on other users' resources.
|
||||||
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
|
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
use crate::domain::repositories::drive_repository::{
|
||||||
|
CreatePersonalDriveInput, DriveRepositoryError,
|
||||||
|
};
|
||||||
|
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||||
|
|
||||||
if user.is_external() {
|
if user.is_external() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// `ensure_home_folder` handles the "does the user already have a
|
|
||||||
// root folder?" check internally and is a no-op if so.
|
// Idempotent shortcut: if the user already has a default drive,
|
||||||
self.folder_service
|
// nothing to do. Covers re-runs from `on_user_login` plus the
|
||||||
.ensure_home_folder(user.id(), user.username())
|
// case where `on_user_created` ran successfully but logged in
|
||||||
|
// before reaching the role_grant step (next-login retry lands
|
||||||
|
// here and finds the drive, completing the role_grant if missing).
|
||||||
|
match self.drive_repo.find_default_for_user(user.id()).await {
|
||||||
|
Ok(drive) => {
|
||||||
|
// Drive exists; ensure the Owner role_grant is in
|
||||||
|
// place too. `set_role` is an UPSERT — safe to re-run.
|
||||||
|
self.authorization
|
||||||
|
.set_role(
|
||||||
|
user.id(),
|
||||||
|
Subject::User(user.id()),
|
||||||
|
Role::Owner,
|
||||||
|
Resource::Drive(drive.id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map(|_created| ())
|
.map(|_grant| ())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(DriveRepositoryError::NotFound(_)) => { /* fall through to create */ }
|
||||||
|
Err(e) => {
|
||||||
|
return Err(DomainError::internal_error(
|
||||||
|
"PersonalDriveHook",
|
||||||
|
format!("find_default lookup: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the drive.
|
||||||
|
let drive = self
|
||||||
|
.drive_repo
|
||||||
|
.create_personal(CreatePersonalDriveInput {
|
||||||
|
name: "Personal".to_owned(),
|
||||||
|
owner_id: user.id(),
|
||||||
|
is_default: true,
|
||||||
|
quota_bytes: Some(user.storage_quota_bytes()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("PersonalDriveHook", format!("create_personal: {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!(
|
||||||
|
target: "user_lifecycle",
|
||||||
|
hook = "personal_drive",
|
||||||
|
user_id = %user.id(),
|
||||||
|
drive_id = %drive.id,
|
||||||
|
"Default personal drive + wrapper folder provisioned"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserLifecycleHook for HomeFolderLifecycleHook {
|
impl UserLifecycleHook for PersonalDriveLifecycleHook {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"home_folder"
|
"personal_drive"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||||
@@ -787,7 +897,7 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||||
// Folders don't react to logout. Explicit no-op per the
|
// Drives don't react to logout. Explicit no-op per the
|
||||||
// "no defaults" convention.
|
// "no defaults" convention.
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -798,24 +908,22 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
|
|||||||
mode: DeletionMode,
|
mode: DeletionMode,
|
||||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
// For both DeletionMode variants today the FK CASCADE on
|
// `storage.drives.default_for_user` has ON DELETE CASCADE
|
||||||
// `storage.folders.user_id` (and downstream files/blobs)
|
// referencing `auth.users(id)`, and `storage.folders.drive_id`
|
||||||
// removes the home folder + contents when the user row goes.
|
// / `storage.files.drive_id` both have ON DELETE CASCADE on
|
||||||
|
// `storage.drives(id)` (M3). So a user delete cascades:
|
||||||
|
// user → drive → folders → files in one transaction.
|
||||||
|
//
|
||||||
// The hook emits a per-mode tracing event so audit can tell
|
// The hook emits a per-mode tracing event so audit can tell
|
||||||
// AdminDelete (currently recoverable only via DB-level rollback
|
// AdminDelete (currently recoverable only via DB-level rollback
|
||||||
// before commit) from GdprPurge (no sweeper exists yet — the
|
// before commit) from GdprPurge (no sweeper exists yet — the
|
||||||
// variant is reserved for a future PR that adds retention).
|
// variant is reserved for a future PR that adds retention).
|
||||||
//
|
|
||||||
// The `tx` is provided per the trait contract but unused here:
|
|
||||||
// emitting a tracing event doesn't require DB access. Future
|
|
||||||
// policy (trash with retention) would write to `storage.trash`
|
|
||||||
// inside this same tx.
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "user_lifecycle",
|
target: "user_lifecycle",
|
||||||
hook = "home_folder",
|
hook = "personal_drive",
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
mode = ?mode,
|
mode = ?mode,
|
||||||
"Home folder will be removed via FK CASCADE on user delete"
|
"Personal drive (and tree) will be removed via FK CASCADE on user delete"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,6 +307,23 @@ impl MagicLinkInviteService {
|
|||||||
let (kind, resource_id) = match resource {
|
let (kind, resource_id) = match resource {
|
||||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||||
|
// Drive sharing — and therefore drive magic-link invitations —
|
||||||
|
// land in D2. The grant DTOs accept `Resource::Drive` from the
|
||||||
|
// wire today (see ResourceTypeDto) but no public API path
|
||||||
|
// actually grants on a drive in D0, so this arm is
|
||||||
|
// defensively unreachable. Treating it as an audit-logged
|
||||||
|
// no-op (grant is in place, mail suppressed) matches the
|
||||||
|
// ineligible-recipient branch above.
|
||||||
|
Resource::Drive(_) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.invitation_suppressed",
|
||||||
|
reason = "drive_resource_unsupported",
|
||||||
|
user_id = %recipient.id(),
|
||||||
|
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Invitation tokens are cross-device by design (recipient has
|
// Invitation tokens are cross-device by design (recipient has
|
||||||
// no prior browser context with the server) — no challenge
|
// no prior browser context with the server) — no challenge
|
||||||
@@ -329,6 +346,11 @@ impl MagicLinkInviteService {
|
|||||||
let kind_key = match resource {
|
let kind_key = match resource {
|
||||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||||
|
// Unreachable — the early-return above exits before we get
|
||||||
|
// here for a Drive resource. The arm exists only to satisfy
|
||||||
|
// exhaustiveness; if you find this firing, the early-return
|
||||||
|
// was bypassed.
|
||||||
|
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||||
};
|
};
|
||||||
// PR C: render in the recipient's preferred locale (set by UI
|
// PR C: render in the recipient's preferred locale (set by UI
|
||||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||||
@@ -731,6 +753,15 @@ impl From<ResourceKind> for MagicLinkResourceKind {
|
|||||||
match kind {
|
match kind {
|
||||||
ResourceKind::Folder => Self::Folder,
|
ResourceKind::Folder => Self::Folder,
|
||||||
ResourceKind::File => Self::File,
|
ResourceKind::File => Self::File,
|
||||||
|
// Drives aren't a magic-link invite target in D0. The
|
||||||
|
// grant DTO surface accepts drive resources, but the
|
||||||
|
// grant_handler doesn't issue magic-links for them
|
||||||
|
// (drive sharing lands in D2). Mapping Drive → Folder
|
||||||
|
// gives a non-panicking fallback that would still emit a
|
||||||
|
// valid token shape if the path were ever reached; the
|
||||||
|
// runtime branches above suppress drive invitations
|
||||||
|
// before reaching this conversion.
|
||||||
|
ResourceKind::Drive => Self::Folder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -472,6 +472,11 @@ impl RecipientNotificationService {
|
|||||||
let kind_key = match resource {
|
let kind_key = match resource {
|
||||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||||
|
// Drives don't generate share notifications in D0 — drive
|
||||||
|
// sharing lands in D2 and gets its own template key. Fall
|
||||||
|
// back to the folder label so any path that does reach
|
||||||
|
// here produces a readable, if generic, mail body.
|
||||||
|
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||||
};
|
};
|
||||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||||
// Short form for the subject, long form (with email) for the
|
// Short form for the subject, long form (with email) for the
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ pub struct SearchService {
|
|||||||
/// matches; hits are hydrated and re-filtered through SQL before use.
|
/// matches; hits are hydrated and re-filtered through SQL before use.
|
||||||
content_index: Option<Arc<dyn ContentIndexPort>>,
|
content_index: Option<Arc<dyn ContentIndexPort>>,
|
||||||
|
|
||||||
|
/// Optional authorization engine — needed to resolve the caller's
|
||||||
|
/// accessible drive set before querying the content index, and to
|
||||||
|
/// re-verify each Tantivy hit against `engine.check(Read, File(id))`
|
||||||
|
/// as a defense-in-depth measure (catches index staleness and
|
||||||
|
/// per-file grants that the drive-only Tantivy filter misses; see
|
||||||
|
/// `docs/plan/drive.md` §11). `None` short-circuits the content
|
||||||
|
/// index (the cheapest safe degradation).
|
||||||
|
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
|
||||||
|
|
||||||
|
/// Optional drive repository — used in tandem with the authorization
|
||||||
|
/// engine to resolve the caller's accessible drives for the Tantivy
|
||||||
|
/// filter. `None` short-circuits the content index.
|
||||||
|
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||||
|
|
||||||
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
|
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
|
||||||
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
|
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
|
||||||
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
|
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
|
||||||
@@ -151,6 +165,8 @@ impl SearchService {
|
|||||||
file_repository: Arc<FileBlobReadRepository>,
|
file_repository: Arc<FileBlobReadRepository>,
|
||||||
folder_repository: Arc<FolderDbRepository>,
|
folder_repository: Arc<FolderDbRepository>,
|
||||||
content_index: Option<Arc<dyn ContentIndexPort>>,
|
content_index: Option<Arc<dyn ContentIndexPort>>,
|
||||||
|
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
|
||||||
|
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||||
cache_ttl: u64,
|
cache_ttl: u64,
|
||||||
max_cache_size: usize,
|
max_cache_size: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -163,6 +179,8 @@ impl SearchService {
|
|||||||
file_repository,
|
file_repository,
|
||||||
folder_repository,
|
folder_repository,
|
||||||
content_index,
|
content_index,
|
||||||
|
authorization,
|
||||||
|
drive_repo,
|
||||||
search_cache,
|
search_cache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,9 +268,18 @@ impl SearchService {
|
|||||||
criteria: &SearchCriteriaDto,
|
criteria: &SearchCriteriaDto,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Vec<ContentHitDto> {
|
) -> Vec<ContentHitDto> {
|
||||||
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
|
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||||
|
|
||||||
let Some(index) = &self.content_index else {
|
let Some(index) = &self.content_index else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
let Some(authz) = &self.authorization else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(drive_repo) = &self.drive_repo else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
if criteria.offset != 0 {
|
if criteria.offset != 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -265,17 +292,79 @@ impl SearchService {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
match index
|
// Resolve the caller's accessible drive set via the engine
|
||||||
.search_content(user_id, query, CONTENT_HITS_LIMIT)
|
// (handles group-mediated drive grants) + the repo lookup.
|
||||||
|
let caller = Subject::User(user_id);
|
||||||
|
let (subject_types, subject_ids) = match authz.expand_subject_for_listing(caller).await {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Content-index: subject expansion failed — degrading to empty: {e}");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let accessible_drives: Vec<Uuid> = match drive_repo
|
||||||
|
.list_for_subjects(&subject_types, &subject_ids)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(drives) => drives.into_iter().map(|d| d.id).collect(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tantivy filter (Must drive_id ∈ accessible_drives) handles
|
||||||
|
// the cross-drive isolation. Empty drive list short-circuits
|
||||||
|
// inside `search_content`.
|
||||||
|
let hits = match index
|
||||||
|
.search_content(&accessible_drives, query, CONTENT_HITS_LIMIT)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(hits) => hits,
|
Ok(hits) => hits,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Content-index lookup failed — returning name-only results: {e}");
|
tracing::warn!("Content-index lookup failed — returning name-only results: {e}");
|
||||||
Vec::new()
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Defense in depth: re-verify each hit through the engine.
|
||||||
|
// Catches two cases the drive_id filter can't:
|
||||||
|
// * Index staleness — the file just moved drives and the
|
||||||
|
// worker hasn't caught up.
|
||||||
|
// * Per-file grants — ReBAC can grant a single file inside a
|
||||||
|
// drive the caller doesn't otherwise have. The Tantivy
|
||||||
|
// filter is drive-only; this re-check restores per-file
|
||||||
|
// resolution.
|
||||||
|
// Failures degrade conservatively (drop the hit, log it) —
|
||||||
|
// never leak.
|
||||||
|
let mut verified = Vec::with_capacity(hits.len());
|
||||||
|
for hit in hits {
|
||||||
|
let file_uuid = match Uuid::parse_str(&hit.file_id) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("Content-index hit had non-UUID file_id: {}", hit.file_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match authz
|
||||||
|
.check(caller, Permission::Read, Resource::File(file_uuid))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => verified.push(hit),
|
||||||
|
Ok(false) => {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "oxicloud::search",
|
||||||
|
file_id = %file_uuid,
|
||||||
|
"dropping content-index hit: ReBAC denies Read after Tantivy filter",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("ReBAC re-check failed for {file_uuid}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
verified
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge content-index hits into the name-search result page:
|
/// Merge content-index hits into the name-search result page:
|
||||||
/// * files the name search already found just gain their `snippet`;
|
/// * files the name search already found just gain their `snippet`;
|
||||||
|
|||||||
@@ -1020,6 +1020,7 @@ mod tests {
|
|||||||
async fn create_home_folder(
|
async fn create_home_folder(
|
||||||
&self,
|
&self,
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
|
_drive_id: Uuid,
|
||||||
_name: String,
|
_name: String,
|
||||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
|
|||||||
@@ -826,6 +826,7 @@ impl FolderRepository for MockFolderRepository {
|
|||||||
async fn create_home_folder(
|
async fn create_home_folder(
|
||||||
&self,
|
&self,
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
|
_drive_id: Uuid,
|
||||||
_name: String,
|
_name: String,
|
||||||
) -> std::result::Result<Folder, DomainError> {
|
) -> std::result::Result<Folder, DomainError> {
|
||||||
Ok(Folder::default())
|
Ok(Folder::default())
|
||||||
|
|||||||
+20
-1
@@ -454,6 +454,7 @@ impl AppServiceFactory {
|
|||||||
repos: &RepositoryServices,
|
repos: &RepositoryServices,
|
||||||
trash_service: Option<Arc<TrashService>>,
|
trash_service: Option<Arc<TrashService>>,
|
||||||
authz: &Arc<PgAclEngine>,
|
authz: &Arc<PgAclEngine>,
|
||||||
|
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||||
storage_usage: &Arc<StorageUsageService>,
|
storage_usage: &Arc<StorageUsageService>,
|
||||||
content_index: Option<Arc<TantivyContentIndex>>,
|
content_index: Option<Arc<TantivyContentIndex>>,
|
||||||
plugin_dispatch: Option<
|
plugin_dispatch: Option<
|
||||||
@@ -540,6 +541,8 @@ impl AppServiceFactory {
|
|||||||
repos.file_read_repository.clone(),
|
repos.file_read_repository.clone(),
|
||||||
repos.folder_repository.clone(),
|
repos.folder_repository.clone(),
|
||||||
content_index_port,
|
content_index_port,
|
||||||
|
Some(authz.clone()),
|
||||||
|
Some(drive_repo.clone()),
|
||||||
300, // Cache TTL in seconds (5 minutes)
|
300, // Cache TTL in seconds (5 minutes)
|
||||||
1000, // Maximum cache entries
|
1000, // Maximum cache entries
|
||||||
)));
|
)));
|
||||||
@@ -1050,6 +1053,12 @@ impl AppServiceFactory {
|
|||||||
subject_group_repo.clone(),
|
subject_group_repo.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drive repository — needed both by the lifecycle hook (when auth
|
||||||
|
// is enabled) and by `GET /api/drives` on the final `AppState`,
|
||||||
|
// so declared at the outer scope.
|
||||||
|
let drive_repo =
|
||||||
|
Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone()));
|
||||||
|
|
||||||
// 3b. Trash service (needed before application services)
|
// 3b. Trash service (needed before application services)
|
||||||
let trash_service = self
|
let trash_service = self
|
||||||
.create_trash_service(&repos, &core, &authorization)
|
.create_trash_service(&repos, &core, &authorization)
|
||||||
@@ -1078,6 +1087,7 @@ impl AppServiceFactory {
|
|||||||
&repos,
|
&repos,
|
||||||
trash_service.clone(),
|
trash_service.clone(),
|
||||||
&authorization,
|
&authorization,
|
||||||
|
&drive_repo,
|
||||||
&storage_usage,
|
&storage_usage,
|
||||||
content_index.as_ref().map(|(idx, _)| idx.clone()),
|
content_index.as_ref().map(|(idx, _)| idx.clone()),
|
||||||
plugin_dispatch.clone(),
|
plugin_dispatch.clone(),
|
||||||
@@ -1190,8 +1200,10 @@ impl AppServiceFactory {
|
|||||||
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
|
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
|
||||||
))
|
))
|
||||||
.with_hook(Arc::new(
|
.with_hook(Arc::new(
|
||||||
crate::application::services::folder_service::HomeFolderLifecycleHook::new(
|
crate::application::services::folder_service::PersonalDriveLifecycleHook::new(
|
||||||
|
drive_repo.clone(),
|
||||||
apps.folder_service_concrete.clone(),
|
apps.folder_service_concrete.clone(),
|
||||||
|
authorization.clone(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
.with_hook(Arc::new(
|
.with_hook(Arc::new(
|
||||||
@@ -1379,6 +1391,7 @@ impl AppServiceFactory {
|
|||||||
webdav_lock_store:
|
webdav_lock_store:
|
||||||
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
|
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
|
||||||
authorization,
|
authorization,
|
||||||
|
drive_repo: drive_repo.clone(),
|
||||||
subject_group_service: Some(Arc::new(
|
subject_group_service: Some(Arc::new(
|
||||||
crate::application::services::subject_group_service::SubjectGroupService::new(
|
crate::application::services::subject_group_service::SubjectGroupService::new(
|
||||||
subject_group_repo.clone(),
|
subject_group_repo.clone(),
|
||||||
@@ -1839,6 +1852,12 @@ pub struct AppState {
|
|||||||
/// an enum dispatcher or `Arc<dyn AuthorizationEngine>` (with
|
/// an enum dispatcher or `Arc<dyn AuthorizationEngine>` (with
|
||||||
/// `async_trait` boxing).
|
/// `async_trait` boxing).
|
||||||
pub authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
pub authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||||
|
/// Drive entity repository — `GET /api/drives`, the personal-drive
|
||||||
|
/// lifecycle hook, and (post-D2) shared-drive creation flow all read
|
||||||
|
/// through this. Backing table is `storage.drives`; membership is
|
||||||
|
/// resolved through `role_grants` not a separate `drive_members`
|
||||||
|
/// table (see `docs/plan/drive.md` §3).
|
||||||
|
pub drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||||
/// ReBAC subject-group management (CRUD + membership). `None` when the
|
/// ReBAC subject-group management (CRUD + membership). `None` when the
|
||||||
/// auth subsystem is not configured.
|
/// auth subsystem is not configured.
|
||||||
pub subject_group_service:
|
pub subject_group_service:
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ impl FolderRepository for StubFolderStoragePort {
|
|||||||
async fn create_home_folder(
|
async fn create_home_folder(
|
||||||
&self,
|
&self,
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
|
_drive_id: Uuid,
|
||||||
_name: String,
|
_name: String,
|
||||||
) -> Result<Folder, DomainError> {
|
) -> Result<Folder, DomainError> {
|
||||||
Ok(Folder::default())
|
Ok(Folder::default())
|
||||||
@@ -459,6 +460,7 @@ impl FolderUseCase for StubFolderUseCase {
|
|||||||
async fn create_home_folder(
|
async fn create_home_folder(
|
||||||
&self,
|
&self,
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
|
_drive_id: Uuid,
|
||||||
_name: String,
|
_name: String,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Drive — the top-level container that owns a tree of folders/files.
|
||||||
|
//!
|
||||||
|
//! Drives replaced the per-user `My Folder - <username>` wrapper at D0.
|
||||||
|
//! Every folder and file row carries a `drive_id` (added by D0's
|
||||||
|
//! migration); a drive is the natural unit of quota, sharing, and
|
||||||
|
//! lifecycle. Membership is expressed through `storage.role_grants` rows
|
||||||
|
//! with `resource_type='drive'` — there is no separate `drive_members`
|
||||||
|
//! table.
|
||||||
|
//!
|
||||||
|
//! ## Kinds
|
||||||
|
//!
|
||||||
|
//! Two kinds today; the discriminant is the `kind` column with a CHECK
|
||||||
|
//! constraint.
|
||||||
|
//!
|
||||||
|
//! - **`personal`** — single-user, single-owner. The owner is captured
|
||||||
|
//! by `default_for_user` (for the default Personal drive) or by an
|
||||||
|
//! Owner role_grant on a secondary personal drive. Personal drives
|
||||||
|
//! refuse `add_member`, `remove_member`, and `delete_drive` (when
|
||||||
|
//! it's the user's only or default drive). A user can have multiple
|
||||||
|
//! personal drives — one is marked default (`default_for_user =
|
||||||
|
//! <uid>`), the others are secondaries (`default_for_user = NULL`,
|
||||||
|
//! one Owner row in role_grants pinning them to the same user).
|
||||||
|
//!
|
||||||
|
//! - **`shared`** — multi-member, group-aware, full role roster
|
||||||
|
//! (viewer / commenter / contributor / editor / owner). Members
|
||||||
|
//! come from role_grants; group subjects expand transitively via
|
||||||
|
//! the existing `subject_groups` machinery. Last-owner protection
|
||||||
|
//! applies on member removal and drive deletion. Quota is set by
|
||||||
|
//! the drive owner (or admin); `used_bytes` tracks consumption.
|
||||||
|
//!
|
||||||
|
//! Future kinds (e.g. `system` for built-in scratch space) drop in by
|
||||||
|
//! extending the CHECK + the `DriveKind` enum.
|
||||||
|
//!
|
||||||
|
//! ## Policies
|
||||||
|
//!
|
||||||
|
//! `policies` is a JSONB bag carrying feature flags / capability toggles
|
||||||
|
//! that drive owners can flip without a schema change. Known keys live in
|
||||||
|
//! `docs/plan/drive.md` §8 and §15 (e.g. `forbid_public_links`,
|
||||||
|
//! `include_in_photo_index`, `forbid_music_index`). Unknown keys are
|
||||||
|
//! preserved by the application — the schema is intentionally permissive
|
||||||
|
//! so future capability flags can land without a migration.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Drive kind discriminant. Mirrors the `storage.drives.kind` CHECK
|
||||||
|
/// constraint values.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DriveKind {
|
||||||
|
/// Single-owner storage compartment. Cannot have members added or
|
||||||
|
/// removed via the membership API; the owner is fixed for the drive's
|
||||||
|
/// lifetime.
|
||||||
|
Personal,
|
||||||
|
/// Multi-member drive supporting the full role roster. Membership is
|
||||||
|
/// open to admin/owner-driven changes through the membership API.
|
||||||
|
Shared,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DriveKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DriveKind::Personal => "personal",
|
||||||
|
DriveKind::Shared => "shared",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"personal" => Some(DriveKind::Personal),
|
||||||
|
"shared" => Some(DriveKind::Shared),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain entity for a row in `storage.drives`.
|
||||||
|
///
|
||||||
|
/// Field-level constraints are enforced at the SQL layer (CHECK on
|
||||||
|
/// `kind`, partial UNIQUE on `default_for_user`). The struct mirrors
|
||||||
|
/// the column set 1:1; behaviour beyond field access lives in
|
||||||
|
/// `DriveRepository` (D0-5) and `DriveService` (post-D0).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Drive {
|
||||||
|
/// Stable identifier. Generated server-side at creation.
|
||||||
|
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`].
|
||||||
|
pub kind: DriveKind,
|
||||||
|
/// Set iff this is the user's default personal drive (UNIQUE in SQL
|
||||||
|
/// via a partial index `WHERE default_for_user IS NOT NULL`). NULL
|
||||||
|
/// on shared drives and on secondary personal drives.
|
||||||
|
pub default_for_user: Option<Uuid>,
|
||||||
|
/// Soft cap on this drive's storage usage, in bytes. `None` means
|
||||||
|
/// "no quota" (rare; reserved for admin overrides). The default
|
||||||
|
/// initial quota for a fresh personal drive is taken from the
|
||||||
|
/// owner's `auth.users.storage_quota_bytes` at creation time (see
|
||||||
|
/// Open Question 2 in `docs/plan/drive.md`).
|
||||||
|
pub quota_bytes: Option<i64>,
|
||||||
|
/// Running total of bytes consumed. Maintained incrementally by
|
||||||
|
/// upload/delete paths in D4; on D0 still reflects the pre-Drive
|
||||||
|
/// per-user counters via the backfill.
|
||||||
|
pub used_bytes: i64,
|
||||||
|
/// Capability flags / feature toggles. Extensible JSONB — see
|
||||||
|
/// `docs/plan/drive.md` §8 and §15 for the known keys.
|
||||||
|
pub policies: serde_json::Value,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drive {
|
||||||
|
/// `true` for the user's default personal drive (the only drive for
|
||||||
|
/// which `default_for_user` is set to that user's id).
|
||||||
|
pub fn is_default_for(&self, user_id: Uuid) -> bool {
|
||||||
|
self.default_for_user == Some(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` if this drive is a personal drive of any kind (default or
|
||||||
|
/// secondary). Encapsulates the kind check at the call site.
|
||||||
|
pub fn is_personal(&self) -> bool {
|
||||||
|
matches!(self.kind, DriveKind::Personal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod calendar;
|
|||||||
pub mod calendar_event;
|
pub mod calendar_event;
|
||||||
pub mod contact;
|
pub mod contact;
|
||||||
pub mod device_code;
|
pub mod device_code;
|
||||||
|
pub mod drive;
|
||||||
pub mod entity_errors;
|
pub mod entity_errors;
|
||||||
pub mod face;
|
pub mod face;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! Repository for [`Drive`] entities backed by `storage.drives`.
|
||||||
|
//!
|
||||||
|
//! Drives have no separate membership table — owner/editor/viewer
|
||||||
|
//! membership lives in `storage.role_grants` with
|
||||||
|
//! `resource_type='drive'`. That means **listing the drives a user can
|
||||||
|
//! reach goes through the role-grant query, not through this
|
||||||
|
//! repository**. This repo handles:
|
||||||
|
//!
|
||||||
|
//! * Creating a drive (used by the user-creation lifecycle hook and
|
||||||
|
//! by D3's shared-drive flow).
|
||||||
|
//! * Looking up a single drive by id (used by the engine's owner_of /
|
||||||
|
//! check paths, by `/api/drives/{id}`, and by the drive picker).
|
||||||
|
//! * Finding the caller's default drive (used by the Photos / Music
|
||||||
|
//! endpoints and by D1's redirect-from-`/` logic).
|
||||||
|
//!
|
||||||
|
//! Membership-flavoured queries (e.g. "list every drive user X can
|
||||||
|
//! read") live in `DriveListingService` (post-D0) which reads
|
||||||
|
//! `role_grants` and resolves the matching drive rows here.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::entities::drive::{Drive, DriveKind};
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DriveRepositoryError {
|
||||||
|
#[error("Drive not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
/// A user already has a default drive set — partial unique index on
|
||||||
|
/// `default_for_user` rejects a second one. Surfaces the constraint
|
||||||
|
/// explicitly so the lifecycle hook can no-op idempotently.
|
||||||
|
#[error("User already has a default drive: {0}")]
|
||||||
|
DefaultDriveAlreadyExists(String),
|
||||||
|
#[error("Invalid drive kind: {0}")]
|
||||||
|
InvalidKind(String),
|
||||||
|
#[error("Storage error: {0}")]
|
||||||
|
StorageError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input parameters for creating a new personal drive.
|
||||||
|
///
|
||||||
|
/// Shared drives land in D3 with their own creation surface
|
||||||
|
/// (`create_shared_drive`). For now D0 only mints personal drives —
|
||||||
|
/// either as the default for a fresh user (via the lifecycle hook) or
|
||||||
|
/// as a secondary promoted by the M2 backfill.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CreatePersonalDriveInput {
|
||||||
|
/// Display name. The lifecycle hook passes `"Personal"`; the M2
|
||||||
|
/// backfill carries over the original sibling-root folder name for
|
||||||
|
/// secondaries.
|
||||||
|
pub name: String,
|
||||||
|
/// The owner. For personal drives the owner is exactly one user.
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
/// `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]
|
||||||
|
pub trait DriveRepository: Send + Sync + 'static {
|
||||||
|
/// Insert a personal drive row. The caller is responsible for
|
||||||
|
/// inserting the matching owner row in `storage.role_grants` in the
|
||||||
|
/// same transaction (the lifecycle hook handles this; M2's backfill
|
||||||
|
/// did it directly in SQL).
|
||||||
|
///
|
||||||
|
/// Returns `DefaultDriveAlreadyExists` when `is_default=true` and the
|
||||||
|
/// owner already has a default drive — relies on the partial UNIQUE
|
||||||
|
/// index on `default_for_user`.
|
||||||
|
async fn create_personal(
|
||||||
|
&self,
|
||||||
|
input: CreatePersonalDriveInput,
|
||||||
|
) -> Result<Drive, DriveRepositoryError>;
|
||||||
|
|
||||||
|
/// Fetch a drive by id. `NotFound` when no row matches.
|
||||||
|
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError>;
|
||||||
|
|
||||||
|
/// Return the caller's default personal drive, or `NotFound` if they
|
||||||
|
/// don't have one (e.g. external users; users created before the
|
||||||
|
/// lifecycle hook fired). Drives the Photos timeline scope, the
|
||||||
|
/// `/api/recent/*` scope, and D1's redirect-from-`/`.
|
||||||
|
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError>;
|
||||||
|
|
||||||
|
/// List drives the caller can read, resolved via `role_grants` for
|
||||||
|
/// `resource_type='drive'`. The caller's group memberships are
|
||||||
|
/// expanded by the engine's `subject_match_set`; that expanded set
|
||||||
|
/// is what this method's `subject_ids` argument carries.
|
||||||
|
///
|
||||||
|
/// Returns rows in a stable order: default drive first (if any),
|
||||||
|
/// then by name. The `/api/drives` handler relies on that order for
|
||||||
|
/// the picker UI without a follow-up sort.
|
||||||
|
async fn list_for_subjects(
|
||||||
|
&self,
|
||||||
|
subject_types: &[&str],
|
||||||
|
subject_ids: &[Uuid],
|
||||||
|
) -> Result<Vec<Drive>, DriveRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: convert the canonical kind discriminator from its SQL
|
||||||
|
/// form into the typed enum. Mirrored on the entity for symmetry.
|
||||||
|
impl DriveKind {
|
||||||
|
pub fn from_sql(s: &str) -> Result<Self, DriveRepositoryError> {
|
||||||
|
DriveKind::parse(s).ok_or_else(|| DriveRepositoryError::InvalidKind(s.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,9 +98,17 @@ pub trait FolderRepository: Send + Sync + 'static {
|
|||||||
/// Permanently deletes a folder (used by the trash)
|
/// Permanently deletes a folder (used by the trash)
|
||||||
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
|
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Creates a root-level home folder for a user.
|
/// Creates a root-level home folder for a user inside their personal drive.
|
||||||
/// This is used during user registration to create the user's personal folder.
|
/// Called during user registration / first login to maintain the wrapper-
|
||||||
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError>;
|
/// folder convention through the D0 dual-write window (the wrapper itself
|
||||||
|
/// retires in a follow-up migration; for now it stays as a real folder
|
||||||
|
/// row stamped with `drive_id`).
|
||||||
|
async fn create_home_folder(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
drive_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
) -> Result<Folder, DomainError>;
|
||||||
|
|
||||||
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
|
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod address_book_repository;
|
|||||||
pub mod calendar_event_repository;
|
pub mod calendar_event_repository;
|
||||||
pub mod calendar_repository;
|
pub mod calendar_repository;
|
||||||
pub mod contact_repository;
|
pub mod contact_repository;
|
||||||
|
pub mod drive_repository;
|
||||||
pub mod file_repository;
|
pub mod file_repository;
|
||||||
pub mod folder_repository;
|
pub mod folder_repository;
|
||||||
pub mod magic_link_token_repository;
|
pub mod magic_link_token_repository;
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ impl fmt::Display for Subject {
|
|||||||
pub enum Resource {
|
pub enum Resource {
|
||||||
Folder(Uuid),
|
Folder(Uuid),
|
||||||
File(Uuid),
|
File(Uuid),
|
||||||
|
/// A drive — root scope for a tree of folders/files plus its own
|
||||||
|
/// membership and policy bag. Added in D0; membership lives in
|
||||||
|
/// `storage.role_grants` (no separate `drive_members` table).
|
||||||
|
Drive(Uuid),
|
||||||
// Reserved for future use:
|
// Reserved for future use:
|
||||||
// Calendar(Uuid),
|
// Calendar(Uuid),
|
||||||
// Reserved for future use:
|
// Reserved for future use:
|
||||||
@@ -87,6 +91,7 @@ impl Resource {
|
|||||||
match self {
|
match self {
|
||||||
Resource::Folder(_) => "folder",
|
Resource::Folder(_) => "folder",
|
||||||
Resource::File(_) => "file",
|
Resource::File(_) => "file",
|
||||||
|
Resource::Drive(_) => "drive",
|
||||||
//Resource::Calendar(_) => "calendar",
|
//Resource::Calendar(_) => "calendar",
|
||||||
//Resource::AddressBook(_) => "adressbook",
|
//Resource::AddressBook(_) => "adressbook",
|
||||||
//Resource::Playlist(_) => "playlist",
|
//Resource::Playlist(_) => "playlist",
|
||||||
@@ -95,12 +100,10 @@ impl Resource {
|
|||||||
|
|
||||||
pub fn id(&self) -> Uuid {
|
pub fn id(&self) -> Uuid {
|
||||||
match self {
|
match self {
|
||||||
Resource::Folder(id)
|
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
|
||||||
| Resource::File(id)
|
|
||||||
//| Resource::Calendar(id)
|
//| Resource::Calendar(id)
|
||||||
//| Resource::AddressBook(id)
|
//| Resource::AddressBook(id)
|
||||||
//| Resource::Playlist(id)
|
//| Resource::Playlist(id)
|
||||||
=> *id,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ impl Resource {
|
|||||||
match resource_type {
|
match resource_type {
|
||||||
"folder" => Some(Resource::Folder(id)),
|
"folder" => Some(Resource::Folder(id)),
|
||||||
"file" => Some(Resource::File(id)),
|
"file" => Some(Resource::File(id)),
|
||||||
|
"drive" => Some(Resource::Drive(id)),
|
||||||
//"calendar" => Some(Resource::Calendar(id)),
|
//"calendar" => Some(Resource::Calendar(id)),
|
||||||
//"adressbook" => Some(Resource::AddressBook(id)),
|
//"adressbook" => Some(Resource::AddressBook(id)),
|
||||||
//"playlist" => Some(Resource::Playlist(id)),
|
//"playlist" => Some(Resource::Playlist(id)),
|
||||||
@@ -351,6 +355,7 @@ impl Grant {
|
|||||||
pub enum ResourceKind {
|
pub enum ResourceKind {
|
||||||
File,
|
File,
|
||||||
Folder,
|
Folder,
|
||||||
|
Drive,
|
||||||
// Future: Calendar, AddressBook, Playlist, …
|
// Future: Calendar, AddressBook, Playlist, …
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +364,7 @@ impl ResourceKind {
|
|||||||
match self {
|
match self {
|
||||||
ResourceKind::File => "file",
|
ResourceKind::File => "file",
|
||||||
ResourceKind::Folder => "folder",
|
ResourceKind::Folder => "folder",
|
||||||
|
ResourceKind::Drive => "drive",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +372,7 @@ impl ResourceKind {
|
|||||||
match s {
|
match s {
|
||||||
"file" => Some(ResourceKind::File),
|
"file" => Some(ResourceKind::File),
|
||||||
"folder" => Some(ResourceKind::Folder),
|
"folder" => Some(ResourceKind::Folder),
|
||||||
|
"drive" => Some(ResourceKind::Drive),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
//! PostgreSQL implementation of [`DriveRepository`].
|
||||||
|
//!
|
||||||
|
//! The repo deals only with the `storage.drives` table itself. Drive
|
||||||
|
//! membership lives in `storage.role_grants` (`resource_type='drive'`)
|
||||||
|
//! and is queried through the engine's existing grant paths;
|
||||||
|
//! `list_for_subjects` below resolves `role_grants` → `storage.drives`
|
||||||
|
//! via a single join.
|
||||||
|
//!
|
||||||
|
//! See `migrations/20260802000000_drives_schema_additive.sql` for the
|
||||||
|
//! schema and `docs/plan/drive.md` §3 / §15 for the locked design.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sqlx::{PgPool, Row, types::Uuid};
|
||||||
|
|
||||||
|
use crate::domain::entities::drive::{Drive, DriveKind};
|
||||||
|
use crate::domain::repositories::drive_repository::{
|
||||||
|
CreatePersonalDriveInput, DriveRepository, DriveRepositoryError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct DrivePgRepository {
|
||||||
|
pool: Arc<PgPool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrivePgRepository {
|
||||||
|
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> DriveRepositoryError {
|
||||||
|
if let sqlx::Error::Database(ref dberr) = e
|
||||||
|
&& let Some(code) = dberr.code()
|
||||||
|
&& code.as_ref() == "23505"
|
||||||
|
{
|
||||||
|
// unique_violation. With drives, the only relevant unique is
|
||||||
|
// the partial index `idx_drives_default_for_user_unique` —
|
||||||
|
// surface the typed variant so the lifecycle hook can detect
|
||||||
|
// idempotent re-runs (D0-9 calls create_personal during
|
||||||
|
// user provisioning).
|
||||||
|
return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string());
|
||||||
|
}
|
||||||
|
DriveRepositoryError::StorageError(format!("{context}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_drive(row: &sqlx::postgres::PgRow) -> Result<Drive, DriveRepositoryError> {
|
||||||
|
let kind_str: String = row.get("kind");
|
||||||
|
let kind = DriveKind::from_sql(&kind_str)?;
|
||||||
|
Ok(Drive {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
kind,
|
||||||
|
default_for_user: row.get("default_for_user"),
|
||||||
|
quota_bytes: row.get("quota_bytes"),
|
||||||
|
used_bytes: row.get("used_bytes"),
|
||||||
|
policies: row.get("policies"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl DriveRepository for DrivePgRepository {
|
||||||
|
async fn create_personal(
|
||||||
|
&self,
|
||||||
|
input: CreatePersonalDriveInput,
|
||||||
|
) -> Result<Drive, DriveRepositoryError> {
|
||||||
|
let default_for_user = if input.is_default {
|
||||||
|
Some(input.owner_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO storage.drives
|
||||||
|
(name, kind, default_for_user, quota_bytes, policies)
|
||||||
|
VALUES ($1, 'personal', $2, $3, '{}'::jsonb)
|
||||||
|
RETURNING id, name, kind, default_for_user, quota_bytes,
|
||||||
|
used_bytes, policies, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&input.name)
|
||||||
|
.bind(default_for_user)
|
||||||
|
.bind(input.quota_bytes)
|
||||||
|
.fetch_one(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Self::map_sqlx_err("create_personal", e))?;
|
||||||
|
|
||||||
|
Self::row_to_drive(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, kind, default_for_user, quota_bytes,
|
||||||
|
used_bytes, policies, created_at, updated_at
|
||||||
|
FROM storage.drives
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?
|
||||||
|
.ok_or_else(|| DriveRepositoryError::NotFound(id.to_string()))?;
|
||||||
|
|
||||||
|
Self::row_to_drive(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, kind, default_for_user, quota_bytes,
|
||||||
|
used_bytes, policies, created_at, updated_at
|
||||||
|
FROM storage.drives
|
||||||
|
WHERE default_for_user = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Self::map_sqlx_err("find_default_for_user", e))?
|
||||||
|
.ok_or_else(|| DriveRepositoryError::NotFound(user_id.to_string()))?;
|
||||||
|
|
||||||
|
Self::row_to_drive(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_subjects(
|
||||||
|
&self,
|
||||||
|
subject_types: &[&str],
|
||||||
|
subject_ids: &[Uuid],
|
||||||
|
) -> Result<Vec<Drive>, DriveRepositoryError> {
|
||||||
|
// Joining `role_grants` → `storage.drives` returns every drive
|
||||||
|
// the expanded subject set can read. ORDER BY puts default
|
||||||
|
// drives first (so the picker UI doesn't need a follow-up
|
||||||
|
// sort), then alphabetical by name. DISTINCT collapses the
|
||||||
|
// case where a caller has multiple role_grants on the same
|
||||||
|
// drive (e.g. direct + group-mediated); a GROUP BY on the
|
||||||
|
// drive id sidesteps PostgreSQL's "ORDER BY expression must
|
||||||
|
// appear in select list" rule that `SELECT DISTINCT` imposes.
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT d.id, d.name, 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 storage.role_grants g
|
||||||
|
ON g.resource_type = 'drive'
|
||||||
|
AND g.resource_id = d.id
|
||||||
|
WHERE g.subject_type = ANY($1)
|
||||||
|
AND g.subject_id = ANY($2)
|
||||||
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||||
|
GROUP BY d.id, d.name, d.kind, d.default_for_user,
|
||||||
|
d.quota_bytes, d.used_bytes, d.policies,
|
||||||
|
d.created_at, d.updated_at
|
||||||
|
ORDER BY (d.default_for_user IS NULL) ASC,
|
||||||
|
LOWER(d.name) ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
subject_types
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.bind(subject_ids)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
|
||||||
|
|
||||||
|
rows.iter().map(Self::row_to_drive).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ use crate::infrastructure::services::dedup_service::DedupService;
|
|||||||
pub struct FileBlobWriteRepository {
|
pub struct FileBlobWriteRepository {
|
||||||
pool: Arc<PgPool>,
|
pool: Arc<PgPool>,
|
||||||
dedup: Arc<DedupService>,
|
dedup: Arc<DedupService>,
|
||||||
|
/// Retained on the struct after D0-8 inlined parent-folder lookups
|
||||||
|
/// directly via SQL; kept for now so D0's diff stays scoped to drive_id
|
||||||
|
/// + provenance plumbing. Slated for removal in a follow-up cleanup.
|
||||||
|
#[allow(dead_code)]
|
||||||
folder_repo: Arc<FolderDbRepository>,
|
folder_repo: Arc<FolderDbRepository>,
|
||||||
/// Shared handle to `FileBlobReadRepository`'s file_id → blob_hash
|
/// Shared handle to `FileBlobReadRepository`'s file_id → blob_hash
|
||||||
/// cache. Content swaps and hard deletes invalidate the mapping here
|
/// cache. Content swaps and hard deletes invalidate the mapping here
|
||||||
@@ -128,10 +132,27 @@ impl FileBlobWriteRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
|
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive user_id from the parent folder, or error if folder_id is None.
|
/// Derive `(user_id, drive_id)` from the parent folder. Both are
|
||||||
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<Uuid, DomainError> {
|
/// needed during the D0 dual-write window: `user_id` for the legacy
|
||||||
|
/// column (dropped in D7) and `drive_id` for the new owning-drive
|
||||||
|
/// reference.
|
||||||
|
async fn resolve_owner_and_drive(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
) -> Result<(Uuid, Uuid), DomainError> {
|
||||||
match folder_id {
|
match folder_id {
|
||||||
Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
|
Some(fid) => {
|
||||||
|
let row: Option<(Uuid, Uuid)> = sqlx::query_as::<_, (Uuid, Uuid)>(
|
||||||
|
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
|
||||||
|
)
|
||||||
|
.bind(fid)
|
||||||
|
.fetch_optional(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
|
||||||
|
})?;
|
||||||
|
row.ok_or_else(|| DomainError::not_found("Folder", fid))
|
||||||
|
}
|
||||||
None => Err(DomainError::internal_error(
|
None => Err(DomainError::internal_error(
|
||||||
"FileBlobWrite",
|
"FileBlobWrite",
|
||||||
"folder_id is required to determine file owner",
|
"folder_id is required to determine file owner",
|
||||||
@@ -168,7 +189,8 @@ impl FileBlobWriteRepository {
|
|||||||
)
|
)
|
||||||
UPDATE storage.files f
|
UPDATE storage.files f
|
||||||
SET blob_hash = $1, size = $2,
|
SET blob_hash = $1, size = $2,
|
||||||
updated_at = COALESCE(to_timestamp($4), NOW())
|
updated_at = COALESCE(to_timestamp($4), NOW()),
|
||||||
|
updated_by = f.user_id
|
||||||
FROM old
|
FROM old
|
||||||
WHERE f.id = old.id
|
WHERE f.id = old.id
|
||||||
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint
|
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint
|
||||||
@@ -263,11 +285,14 @@ impl FileBlobWriteRepository {
|
|||||||
sqlx::query_as::<_, (String, Uuid, String, i64, i64)>(
|
sqlx::query_as::<_, (String, Uuid, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
WITH parent AS (
|
WITH parent AS (
|
||||||
SELECT id, user_id, path FROM storage.folders WHERE id = $2::uuid
|
SELECT id, user_id, drive_id, path FROM storage.folders WHERE id = $2::uuid
|
||||||
)
|
)
|
||||||
INSERT INTO storage.files
|
INSERT INTO storage.files
|
||||||
(name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
(name, folder_id, user_id, drive_id, blob_hash, size,
|
||||||
SELECT $1, parent.id, parent.user_id, $3, $4, $5, $6 FROM parent
|
mime_type, category_order, created_by, updated_by)
|
||||||
|
SELECT $1, parent.id, parent.user_id, parent.drive_id, $3, $4,
|
||||||
|
$5, $6, parent.user_id, parent.user_id
|
||||||
|
FROM parent
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
user_id,
|
user_id,
|
||||||
(SELECT path FROM parent),
|
(SELECT path FROM parent),
|
||||||
@@ -363,12 +388,19 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
// If moving to a different folder, get the new user_id (must be same user)
|
// If moving to a different folder, get the new user_id (must be same user)
|
||||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.files
|
WITH dest AS (
|
||||||
SET folder_id = $1::uuid, updated_at = NOW()
|
SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
)
|
||||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
UPDATE storage.files f
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
SET folder_id = $1::uuid,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
user_id = COALESCE((SELECT user_id FROM dest), f.user_id),
|
||||||
|
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = COALESCE((SELECT user_id FROM dest), f.user_id)
|
||||||
|
WHERE f.id = $2::uuid AND NOT f.is_trashed
|
||||||
|
RETURNING f.id::text, f.name, f.folder_id::text, f.size, f.mime_type,
|
||||||
|
EXTRACT(EPOCH FROM f.created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM f.updated_at)::bigint
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&target_folder_id)
|
.bind(&target_folder_id)
|
||||||
@@ -424,16 +456,33 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
FROM storage.files
|
FROM storage.files
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
WHERE id = $1::uuid AND NOT is_trashed
|
||||||
),
|
),
|
||||||
|
-- The destination folder may differ from the source's
|
||||||
|
-- folder (when $2 is set); derive drive_id from the
|
||||||
|
-- DESTINATION so cross-drive copies land in the right
|
||||||
|
-- drive. Files in personal drives only copy within the
|
||||||
|
-- same drive today, but the join makes the migration
|
||||||
|
-- future-proof for D2's cross-drive copy story.
|
||||||
|
dest_folder AS (
|
||||||
|
SELECT id, user_id, drive_id
|
||||||
|
FROM storage.folders
|
||||||
|
WHERE id = COALESCE($2::uuid,
|
||||||
|
(SELECT folder_id FROM src))
|
||||||
|
),
|
||||||
new_file AS (
|
new_file AS (
|
||||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
INSERT INTO storage.files
|
||||||
SELECT COALESCE($3::text, name),
|
(name, folder_id, user_id, drive_id, blob_hash, size,
|
||||||
COALESCE($2::uuid, folder_id),
|
mime_type, category_order, created_by, updated_by)
|
||||||
user_id,
|
SELECT COALESCE($3::text, src.name),
|
||||||
blob_hash,
|
dest_folder.id,
|
||||||
size,
|
dest_folder.user_id,
|
||||||
mime_type,
|
dest_folder.drive_id,
|
||||||
category_order
|
src.blob_hash,
|
||||||
FROM src
|
src.size,
|
||||||
|
src.mime_type,
|
||||||
|
src.category_order,
|
||||||
|
dest_folder.user_id,
|
||||||
|
dest_folder.user_id
|
||||||
|
FROM src, dest_folder
|
||||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||||
@@ -497,7 +546,7 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.files
|
UPDATE storage.files
|
||||||
SET name = $1, updated_at = NOW()
|
SET name = $1, updated_at = NOW(), updated_by = user_id
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
WHERE id = $2::uuid AND NOT is_trashed
|
||||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
@@ -580,7 +629,7 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
content_type: String,
|
content_type: String,
|
||||||
size: u64,
|
size: u64,
|
||||||
) -> Result<(File, PathBuf), DomainError> {
|
) -> Result<(File, PathBuf), DomainError> {
|
||||||
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
|
let (user_id, drive_id) = self.resolve_owner_and_drive(folder_id.as_deref()).await?;
|
||||||
|
|
||||||
// For deferred registration we use a placeholder hash.
|
// For deferred registration we use a placeholder hash.
|
||||||
// The write-behind cache will call update_file_content later.
|
// The write-behind cache will call update_file_content later.
|
||||||
@@ -589,8 +638,10 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
let row = retry_on_deadlock("files.insert_deferred", || {
|
let row = retry_on_deadlock("files.insert_deferred", || {
|
||||||
sqlx::query_as::<_, (String, i64, i64)>(
|
sqlx::query_as::<_, (String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
INSERT INTO storage.files
|
||||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
(name, folder_id, user_id, drive_id, blob_hash, size,
|
||||||
|
mime_type, category_order, created_by, updated_by)
|
||||||
|
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $3, $3)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
@@ -599,6 +650,7 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(&folder_id)
|
.bind(&folder_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(drive_id)
|
||||||
.bind(placeholder_hash)
|
.bind(placeholder_hash)
|
||||||
.bind(size as i64)
|
.bind(size as i64)
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
@@ -638,7 +690,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
SET is_trashed = TRUE,
|
SET is_trashed = TRUE,
|
||||||
trashed_at = NOW(),
|
trashed_at = NOW(),
|
||||||
original_folder_id = folder_id,
|
original_folder_id = folder_id,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = user_id
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
WHERE id = $1::uuid AND NOT is_trashed
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -665,7 +718,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
trashed_at = NULL,
|
trashed_at = NULL,
|
||||||
folder_id = COALESCE(original_folder_id, folder_id),
|
folder_id = COALESCE(original_folder_id, folder_id),
|
||||||
original_folder_id = NULL,
|
original_folder_id = NULL,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = user_id
|
||||||
WHERE id = $1::uuid AND is_trashed
|
WHERE id = $1::uuid AND is_trashed
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -148,17 +148,18 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
name: String,
|
name: String,
|
||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
) -> Result<Folder, DomainError> {
|
) -> Result<Folder, DomainError> {
|
||||||
// Derive user_id from parent folder. Root-level folders require the
|
// Derive (user_id, drive_id) from parent folder in one round-trip.
|
||||||
// caller to have set up the home folder beforehand (done during user
|
// Root-level folders require the caller to have set up the home
|
||||||
// registration).
|
// drive beforehand (done during user registration via the
|
||||||
let user_id: Uuid = if let Some(ref pid) = parent_id {
|
// lifecycle hook).
|
||||||
sqlx::query_scalar::<_, Uuid>("SELECT user_id FROM storage.folders WHERE id = $1::uuid")
|
let (user_id, drive_id): (Uuid, Uuid) = if let Some(ref pid) = parent_id {
|
||||||
|
sqlx::query_as::<_, (Uuid, Uuid)>(
|
||||||
|
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
|
||||||
|
)
|
||||||
.bind(pid)
|
.bind(pid)
|
||||||
.fetch_optional(self.pool())
|
.fetch_optional(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))?
|
||||||
DomainError::internal_error("FolderDb", format!("parent lookup: {e}"))
|
|
||||||
})?
|
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", pid))?
|
.ok_or_else(|| DomainError::not_found("Folder", pid))?
|
||||||
} else {
|
} else {
|
||||||
return Err(DomainError::internal_error(
|
return Err(DomainError::internal_error(
|
||||||
@@ -167,10 +168,17 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// D0 dual-write: drive_id alongside user_id (drops in D7), plus
|
||||||
|
// provenance columns created_by/updated_by. The repo derives
|
||||||
|
// created_by from user_id because the parent's owner is the
|
||||||
|
// creator on personal drives (the only kind that exists in D0).
|
||||||
|
// D2 plumbs the real caller_id when shared drives let other
|
||||||
|
// members write into a drive they don't own.
|
||||||
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
INSERT INTO storage.folders
|
||||||
VALUES ($1, $2::uuid, $3)
|
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||||
|
VALUES ($1, $2::uuid, $3, $4, $3, $3)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
path,
|
path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
@@ -181,6 +189,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(&parent_id)
|
.bind(&parent_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(drive_id)
|
||||||
.fetch_one(self.pool())
|
.fetch_one(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -489,7 +498,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
sqlx::query_as::<_, FolderRow>(
|
sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
UPDATE storage.folders
|
||||||
SET name = $1, updated_at = NOW()
|
SET name = $1, updated_at = NOW(), updated_by = user_id
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
WHERE id = $2::uuid AND NOT is_trashed
|
||||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
@@ -528,7 +537,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
sqlx::query_as::<_, FolderRow>(
|
sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
UPDATE storage.folders
|
||||||
SET parent_id = $1::uuid, updated_at = NOW()
|
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = user_id
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
WHERE id = $2::uuid AND NOT is_trashed
|
||||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
@@ -634,7 +643,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
SET is_trashed = TRUE,
|
SET is_trashed = TRUE,
|
||||||
trashed_at = NOW(),
|
trashed_at = NOW(),
|
||||||
original_parent_id = parent_id,
|
original_parent_id = parent_id,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = user_id
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
WHERE id = $1::uuid AND NOT is_trashed
|
||||||
RETURNING id, lpath
|
RETURNING id, lpath
|
||||||
),
|
),
|
||||||
@@ -642,7 +652,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
UPDATE storage.folders f
|
UPDATE storage.folders f
|
||||||
SET is_trashed = TRUE,
|
SET is_trashed = TRUE,
|
||||||
trashed_at = NOW(),
|
trashed_at = NOW(),
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = f.user_id
|
||||||
FROM trash_root tr
|
FROM trash_root tr
|
||||||
WHERE f.lpath <@ tr.lpath
|
WHERE f.lpath <@ tr.lpath
|
||||||
AND f.id != tr.id
|
AND f.id != tr.id
|
||||||
@@ -653,7 +664,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
UPDATE storage.files fi
|
UPDATE storage.files fi
|
||||||
SET is_trashed = TRUE,
|
SET is_trashed = TRUE,
|
||||||
trashed_at = NOW(),
|
trashed_at = NOW(),
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = fi.user_id
|
||||||
FROM trash_root tr
|
FROM trash_root tr
|
||||||
JOIN storage.folders f ON f.lpath <@ tr.lpath
|
JOIN storage.folders f ON f.lpath <@ tr.lpath
|
||||||
WHERE fi.folder_id = f.id
|
WHERE fi.folder_id = f.id
|
||||||
@@ -698,7 +710,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
trashed_at = NULL,
|
trashed_at = NULL,
|
||||||
parent_id = COALESCE(original_parent_id, parent_id),
|
parent_id = COALESCE(original_parent_id, parent_id),
|
||||||
original_parent_id = NULL,
|
original_parent_id = NULL,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = user_id
|
||||||
WHERE id = $1::uuid AND is_trashed
|
WHERE id = $1::uuid AND is_trashed
|
||||||
RETURNING id, lpath
|
RETURNING id, lpath
|
||||||
),
|
),
|
||||||
@@ -706,7 +719,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
UPDATE storage.folders f
|
UPDATE storage.folders f
|
||||||
SET is_trashed = FALSE,
|
SET is_trashed = FALSE,
|
||||||
trashed_at = NULL,
|
trashed_at = NULL,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = f.user_id
|
||||||
FROM restore_root rr
|
FROM restore_root rr
|
||||||
WHERE f.lpath <@ rr.lpath
|
WHERE f.lpath <@ rr.lpath
|
||||||
AND f.id != rr.id
|
AND f.id != rr.id
|
||||||
@@ -718,7 +732,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
UPDATE storage.files fi
|
UPDATE storage.files fi
|
||||||
SET is_trashed = FALSE,
|
SET is_trashed = FALSE,
|
||||||
trashed_at = NULL,
|
trashed_at = NULL,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
updated_by = fi.user_id
|
||||||
FROM restore_root rr
|
FROM restore_root rr
|
||||||
JOIN storage.folders f ON f.lpath <@ rr.lpath
|
JOIN storage.folders f ON f.lpath <@ rr.lpath
|
||||||
WHERE fi.folder_id = f.id
|
WHERE fi.folder_id = f.id
|
||||||
@@ -775,11 +790,24 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError> {
|
async fn create_home_folder(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
drive_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
) -> Result<Folder, DomainError> {
|
||||||
|
// D0-9 keeps the wrapper-folder convention through the dual-write
|
||||||
|
// window: the lifecycle hook creates the personal drive AND a
|
||||||
|
// root folder under it. Wrapper retirement (the `My Folder -
|
||||||
|
// <username>/` prefix and the wrapper row itself) lands in M2b
|
||||||
|
// alongside the path rewrite. drive_id is required (M3 NOT NULL);
|
||||||
|
// created_by/updated_by are stamped from user_id for D0
|
||||||
|
// provenance.
|
||||||
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
INSERT INTO storage.folders
|
||||||
VALUES ($1, NULL, $2)
|
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||||
|
VALUES ($1, NULL, $2, $3, $2, $2)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
path,
|
path,
|
||||||
@@ -790,6 +818,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
)
|
)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(drive_id)
|
||||||
.fetch_optional(self.pool())
|
.fetch_optional(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod contact_group_pg_repository;
|
|||||||
mod contact_persistence_dto;
|
mod contact_persistence_dto;
|
||||||
mod contact_pg_repository;
|
mod contact_pg_repository;
|
||||||
mod device_code_pg_repository;
|
mod device_code_pg_repository;
|
||||||
|
mod drive_pg_repository;
|
||||||
mod face_pg_repository;
|
mod face_pg_repository;
|
||||||
mod favorites_pg_repository;
|
mod favorites_pg_repository;
|
||||||
pub mod file_metadata_repository;
|
pub mod file_metadata_repository;
|
||||||
@@ -34,6 +35,7 @@ pub use contact_group_pg_repository::ContactGroupPgRepository;
|
|||||||
pub use contact_persistence_dto::*;
|
pub use contact_persistence_dto::*;
|
||||||
pub use contact_pg_repository::ContactPgRepository;
|
pub use contact_pg_repository::ContactPgRepository;
|
||||||
pub use device_code_pg_repository::DeviceCodePgRepository;
|
pub use device_code_pg_repository::DeviceCodePgRepository;
|
||||||
|
pub use drive_pg_repository::DrivePgRepository;
|
||||||
pub use face_pg_repository::FacePgRepository;
|
pub use face_pg_repository::FacePgRepository;
|
||||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||||
|
|||||||
@@ -2776,12 +2776,22 @@ mod rechunk_integration_tests {
|
|||||||
Arc::new(pool)
|
Arc::new(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn seed_user(pool: &PgPool) -> Uuid {
|
/// Returns `(user_id, drive_id)`. Post-D0 every internal user has a
|
||||||
sqlx::query("SELECT id FROM auth.users LIMIT 1")
|
/// default Personal drive (provisioned by `PersonalDriveLifecycleHook`
|
||||||
|
/// during init-test-schema.sh's user seeding); the JOIN below picks
|
||||||
|
/// the user-drive pair atomically so test fixtures can insert into
|
||||||
|
/// `storage.files` with both `user_id` and `drive_id` populated.
|
||||||
|
async fn seed_user(pool: &PgPool) -> (Uuid, Uuid) {
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT u.id AS user_id, d.id AS drive_id
|
||||||
|
FROM auth.users u
|
||||||
|
JOIN storage.drives d ON d.default_for_user = u.id
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.map(|r| r.get::<Uuid, _>("id"))
|
.map(|r| (r.get::<Uuid, _>("user_id"), r.get::<Uuid, _>("drive_id")))
|
||||||
.expect("auth.users must be seeded (init-test-schema.sh)")
|
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plain local backend in a fresh temp dir.
|
/// Plain local backend in a fresh temp dir.
|
||||||
@@ -2848,7 +2858,7 @@ mod rechunk_integration_tests {
|
|||||||
.await
|
.await
|
||||||
.expect("insert legacy blob row");
|
.expect("insert legacy blob row");
|
||||||
|
|
||||||
let user_id = seed_user(pool).await;
|
let (user_id, drive_id) = seed_user(pool).await;
|
||||||
let mut file_ids = Vec::new();
|
let mut file_ids = Vec::new();
|
||||||
for i in 0..n_files {
|
for i in 0..n_files {
|
||||||
let name = format!(
|
let name = format!(
|
||||||
@@ -2856,11 +2866,12 @@ mod rechunk_integration_tests {
|
|||||||
&Uuid::new_v4().to_string()[..8]
|
&Uuid::new_v4().to_string()[..8]
|
||||||
);
|
);
|
||||||
let id: Uuid = sqlx::query_scalar(
|
let id: Uuid = sqlx::query_scalar(
|
||||||
"INSERT INTO storage.files (name, user_id, blob_hash, size)
|
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
|
||||||
VALUES ($1, $2, $3, $4) RETURNING id",
|
VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(drive_id)
|
||||||
.bind(&hash)
|
.bind(&hash)
|
||||||
.bind(data.len() as i64)
|
.bind(data.len() as i64)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
@@ -3121,12 +3132,20 @@ mod delta_upload_integration_tests {
|
|||||||
Arc::new(pool)
|
Arc::new(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn seed_user(pool: &PgPool) -> Uuid {
|
/// Returns `(user_id, drive_id)` — same shape as the rechunk tests'
|
||||||
sqlx::query("SELECT id FROM auth.users LIMIT 1")
|
/// `seed_user`. Post-D0 every internal user has a default Personal
|
||||||
|
/// drive provisioned by `PersonalDriveLifecycleHook`.
|
||||||
|
async fn seed_user(pool: &PgPool) -> (Uuid, Uuid) {
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT u.id AS user_id, d.id AS drive_id
|
||||||
|
FROM auth.users u
|
||||||
|
JOIN storage.drives d ON d.default_for_user = u.id
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.map(|r| r.get::<Uuid, _>("id"))
|
.map(|r| (r.get::<Uuid, _>("user_id"), r.get::<Uuid, _>("drive_id")))
|
||||||
.expect("auth.users must be seeded (init-test-schema.sh)")
|
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn local_svc(pool: &Arc<PgPool>, dir: &TempDir) -> DedupService {
|
async fn local_svc(pool: &Arc<PgPool>, dir: &TempDir) -> DedupService {
|
||||||
@@ -3141,6 +3160,7 @@ mod delta_upload_integration_tests {
|
|||||||
svc: &DedupService,
|
svc: &DedupService,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
drive_id: Uuid,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> (String, Vec<String>, Uuid) {
|
) -> (String, Vec<String>, Uuid) {
|
||||||
@@ -3159,14 +3179,15 @@ mod delta_upload_integration_tests {
|
|||||||
.expect("chunks");
|
.expect("chunks");
|
||||||
|
|
||||||
let file_id: Uuid = sqlx::query_scalar(
|
let file_id: Uuid = sqlx::query_scalar(
|
||||||
"INSERT INTO storage.files (name, user_id, blob_hash, size)
|
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
|
||||||
VALUES ($1, $2, $3, $4) RETURNING id",
|
VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(format!(
|
.bind(format!(
|
||||||
"rust-test-delta-{label}-{}",
|
"rust-test-delta-{label}-{}",
|
||||||
&Uuid::new_v4().to_string()[..8]
|
&Uuid::new_v4().to_string()[..8]
|
||||||
))
|
))
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(drive_id)
|
||||||
.bind(&file_hash)
|
.bind(&file_hash)
|
||||||
.bind(data.len() as i64)
|
.bind(data.len() as i64)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
@@ -3226,13 +3247,13 @@ mod delta_upload_integration_tests {
|
|||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let svc = local_svc(&pool, &dir).await;
|
let svc = local_svc(&pool, &dir).await;
|
||||||
let user = seed_user(&pool).await;
|
let (user, drive_id) = seed_user(&pool).await;
|
||||||
|
|
||||||
// Owned content (multi-chunk), one foreign chunk (ref 1, no file
|
// Owned content (multi-chunk), one foreign chunk (ref 1, no file
|
||||||
// row for this user), one orphan (ref 0), one unknown hash.
|
// row for this user), one orphan (ref 0), one unknown hash.
|
||||||
let data = content(3 * 1024 * 1024, 21);
|
let data = content(3 * 1024 * 1024, 21);
|
||||||
let (file_hash, owned_chunks, file_id) =
|
let (file_hash, owned_chunks, file_id) =
|
||||||
seed_owned_content(&svc, &pool, user, &data, "claim").await;
|
seed_owned_content(&svc, &pool, user, drive_id, &data, "claim").await;
|
||||||
assert!(owned_chunks.len() >= 3, "3 MiB must split into ≥3 chunks");
|
assert!(owned_chunks.len() >= 3, "3 MiB must split into ≥3 chunks");
|
||||||
|
|
||||||
let foreign = blake3::hash(format!("foreign-{}", Uuid::new_v4()).as_bytes())
|
let foreign = blake3::hash(format!("foreign-{}", Uuid::new_v4()).as_bytes())
|
||||||
@@ -3316,12 +3337,12 @@ mod delta_upload_integration_tests {
|
|||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let svc = local_svc(&pool, &dir).await;
|
let svc = local_svc(&pool, &dir).await;
|
||||||
let user = seed_user(&pool).await;
|
let (user, drive_id) = seed_user(&pool).await;
|
||||||
|
|
||||||
// An owned chunk that the client redundantly re-uploads.
|
// An owned chunk that the client redundantly re-uploads.
|
||||||
let data = content(100 * 1024, 22);
|
let data = content(100 * 1024, 22);
|
||||||
let (file_hash, owned_chunks, file_id) =
|
let (file_hash, owned_chunks, file_id) =
|
||||||
seed_owned_content(&svc, &pool, user, &data, "loose").await;
|
seed_owned_content(&svc, &pool, user, drive_id, &data, "loose").await;
|
||||||
let owned_chunk_bytes = {
|
let owned_chunk_bytes = {
|
||||||
let mut stream = svc.read_blob_stream(&file_hash).await.expect("stream");
|
let mut stream = svc.read_blob_stream(&file_hash).await.expect("stream");
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -3540,11 +3561,11 @@ mod delta_upload_integration_tests {
|
|||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let svc = local_svc(&pool, &dir).await;
|
let svc = local_svc(&pool, &dir).await;
|
||||||
let user = seed_user(&pool).await;
|
let (user, drive_id) = seed_user(&pool).await;
|
||||||
|
|
||||||
let data = content(2 * 1024 * 1024 + 137, 24);
|
let data = content(2 * 1024 * 1024 + 137, 24);
|
||||||
let (file_hash, _chunks, file_id) =
|
let (file_hash, _chunks, file_id) =
|
||||||
seed_owned_content(&svc, &pool, user, &data, "verify").await;
|
seed_owned_content(&svc, &pool, user, drive_id, &data, "verify").await;
|
||||||
|
|
||||||
let manifest: (Vec<String>, Vec<i64>) = sqlx::query_as(
|
let manifest: (Vec<String>, Vec<i64>) = sqlx::query_as(
|
||||||
"SELECT chunk_hashes, chunk_sizes FROM storage.chunk_manifests WHERE file_hash = $1",
|
"SELECT chunk_hashes, chunk_sizes FROM storage.chunk_manifests WHERE file_hash = $1",
|
||||||
|
|||||||
@@ -230,11 +230,32 @@ impl PgAclEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public wrapper around `subject_match_set` for callers that need
|
||||||
|
/// the expanded `(subject_types, subject_ids)` pair without invoking
|
||||||
|
/// the engine's full `check`/`require` pipeline. Used by
|
||||||
|
/// `GET /api/drives` (and future drive-aware listing surfaces) to
|
||||||
|
/// ask the `DriveRepository` for every drive the caller can read,
|
||||||
|
/// reusing the engine's cached group-expansion logic.
|
||||||
|
pub async fn expand_subject_for_listing(
|
||||||
|
&self,
|
||||||
|
subject: Subject,
|
||||||
|
) -> Result<(Vec<&'static str>, Vec<Uuid>), DomainError> {
|
||||||
|
let counters = QueryCounters::default();
|
||||||
|
self.subject_match_set(subject, &counters).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the owner UUID for any resource type.
|
/// Returns the owner UUID for any resource type.
|
||||||
async fn owner_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
|
async fn owner_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
|
||||||
match resource {
|
match resource {
|
||||||
Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await,
|
Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await,
|
||||||
Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await,
|
Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await,
|
||||||
|
// Drive owner resolution wires up in D0-6 once `DriveRepository`
|
||||||
|
// lands (D0-5). Drive entity carries `default_for_user` for
|
||||||
|
// `kind='personal'`; shared drives resolve through role_grants
|
||||||
|
// (Owner role). Returning NotFound here means a permission
|
||||||
|
// check that reached owner_of on a Drive falls through to the
|
||||||
|
// grant-lookup path — safe default during D0-1.
|
||||||
|
Resource::Drive(_) => Err(DomainError::not_found("Drive", resource.id().to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +381,43 @@ impl PgAclEngine {
|
|||||||
Ok(exists.is_some())
|
Ok(exists.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Direct grant lookup for a drive — no ltree cascade (drives have
|
||||||
|
/// no ancestors). Mirrors the cascade helpers above but with a
|
||||||
|
/// straight `resource_type='drive' AND resource_id=$4` filter.
|
||||||
|
async fn drive_grant_exists(
|
||||||
|
&self,
|
||||||
|
subject_types: &[&str],
|
||||||
|
subject_ids: &[Uuid],
|
||||||
|
permission: Permission,
|
||||||
|
drive_id: Uuid,
|
||||||
|
counters: &QueryCounters,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let roles = Self::roles_implying_strings(permission);
|
||||||
|
let exists: Option<i32> = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT 1
|
||||||
|
FROM storage.role_grants g
|
||||||
|
WHERE g.subject_type = ANY($1)
|
||||||
|
AND g.subject_id = ANY($2)
|
||||||
|
AND g.role = ANY($3::storage.grant_role[])
|
||||||
|
AND g.resource_type = 'drive'
|
||||||
|
AND g.resource_id = $4
|
||||||
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(subject_types)
|
||||||
|
.bind(subject_ids)
|
||||||
|
.bind(&roles)
|
||||||
|
.bind(drive_id)
|
||||||
|
.fetch_optional(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("PgAcl", format!("drive grant: {e}")))?;
|
||||||
|
|
||||||
|
Ok(exists.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up a single role grant by id, returning the actors a revoke /
|
/// Look up a single role grant by id, returning the actors a revoke /
|
||||||
/// notify handler needs to make a decision without a second round-trip.
|
/// notify handler needs to make a decision without a second round-trip.
|
||||||
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
|
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
|
||||||
@@ -459,7 +517,12 @@ impl PgAclEngine {
|
|||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
// Owner short-circuit (only for User subjects — groups/tokens/external
|
// Owner short-circuit (only for User subjects — groups/tokens/external
|
||||||
// are never owners of resources).
|
// are never owners of resources).
|
||||||
if let Subject::User(uid) = subject {
|
// Owner short-circuit applies to Folder/File only — they carry a
|
||||||
|
// single-owner `user_id` column in their respective tables. Drives
|
||||||
|
// model ownership through the `Owner` role in `role_grants`, so
|
||||||
|
// there's no analogous fast path: the grant lookup below resolves
|
||||||
|
// a drive owner via the same query that resolves any drive role.
|
||||||
|
if let (Subject::User(uid), Resource::Folder(_) | Resource::File(_)) = (subject, resource) {
|
||||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||||
match self.owner_of(resource).await {
|
match self.owner_of(resource).await {
|
||||||
Ok(owner) if owner == uid => return Ok(true),
|
Ok(owner) if owner == uid => return Ok(true),
|
||||||
@@ -500,6 +563,10 @@ impl PgAclEngine {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
Resource::Drive(id) => {
|
||||||
|
self.drive_grant_exists(&subject_types, &subject_ids, permission, id, counters)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,12 +242,13 @@ impl ContentIndexWorker {
|
|||||||
|
|
||||||
// Authoritative state re-read: a queued 'upsert' whose row vanished
|
// Authoritative state re-read: a queued 'upsert' whose row vanished
|
||||||
// or got trashed in the meantime becomes a delete.
|
// or got trashed in the meantime becomes a delete.
|
||||||
let files: Vec<(Uuid, String, String, String, String, i64)> =
|
let files: Vec<(Uuid, String, String, String, String, String, i64)> =
|
||||||
if upsert_candidates.is_empty() {
|
if upsert_candidates.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT fi.id, fi.user_id::text, fi.name, fi.blob_hash, fi.mime_type, fi.size
|
"SELECT fi.id, fi.user_id::text, fi.drive_id::text, fi.name,
|
||||||
|
fi.blob_hash, fi.mime_type, fi.size
|
||||||
FROM storage.files fi
|
FROM storage.files fi
|
||||||
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
|
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
|
||||||
)
|
)
|
||||||
@@ -261,10 +262,10 @@ impl ContentIndexWorker {
|
|||||||
// Per-blob text: batch-read the extraction cache, extract misses.
|
// Per-blob text: batch-read the extraction cache, extract misses.
|
||||||
let wanted_hashes: Vec<String> = files
|
let wanted_hashes: Vec<String> = files
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, _, name, _, mime, size)| {
|
.filter(|(_, _, _, name, _, mime, size)| {
|
||||||
text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes
|
text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes
|
||||||
})
|
})
|
||||||
.map(|f| f.3.clone())
|
.map(|f| f.4.clone())
|
||||||
.collect();
|
.collect();
|
||||||
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
|
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
|
||||||
if !wanted_hashes.is_empty() {
|
if !wanted_hashes.is_empty() {
|
||||||
@@ -281,7 +282,7 @@ impl ContentIndexWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut records = Vec::with_capacity(files.len());
|
let mut records = Vec::with_capacity(files.len());
|
||||||
for (file_id, user_id, name, blob_hash, mime, size) in files {
|
for (file_id, user_id, drive_id, name, blob_hash, mime, size) in files {
|
||||||
let supported = text_extractor::supports(&name, &mime);
|
let supported = text_extractor::supports(&name, &mime);
|
||||||
let content = if !supported {
|
let content = if !supported {
|
||||||
None
|
None
|
||||||
@@ -301,6 +302,7 @@ impl ContentIndexWorker {
|
|||||||
records.push(IndexDocRecord {
|
records.push(IndexDocRecord {
|
||||||
file_id: file_id.to_string(),
|
file_id: file_id.to_string(),
|
||||||
user_id,
|
user_id,
|
||||||
|
drive_id,
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
preview,
|
preview,
|
||||||
|
|||||||
@@ -37,7 +37,15 @@ use crate::common::errors::DomainError;
|
|||||||
/// Bump whenever the Tantivy schema OR the text extractor output changes in a
|
/// Bump whenever the Tantivy schema OR the text extractor output changes in a
|
||||||
/// way that requires re-indexing. A mismatch with the on-disk marker wipes the
|
/// way that requires re-indexing. A mismatch with the on-disk marker wipes the
|
||||||
/// index directory and reseeds the dirty queue with every live file.
|
/// index directory and reseeds the dirty queue with every live file.
|
||||||
pub const INDEX_SCHEMA_VERSION: &str = "1";
|
///
|
||||||
|
/// Version history:
|
||||||
|
/// 1 — initial schema (file_id, user_id, name, content, preview)
|
||||||
|
/// 2 — D0 added `drive_id` field; query filter pivots from user_id
|
||||||
|
/// to a `drive_id ∈ accessible_drives` set membership clause. On
|
||||||
|
/// deploy, every operator's index is wiped and reseeded against
|
||||||
|
/// the post-D0 schema (the worker drains the dirty queue with
|
||||||
|
/// drive_id-aware records).
|
||||||
|
pub const INDEX_SCHEMA_VERSION: &str = "2";
|
||||||
|
|
||||||
/// Recorded in `storage.blob_extracted_text.extractor`; rows from another
|
/// Recorded in `storage.blob_extracted_text.extractor`; rows from another
|
||||||
/// version are dropped at worker startup (the reseed re-extracts them).
|
/// version are dropped at worker startup (the reseed re-extracts them).
|
||||||
@@ -73,6 +81,11 @@ const PREFIX_MIN_CHARS: usize = 3;
|
|||||||
pub struct IndexDocRecord {
|
pub struct IndexDocRecord {
|
||||||
pub file_id: String,
|
pub file_id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
/// Owning drive — written verbatim into the `drive_id` STRING field
|
||||||
|
/// for set-membership filtering at query time. The user_id field is
|
||||||
|
/// kept during the D0 dual-write window for rollback safety; the
|
||||||
|
/// query filter no longer reads it.
|
||||||
|
pub drive_id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub preview: Option<String>,
|
pub preview: Option<String>,
|
||||||
@@ -82,6 +95,7 @@ pub struct IndexDocRecord {
|
|||||||
struct IndexFields {
|
struct IndexFields {
|
||||||
file_id: Field,
|
file_id: Field,
|
||||||
user_id: Field,
|
user_id: Field,
|
||||||
|
drive_id: Field,
|
||||||
name: Field,
|
name: Field,
|
||||||
content: Field,
|
content: Field,
|
||||||
preview: Field,
|
preview: Field,
|
||||||
@@ -105,6 +119,7 @@ impl TantivyContentIndex {
|
|||||||
let fields = IndexFields {
|
let fields = IndexFields {
|
||||||
file_id: builder.add_text_field("file_id", STRING | STORED),
|
file_id: builder.add_text_field("file_id", STRING | STORED),
|
||||||
user_id: builder.add_text_field("user_id", STRING),
|
user_id: builder.add_text_field("user_id", STRING),
|
||||||
|
drive_id: builder.add_text_field("drive_id", STRING),
|
||||||
name: builder.add_text_field("name", TEXT),
|
name: builder.add_text_field("name", TEXT),
|
||||||
content: builder.add_text_field("content", TEXT),
|
content: builder.add_text_field("content", TEXT),
|
||||||
preview: builder.add_text_field("preview", STORED),
|
preview: builder.add_text_field("preview", STORED),
|
||||||
@@ -197,6 +212,7 @@ impl TantivyContentIndex {
|
|||||||
let mut document = doc!(
|
let mut document = doc!(
|
||||||
self.fields.file_id => record.file_id,
|
self.fields.file_id => record.file_id,
|
||||||
self.fields.user_id => record.user_id,
|
self.fields.user_id => record.user_id,
|
||||||
|
self.fields.drive_id => record.drive_id,
|
||||||
self.fields.name => record.name,
|
self.fields.name => record.name,
|
||||||
);
|
);
|
||||||
if let Some(content) = record.content {
|
if let Some(content) = record.content {
|
||||||
@@ -234,15 +250,26 @@ impl TantivyContentIndex {
|
|||||||
|
|
||||||
/// Build the scored query: every token must match (in name OR content,
|
/// Build the scored query: every token must match (in name OR content,
|
||||||
/// exact OR fuzzy OR — for the last token — prefix), and the whole thing
|
/// exact OR fuzzy OR — for the last token — prefix), and the whole thing
|
||||||
/// is `Must`-scoped to the user.
|
/// is `Must`-scoped to the caller's accessible drives.
|
||||||
fn build_query(fields: IndexFields, user_id: &str, tokens: &[String]) -> Box<dyn Query> {
|
///
|
||||||
let mut clauses: Vec<(Occur, Box<dyn Query>)> = vec![(
|
/// The drive filter is expressed as a BoolQuery with `Should` arms —
|
||||||
Occur::Must,
|
/// at least one drive_id must match — wrapped under an outer `Must`.
|
||||||
Box::new(TermQuery::new(
|
/// Equivalent to a TermSetQuery; this form avoids the API churn of
|
||||||
Term::from_field_text(fields.user_id, user_id),
|
/// rebuilding the same shape across Tantivy versions.
|
||||||
|
fn build_query(fields: IndexFields, drive_ids: &[String], tokens: &[String]) -> Box<dyn Query> {
|
||||||
|
// Drive-membership Must clause: union of Term(drive_id = $each).
|
||||||
|
let drive_alternatives: Vec<(Occur, Box<dyn Query>)> = drive_ids
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let q: Box<dyn Query> = Box::new(TermQuery::new(
|
||||||
|
Term::from_field_text(fields.drive_id, d),
|
||||||
IndexRecordOption::Basic,
|
IndexRecordOption::Basic,
|
||||||
)),
|
));
|
||||||
)];
|
(Occur::Should, q)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut clauses: Vec<(Occur, Box<dyn Query>)> =
|
||||||
|
vec![(Occur::Must, Box::new(BooleanQuery::new(drive_alternatives)))];
|
||||||
|
|
||||||
let last = tokens.len().saturating_sub(1);
|
let last = tokens.len().saturating_sub(1);
|
||||||
for (i, token) in tokens.iter().enumerate() {
|
for (i, token) in tokens.iter().enumerate() {
|
||||||
@@ -306,7 +333,7 @@ impl TantivyContentIndex {
|
|||||||
searcher: tantivy::Searcher,
|
searcher: tantivy::Searcher,
|
||||||
analyzer: TextAnalyzer,
|
analyzer: TextAnalyzer,
|
||||||
fields: IndexFields,
|
fields: IndexFields,
|
||||||
user_id: &str,
|
drive_ids: &[String],
|
||||||
raw_query: &str,
|
raw_query: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ContentHitDto>, DomainError> {
|
) -> Result<Vec<ContentHitDto>, DomainError> {
|
||||||
@@ -315,7 +342,7 @@ impl TantivyContentIndex {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = Self::build_query(fields, user_id, &tokens);
|
let query = Self::build_query(fields, drive_ids, &tokens);
|
||||||
let top_docs = searcher
|
let top_docs = searcher
|
||||||
.search(&query, &TopDocs::with_limit(limit.max(1)).order_by_score())
|
.search(&query, &TopDocs::with_limit(limit.max(1)).order_by_score())
|
||||||
.map_err(|e| DomainError::internal_error("ContentIndex", format!("search: {e}")))?;
|
.map_err(|e| DomainError::internal_error("ContentIndex", format!("search: {e}")))?;
|
||||||
@@ -365,18 +392,25 @@ impl TantivyContentIndex {
|
|||||||
impl ContentIndexPort for TantivyContentIndex {
|
impl ContentIndexPort for TantivyContentIndex {
|
||||||
async fn search_content(
|
async fn search_content(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
accessible_drive_ids: &[Uuid],
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ContentHitDto>, DomainError> {
|
) -> Result<Vec<ContentHitDto>, DomainError> {
|
||||||
|
// No accessible drives → no hits, no Tantivy work. Matches the
|
||||||
|
// anti-enumeration semantics (empty filter set returns empty
|
||||||
|
// results without any side channel).
|
||||||
|
if accessible_drive_ids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
let searcher = self.reader.searcher();
|
let searcher = self.reader.searcher();
|
||||||
let analyzer = self.analyzer.clone();
|
let analyzer = self.analyzer.clone();
|
||||||
let fields = self.fields;
|
let fields = self.fields;
|
||||||
let user_id = user_id.to_string();
|
let drive_ids: Vec<String> = accessible_drive_ids.iter().map(|d| d.to_string()).collect();
|
||||||
let query = query.to_owned();
|
let query = query.to_owned();
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
Self::search_blocking(searcher, analyzer, fields, &user_id, &query, limit)
|
Self::search_blocking(searcher, analyzer, fields, &drive_ids, &query, limit)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("ContentIndex", format!("join: {e}")))?
|
.map_err(|e| DomainError::internal_error("ContentIndex", format!("join: {e}")))?
|
||||||
@@ -391,6 +425,10 @@ mod tests {
|
|||||||
IndexDocRecord {
|
IndexDocRecord {
|
||||||
file_id: file_id.to_owned(),
|
file_id: file_id.to_owned(),
|
||||||
user_id: user_id.to_owned(),
|
user_id: user_id.to_owned(),
|
||||||
|
// Tests stamp a placeholder drive_id derived from user_id so the
|
||||||
|
// record satisfies the post-D0 schema. Query-side filtering by
|
||||||
|
// drive_id is exercised in D0-12's integration tests, not here.
|
||||||
|
drive_id: format!("{user_id}-drive"),
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
content: content.map(str::to_owned),
|
content: content.map(str::to_owned),
|
||||||
preview: content.map(str::to_owned),
|
preview: content.map(str::to_owned),
|
||||||
@@ -401,11 +439,16 @@ mod tests {
|
|||||||
// Force a reader reload — OnCommitWithDelay is asynchronous and tests
|
// Force a reader reload — OnCommitWithDelay is asynchronous and tests
|
||||||
// must observe the commit immediately.
|
// must observe the commit immediately.
|
||||||
index.reader.reload().unwrap();
|
index.reader.reload().unwrap();
|
||||||
|
// Test records derive `drive_id = format!("{user_id}-drive")` —
|
||||||
|
// the same convention used by `record()`. Filtering by that
|
||||||
|
// single drive id exercises the same path the production
|
||||||
|
// search uses.
|
||||||
|
let drive_ids = vec![format!("{user_id}-drive")];
|
||||||
TantivyContentIndex::search_blocking(
|
TantivyContentIndex::search_blocking(
|
||||||
index.reader.searcher(),
|
index.reader.searcher(),
|
||||||
index.analyzer.clone(),
|
index.analyzer.clone(),
|
||||||
index.fields,
|
index.fields,
|
||||||
user_id,
|
&drive_ids,
|
||||||
query,
|
query,
|
||||||
32,
|
32,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -113,13 +113,15 @@ impl TreeEtagFlushService {
|
|||||||
FROM storage.tree_etag_dirty
|
FROM storage.tree_etag_dirty
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT $1)
|
LIMIT $1)
|
||||||
RETURNING lpath, folder_id
|
RETURNING lpath, folder_id, drive_id
|
||||||
),
|
),
|
||||||
targets AS (
|
targets AS (
|
||||||
-- Captured chain: covers target folders deleted or
|
-- Captured chain: covers target folders deleted or
|
||||||
-- moved away since enqueue (the old location's
|
-- moved away since enqueue (the old location's
|
||||||
-- surviving ancestors still get their bump).
|
-- surviving ancestors still get their bump). drive_id
|
||||||
SELECT lpath FROM drained
|
-- comes along so the victims walk can enforce
|
||||||
|
-- cross-drive isolation (D0-13).
|
||||||
|
SELECT lpath, drive_id FROM drained
|
||||||
UNION
|
UNION
|
||||||
-- Flush-time resolution: a folder MOVED since
|
-- Flush-time resolution: a folder MOVED since
|
||||||
-- enqueue had its subtree's lpaths rewritten, so
|
-- enqueue had its subtree's lpaths rewritten, so
|
||||||
@@ -128,19 +130,29 @@ impl TreeEtagFlushService {
|
|||||||
-- this, a bump queued just before a move would be
|
-- this, a bump queued just before a move would be
|
||||||
-- silently lost and sync clients would never
|
-- silently lost and sync clients would never
|
||||||
-- discover the change.
|
-- discover the change.
|
||||||
SELECT fo.lpath
|
SELECT fo.lpath, fo.drive_id
|
||||||
FROM storage.folders fo
|
FROM storage.folders fo
|
||||||
JOIN drained d ON fo.id = d.folder_id
|
JOIN drained d ON fo.id = d.folder_id
|
||||||
),
|
),
|
||||||
victims AS (
|
victims AS (
|
||||||
-- `lpath @> target` = the target folder itself plus
|
-- `lpath @> target` = the target folder itself plus
|
||||||
-- every ancestor (GiST-indexed). Folder rows deleted
|
-- every ancestor (GiST-indexed). The `drive_id`
|
||||||
-- since enqueue simply don't match. Lock in id order
|
-- predicate prevents a numerically-overlapping
|
||||||
-- so overlapping closures cannot deadlock.
|
-- lpath in a SIBLING drive from spuriously matching
|
||||||
|
-- (D0-13). Rows from old queue entries (pre-M4) have
|
||||||
|
-- NULL `drive_id` — `IS NOT DISTINCT FROM` falls
|
||||||
|
-- back to pure lpath matching for those, preserving
|
||||||
|
-- the rollover semantics for any rows enqueued
|
||||||
|
-- between this migration committing and the
|
||||||
|
-- service restart.
|
||||||
SELECT f.id
|
SELECT f.id
|
||||||
FROM storage.folders f
|
FROM storage.folders f
|
||||||
WHERE EXISTS (SELECT 1 FROM targets t
|
WHERE EXISTS (
|
||||||
WHERE f.lpath @> t.lpath)
|
SELECT 1 FROM targets t
|
||||||
|
WHERE f.lpath @> t.lpath
|
||||||
|
AND (t.drive_id IS NULL
|
||||||
|
OR f.drive_id = t.drive_id)
|
||||||
|
)
|
||||||
ORDER BY f.id
|
ORDER BY f.id
|
||||||
FOR NO KEY UPDATE
|
FOR NO KEY UPDATE
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! `GET /api/drives` — list every drive the caller can read.
|
||||||
|
//!
|
||||||
|
//! D0 ships the read-only listing; D2 adds shared-drive membership
|
||||||
|
//! mutations (`POST/DELETE/PUT /api/drives/{id}/members`), D3 adds the
|
||||||
|
//! create-shared-drive flow, etc.
|
||||||
|
//!
|
||||||
|
//! The handler resolves the caller's expanded subject set through the
|
||||||
|
//! engine (so group-mediated drive grants surface — the foundation for
|
||||||
|
//! D2/D3) and asks the `DriveRepository` for every drive that set can
|
||||||
|
//! read. Authorization is purely the subject-expansion step: no
|
||||||
|
//! `require(...)` call here, because "your accessible drives" is a
|
||||||
|
//! listing query, not a permission decision on a specific drive.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::application::dtos::drive_dto::DriveDto;
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||||
|
use crate::domain::services::authorization::Subject;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/drives",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Drives the caller can read", body = Vec<DriveDto>),
|
||||||
|
(status = 500, description = "Internal server error"),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "drives"
|
||||||
|
)]
|
||||||
|
pub async fn list_drives(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let caller_id = auth_user.id;
|
||||||
|
|
||||||
|
// Expand the caller's `Subject::User` into the `(types, ids)` pair
|
||||||
|
// that includes every group the user transitively belongs to. The
|
||||||
|
// engine caches this expansion in its Moka cache; if the caller
|
||||||
|
// just ran a permission check, this is a hit.
|
||||||
|
let (subject_types, subject_ids) = match state
|
||||||
|
.authorization
|
||||||
|
.expand_subject_for_listing(Subject::User(caller_id))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
error!("list_drives: subject expansion failed: {e}");
|
||||||
|
return AppError::from(e).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state
|
||||||
|
.drive_repo
|
||||||
|
.list_for_subjects(&subject_types, &subject_ids)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(drives) => {
|
||||||
|
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
|
||||||
|
(StatusCode::OK, Json(dtos)).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("list_drives: repo lookup failed: {e}");
|
||||||
|
AppError::internal_error(format!("Failed to list drives: {e}")).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -719,6 +719,13 @@ pub async fn list_shared_with_me(
|
|||||||
summary.resource_id
|
summary.resource_id
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
// Drive grants don't appear in the file/folder "Shared with me"
|
||||||
|
// listing — they're surfaced through `GET /api/drives` (D0).
|
||||||
|
// Silently skipping here is the right behaviour: a drive grant
|
||||||
|
// discovered by `list_incoming_resources_paged` is not a stale
|
||||||
|
// grant, just a different resource type with a different
|
||||||
|
// listing surface.
|
||||||
|
ResourceKind::Drive => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,6 +960,10 @@ pub async fn list_my_shares(
|
|||||||
summary.resource_id
|
summary.resource_id
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
// Drive grants are surfaced via `GET /api/drives` (D0), not
|
||||||
|
// through the My Shares outgoing-resources surface. Silently
|
||||||
|
// skip — symmetric with the `list_shared_with_me` arm above.
|
||||||
|
ResourceKind::Drive => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod contacts_handler;
|
|||||||
pub mod dedup_handler;
|
pub mod dedup_handler;
|
||||||
pub mod delta_upload_handler;
|
pub mod delta_upload_handler;
|
||||||
pub mod device_auth_handler;
|
pub mod device_auth_handler;
|
||||||
|
pub mod drive_handler;
|
||||||
pub mod favorites_handler;
|
pub mod favorites_handler;
|
||||||
pub mod file_handler;
|
pub mod file_handler;
|
||||||
pub mod folder_handler;
|
pub mod folder_handler;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use utoipa::{Modify, OpenApi};
|
|||||||
use crate::application::dtos::contact_dto::{
|
use crate::application::dtos::contact_dto::{
|
||||||
AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto,
|
AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto,
|
||||||
};
|
};
|
||||||
|
use crate::application::dtos::drive_dto::{DriveDto, DriveKindDto};
|
||||||
use crate::application::dtos::favorites_dto::{
|
use crate::application::dtos::favorites_dto::{
|
||||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoritesResourceItemDto,
|
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoritesResourceItemDto,
|
||||||
};
|
};
|
||||||
@@ -165,6 +166,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
// Photos handler (free function)
|
// Photos handler (free function)
|
||||||
handlers::photos_handler::list_photos,
|
handlers::photos_handler::list_photos,
|
||||||
handlers::photos_handler::list_photos_geo,
|
handlers::photos_handler::list_photos_geo,
|
||||||
|
// Drive handler (free function)
|
||||||
|
handlers::drive_handler::list_drives,
|
||||||
// Batch handlers (free functions)
|
// Batch handlers (free functions)
|
||||||
handlers::batch_handler::move_files_batch,
|
handlers::batch_handler::move_files_batch,
|
||||||
handlers::batch_handler::copy_files_batch,
|
handlers::batch_handler::copy_files_batch,
|
||||||
@@ -359,6 +362,9 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
SharedWithMeDto,
|
SharedWithMeDto,
|
||||||
SharedWithMeItemDto,
|
SharedWithMeItemDto,
|
||||||
OutgoingResourceItemDto,
|
OutgoingResourceItemDto,
|
||||||
|
// Drive schemas
|
||||||
|
DriveDto,
|
||||||
|
DriveKindDto,
|
||||||
// Subject-group (ReBAC named groups) schemas
|
// Subject-group (ReBAC named groups) schemas
|
||||||
handlers::subject_group_handler::CreateGroupRequest,
|
handlers::subject_group_handler::CreateGroupRequest,
|
||||||
handlers::subject_group_handler::UpdateGroupRequest,
|
handlers::subject_group_handler::UpdateGroupRequest,
|
||||||
|
|||||||
@@ -440,6 +440,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
router = router.nest("/photos", photos_router);
|
router = router.nest("/photos", photos_router);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives — every drive the caller can read. D0 ships the read-only
|
||||||
|
// listing; D2 adds the membership API + shared-drive endpoints under
|
||||||
|
// `/api/drives/{id}/members`.
|
||||||
|
{
|
||||||
|
use crate::interfaces::api::handlers::drive_handler;
|
||||||
|
|
||||||
|
let drives_router = Router::new()
|
||||||
|
.route("/", get(drive_handler::list_drives))
|
||||||
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
|
router = router.nest("/drives", drives_router);
|
||||||
|
}
|
||||||
|
|
||||||
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
|
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
|
||||||
if app_state.people_service.is_some() {
|
if app_state.people_service.is_some() {
|
||||||
use crate::interfaces::api::handlers::people_handler;
|
use crate::interfaces::api::handlers::people_handler;
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — D0 drives foundation
|
||||||
|
# =============================================================
|
||||||
|
# Verifies the D0 server-side foundation lands end-to-end:
|
||||||
|
#
|
||||||
|
# 1. Every internal user gets exactly one default Personal drive
|
||||||
|
# (the M2 backfill + the on-login lifecycle hook).
|
||||||
|
# 2. `GET /api/drives` returns that drive with the right shape
|
||||||
|
# (kind='personal', default_for_user matches the caller).
|
||||||
|
# 3. New folder/file rows stamp `drive_id` (verified indirectly:
|
||||||
|
# uploads succeed against a NOT NULL drive_id column post-M3).
|
||||||
|
# 4. Cross-drive isolation in `/api/search` — user A's indexed
|
||||||
|
# content does NOT surface in user B's search (Tantivy
|
||||||
|
# Must-clause on drive_id + handler-side ReBAC re-check).
|
||||||
|
# 5. `created_by` / `updated_by` provenance — files surface a
|
||||||
|
# non-null `last_modified_by` (via the file metadata endpoint)
|
||||||
|
# proving the dual-write took effect.
|
||||||
|
#
|
||||||
|
# Self-contained: creates its own users + folders so it can run
|
||||||
|
# independently of other test files.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — admin login
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
admin_token: jsonpath "$.access_token"
|
||||||
|
admin_user_id: jsonpath "$.user.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Admin's GET /api/drives surfaces a default Personal drive
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
# Admin has at least one drive — the default Personal at index 0
|
||||||
|
# (DrivePgRepository orders default-first via `default_for_user IS NULL ASC`).
|
||||||
|
jsonpath "$" count >= 1
|
||||||
|
jsonpath "$[0].kind" == "personal"
|
||||||
|
jsonpath "$[0].default_for_user" == "{{admin_user_id}}"
|
||||||
|
jsonpath "$[0].name" == "Personal"
|
||||||
|
[Captures]
|
||||||
|
admin_drive_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — Create two fresh users (drv_alice, drv_bob) so the
|
||||||
|
# cross-drive isolation test below uses fixtures that
|
||||||
|
# don't collide with other test files.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "drv_alice", "password": "DrvAlicePassword1!", "email": "drv_alice@example.com", "role": "user" }
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
alice_user_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "drv_bob", "password": "DrvBobPassword1!", "email": "drv_bob@example.com", "role": "user" }
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
bob_user_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# Alice's first login fires `PersonalDriveLifecycleHook::on_user_login`
|
||||||
|
# (since `on_user_created` may have provisioned already; the hook is
|
||||||
|
# idempotent either way). After this her default drive exists.
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "drv_alice", "password": "DrvAlicePassword1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
alice_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "drv_bob", "password": "DrvBobPassword1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
bob_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Each non-admin user sees exactly their default drive.
|
||||||
|
# Confirms the lifecycle hook provisioned + drive listing
|
||||||
|
# is correctly scoped (no cross-user leak).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
# Exactly one drive: the default Personal.
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].kind" == "personal"
|
||||||
|
jsonpath "$[0].default_for_user" == "{{alice_user_id}}"
|
||||||
|
[Captures]
|
||||||
|
alice_drive_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{bob_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].kind" == "personal"
|
||||||
|
jsonpath "$[0].default_for_user" == "{{bob_user_id}}"
|
||||||
|
[Captures]
|
||||||
|
bob_drive_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
|
||||||
|
# Cross-user drive id distinctness — Alice's drive id ≠ Bob's drive id.
|
||||||
|
# Hurl can't assert via inter-capture; the search-isolation step below
|
||||||
|
# proves the same property functionally.
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Each user's home folder works end-to-end. The lifecycle
|
||||||
|
# hook creates the drive; folder creation under the home
|
||||||
|
# uses the drive's id (M3 NOT NULL on storage.folders.drive_id
|
||||||
|
# enforces this — any code path that doesn't set drive_id
|
||||||
|
# would error out here).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
alice_home_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "name": "drv-alice-folder", "parent_id": "{{alice_home_id}}" }
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
alice_subfolder_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — Upload a small file via the multipart path so its
|
||||||
|
# drive_id and created_by/updated_by columns get stamped
|
||||||
|
# by the file repository's dual-write.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{alice_subfolder_id}}
|
||||||
|
file: file,fixtures/hello.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
alice_file_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 — Cross-drive isolation in `/api/search`. Bob searches
|
||||||
|
# for a term that exists only in Alice's file. The
|
||||||
|
# response must be empty (no leak of either the existence
|
||||||
|
# or the snippet of Alice's content).
|
||||||
|
#
|
||||||
|
# The Tantivy worker may need a tick to drain the dirty
|
||||||
|
# queue + extract text before the term is indexed. In a
|
||||||
|
# synchronous test we tolerate either response shape
|
||||||
|
# (empty results vs. some results all of which are Bob's
|
||||||
|
# own files), as long as Alice's specific file_id is
|
||||||
|
# absent. The check is the file_id-absent assertion.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/search?query=hello
|
||||||
|
Authorization: Bearer {{bob_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
# Bob may have his own hits or none — what matters is that
|
||||||
|
# Alice's file_id never appears in his result set.
|
||||||
|
jsonpath "$.files[?(@.id=='{{alice_file_id}}')]" not exists
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 — Anti-enum cleanup: drop Alice's file + folder so the
|
||||||
|
# shared test storage doesn't accumulate cross-test state.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/folders/{{alice_subfolder_id}}
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/trash/empty
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
+36
-4
@@ -294,7 +294,15 @@ Authorization: Bearer {{dave_token}}
|
|||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 0
|
# Post-D0 every user carries an incoming Owner grant on their own
|
||||||
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
||||||
|
# assertion was "no grants at all" (count == 0); the post-D0
|
||||||
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
||||||
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
||||||
|
# when nothing matches, so a `count == 0` over a negative filter
|
||||||
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].resource.type" == "drive"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -305,7 +313,15 @@ Authorization: Bearer {{eve_token}}
|
|||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 0
|
# Post-D0 every user carries an incoming Owner grant on their own
|
||||||
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
||||||
|
# assertion was "no grants at all" (count == 0); the post-D0
|
||||||
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
||||||
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
||||||
|
# when nothing matches, so a `count == 0` over a negative filter
|
||||||
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].resource.type" == "drive"
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════
|
||||||
@@ -809,7 +825,15 @@ Authorization: Bearer {{adam_token}}
|
|||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 0
|
# Post-D0 every user carries an incoming Owner grant on their own
|
||||||
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
||||||
|
# assertion was "no grants at all" (count == 0); the post-D0
|
||||||
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
||||||
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
||||||
|
# when nothing matches, so a `count == 0` over a negative filter
|
||||||
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].resource.type" == "drive"
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════
|
||||||
@@ -1233,4 +1257,12 @@ Authorization: Bearer {{frank_token}}
|
|||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$" count == 0
|
# Post-D0 every user carries an incoming Owner grant on their own
|
||||||
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
||||||
|
# assertion was "no grants at all" (count == 0); the post-D0
|
||||||
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
||||||
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
||||||
|
# when nothing matches, so a `count == 0` over a negative filter
|
||||||
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].resource.type" == "drive"
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/subject_groups.hurl" \
|
"$API_DIR/subject_groups.hurl" \
|
||||||
"$API_DIR/groups_effective_members.hurl" \
|
"$API_DIR/groups_effective_members.hurl" \
|
||||||
"$API_DIR/grants_nested_groups.hurl" \
|
"$API_DIR/grants_nested_groups.hurl" \
|
||||||
|
"$API_DIR/drives_foundation.hurl" \
|
||||||
"$API_DIR/external_users.hurl" \
|
"$API_DIR/external_users.hurl" \
|
||||||
"$API_DIR/search_basic.hurl" \
|
"$API_DIR/search_basic.hurl" \
|
||||||
"$API_DIR/nc_second_user_setup.hurl" \
|
"$API_DIR/nc_second_user_setup.hurl" \
|
||||||
|
|||||||
@@ -42,4 +42,40 @@ psql -v ON_ERROR_STOP=1 -c "
|
|||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) DO NOTHING;
|
||||||
" >/dev/null
|
" >/dev/null
|
||||||
|
|
||||||
|
# The OxiCloud server normally provisions a default Personal drive +
|
||||||
|
# Owner role_grant on user creation via PersonalDriveLifecycleHook
|
||||||
|
# (D0). This script bypasses that pipeline — it INSERTs directly into
|
||||||
|
# auth.users — so we mirror the hook's behaviour here. Without it,
|
||||||
|
# integration test fixtures that hand-roll INSERTs into storage.files
|
||||||
|
# fail with "drive_id not-null violation" (M3 made the column
|
||||||
|
# mandatory), and helpers that JOIN auth.users with storage.drives
|
||||||
|
# return RowNotFound.
|
||||||
|
echo "[init-schema] provisioning ci-admin's default Personal drive (idempotent)"
|
||||||
|
psql -v ON_ERROR_STOP=1 <<'SQL' >/dev/null
|
||||||
|
WITH admin AS (
|
||||||
|
SELECT id FROM auth.users WHERE username = 'ci-admin'
|
||||||
|
),
|
||||||
|
ins_drive AS (
|
||||||
|
INSERT INTO storage.drives (name, kind, default_for_user, quota_bytes)
|
||||||
|
SELECT 'Personal', 'personal', admin.id, NULL
|
||||||
|
FROM admin
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM storage.drives d WHERE d.default_for_user = admin.id
|
||||||
|
)
|
||||||
|
RETURNING id, default_for_user
|
||||||
|
)
|
||||||
|
INSERT INTO storage.role_grants
|
||||||
|
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||||||
|
SELECT 'user', ins_drive.default_for_user, 'drive', ins_drive.id, 'owner',
|
||||||
|
ins_drive.default_for_user
|
||||||
|
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
|
||||||
|
|
||||||
echo "[init-schema] done"
|
echo "[init-schema] done"
|
||||||
|
|||||||
Reference in New Issue
Block a user