feat(storage): thumb_attached_import — backfill uploaded previews

Twin of thumb_derived_import, for the other sidecar shape:
{thumbnails_root}/{size}/ext-{file_id}.jpg, the previews a user
uploaded — notably the SPA's client-side PDF generator, which has no
server-side render path at all.

Until a row exists, a copy of the file LOSES the preview: the sidecar is
keyed by file_id, no copy path duplicates it, and the server silently
falls back to rendering from the source, or to nothing for a PDF. That
is the bug file_attached_blobs closed for new uploads; this closes it
for everything already on disk.

Separate job rather than an arm of the derived import, because the
keying differs and that difference is the security boundary. These bytes
are not derivable from the file's content, so content-keying them would
share one user's uploaded preview onto every file with identical
content. Each job's name filter rejects the other's shape, and both
directions are under test.

Idempotence needs more care here than in the derived twin.
store_attached_blob is ON CONFLICT DO UPDATE, so calling it for an
existing row releases the previous reference and takes a new one —
harmless once, but a job doing it every run would churn refcounts. The
row is therefore checked first and the store reached only on a genuine
insert.

uploaded_by is the nil sentinel: disk records no uploader, and inventing
one — the file's created_by, say — would fabricate provenance that could
later read as evidence an Editor replaced someone's preview. The column
is NOT NULL with no FK precisely so provenance survives, and a sentinel
says "unknown" honestly.

Orphaned sidecars (no storage.files row) are counted and reported, not
deleted. This job imports; it does not reclaim. Existence is checked
explicitly rather than letting the foreign key reject the insert, so an
orphan is counted as one instead of surfacing as an opaque constraint
error.
This commit is contained in:
Edouard Vanbelle
2026-08-26 00:20:14 +02:00
parent f80a28763e
commit 7a2ebe0fdc
3 changed files with 390 additions and 0 deletions
+14
View File
@@ -1494,6 +1494,20 @@ impl AppServiceFactory {
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
.await;
// Its file-keyed twin: `ext-{file_id}.jpg` previews the user uploaded,
// which no copy path duplicates today. Separate job, separate keying —
// routing these into the content-keyed table would share one user's
// preview onto every file with identical content.
let _ = Arc::new(
crate::infrastructure::services::thumb_attached_import_service::ThumbAttachedImport::new(
std::path::Path::new(&self.storage_path).join(".thumbnails"),
core.dedup_service.clone(),
maintenance_pool.clone(),
),
)
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
.await;
// Third recoverable-run tenant. Iterates `storage.files`
// and reports parent-folder-trashed cascade misses,
// `missing_blob` (data-loss indicator — file references