Files

158 lines
8.9 KiB
PL/PgSQL
Raw Permalink Normal View History

-- Step 9 of `docs/plan/derived-blobs.md` — the file-keyed half of the pair.
--
-- `content_derived_blobs` holds bytes that are a pure deterministic function
-- of a file's content, so they are keyed by that content and shared across
-- every file holding it. This table holds the opposite: bytes a USER supplied
-- or chose. Those must never be shared across files, and the key is what
-- enforces it.
--
-- The distinction is a security boundary, not a modelling preference. If a
-- client-uploaded preview were content-keyed, user A could upload a file plus
-- a preview that misrepresents it; when user B later uploads the same bytes,
-- dedup would match and B would be served A's preview. Content-keying is only
-- safe when the bytes are derivable from the content by the server — nothing
-- to poison, because anyone with the same input gets the same output.
--
-- Required now rather than deferred: the SPA already generates and PUTs
-- previews for PDFs, and there is no server-side regeneration path for them,
-- so the sidecar migration has nowhere else to put those bytes.
CREATE TABLE IF NOT EXISTS storage.file_attached_blobs (
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('preview', 'subtitle', 'cover_art')),
variant TEXT NOT NULL,
blob_hash VARCHAR(64) NOT NULL,
content_type TEXT NOT NULL,
-- Provenance convention: NOT NULL and NO foreign key. A FK with
-- ON DELETE SET NULL loses the audit trail exactly when it matters most,
-- and without an ON DELETE clause it would block deleting a user
-- outright. Deleting the uploader must not rewrite history, so the id is
-- retained even once it no longer resolves. Rows imported with no known
-- uploader carry the all-zeros sentinel.
uploaded_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (file_id, kind, variant)
);
-- Reverse lookup for the reference recompute and for dedup_gc's reap
-- predicate: both ask "does any row reference this blob?".
CREATE INDEX IF NOT EXISTS idx_file_attached_blobs_blob_hash
ON storage.file_attached_blobs (blob_hash);
-- ── The routing rule, recorded on both tables ────────────────────────────
-- Choosing the wrong table is a silent poisoning bug rather than a compile
-- error, so the rule lives where an implementor will actually meet it.
COMMENT ON TABLE storage.file_attached_blobs IS
'User-supplied or user-chosen artifacts (client previews, subtitles, cover art) keyed by FILE. File-keyed on purpose: these bytes are not derivable from the file''s content, so sharing them across files with identical content would let one user''s upload be served for another user''s file. Server-derived bytes must NOT be stored here — see docs/plan/derived-blobs.md.';
COMMENT ON COLUMN storage.file_attached_blobs.file_id IS
'The file these bytes are attached to. ON DELETE CASCADE: the attachment has no meaning without it. Deleting the row does NOT release the blob reference — the owning service does that in its on_file_deleted hook.';
COMMENT ON COLUMN storage.file_attached_blobs.blob_hash IS
'The attached Blob. Reference HOLDER — bumps chunk_manifests.ref_count via DedupService::add_reference. Dedup still applies to the bytes themselves; what is forbidden is sharing the MAPPING across files.';
COMMENT ON COLUMN storage.file_attached_blobs.variant IS
'Opaque discriminator within a kind (preview | en | fr | cover...). New axes go inside this string, never into new columns.';
COMMENT ON COLUMN storage.file_attached_blobs.uploaded_by IS
'Who supplied these bytes. Retained after the user is deleted — deleting a user must not rewrite provenance. The only trace that an Editor on a shared file replaced the owner''s preview.';
COMMENT ON TABLE storage.content_derived_blobs IS
'Server-derived artifacts (thumbnails, transcodes) keyed by the BLAKE3 of their SOURCE content. Content-keyed on purpose: identical content shares one derivation. ROUTING RULE — bytes that are a pure deterministic function of the file''s content belong here; bytes that are user-supplied or user-chosen belong in storage.file_attached_blobs, which is file-keyed and never shared. See docs/plan/derived-blobs.md.';
-- ── Teach the copy fan-out about it ──────────────────────────────────────
--
-- Only the attached-blobs arm is new versus `20261019000000`; everything
-- else is that definition verbatim. Adding a file-keyed table is now one
-- edit in one function, which is the whole point of having consolidated the
-- two copy paths first.
CREATE OR REPLACE FUNCTION storage.copy_file_satellites(
p_old_ids UUID[],
p_new_ids UUID[]
) RETURNS void AS $$
DECLARE
v_unmatched TEXT[];
BEGIN
IF p_old_ids IS NULL OR cardinality(p_old_ids) = 0 THEN
RETURN;
END IF;
IF p_new_ids IS NULL OR cardinality(p_old_ids) <> cardinality(p_new_ids) THEN
-- Positional correspondence is the whole interface; a length
-- mismatch would silently attach satellites to the wrong file.
RAISE EXCEPTION
'copy_file_satellites: id arrays must correspond positionally (% old vs % new)',
cardinality(p_old_ids), COALESCE(cardinality(p_new_ids), 0);
END IF;
-- 1. WebDAV dead properties. RFC 4918 §8.8 requires COPY to duplicate
-- them: properties describe the resource, and the copy is a resource.
INSERT INTO storage.webdav_dead_properties
(file_id, namespace, local_name, value)
SELECT m.new_id, dp.namespace, dp.local_name, dp.value
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
JOIN storage.webdav_dead_properties dp ON dp.file_id = m.old_id;
-- 2. A reference on the copied content, so deleting the original cannot
-- reap bytes the copy still needs. Read from the NEW rows rather than
-- the old ones: that is what makes an unreferenceable copy impossible
-- to create, since a row that failed to insert contributes nothing.
SELECT storage.add_blob_references(array_agg(f.blob_hash))
INTO v_unmatched
FROM unnest(p_new_ids) AS n(id)
JOIN storage.files f ON f.id = n.id
WHERE NOT f.is_trashed;
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
-- Warn, do not abort. A missing registry row means the SOURCE file
-- was already broken; the copy merely inherits it. Failing here
-- would abort an entire folder copy over one pre-existing fault,
-- which is worse than completing it and reporting. The blob-level
-- audit jobs are what surface the underlying breakage.
RAISE WARNING
'copy_file_satellites: % copied file(s) reference a blob with no registry row (first: %); source was already broken',
cardinality(v_unmatched), v_unmatched[1];
END IF;
-- 3. File-keyed attachments — client previews, subtitles, cover art.
-- DUPLICATED rather than shared, because the key is `file_id` and the
-- copy is a different file. `uploaded_by` carries over: the person
-- who supplied the bytes did not change because someone copied the
-- file, and rewriting it to the copier would forge provenance.
--
-- Each duplicated row is a new reference on the same blob, so the
-- bytes are still deduplicated — it is the MAPPING that must not be
-- shared, not the content.
WITH copied AS (
INSERT INTO storage.file_attached_blobs
(file_id, kind, variant, blob_hash, content_type, uploaded_by)
SELECT m.new_id, a.kind, a.variant, a.blob_hash, a.content_type, a.uploaded_by
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
JOIN storage.file_attached_blobs a ON a.file_id = m.old_id
RETURNING blob_hash
)
SELECT storage.add_blob_references(array_agg(blob_hash))
INTO v_unmatched
FROM copied;
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
RAISE WARNING
'copy_file_satellites: % attached blob(s) reference no registry row (first: %); source was already broken',
cardinality(v_unmatched), v_unmatched[1];
END IF;
-- ── Deliberately absent ──────────────────────────────────────────────
--
-- storage.comments (future): NOT copied. A copy is a new artifact; the
-- discussion belongs to the original.
--
-- content_derived_blobs, blob_extracted_text, faces.faces: content-keyed.
-- The copy shares the source's hash, so it already sees them — copying
-- would duplicate rows that are keyed on the very thing being shared.
--
-- storage.favorites, recent_items, shares: properties of the ORIGINAL's
-- relationship to users, not of its content.
END;
$$ LANGUAGE plpgsql;