diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts
index 7e3130d06..d6efc2aba 100644
--- a/server/db/migrations-pg.ts
+++ b/server/db/migrations-pg.ts
@@ -1190,4 +1190,34 @@ export const pgMigrations: Migration[] = [
on plugin_media_sources (asset_id);
`,
},
+ {
+ // Existing avatars predate anything writing `media_usage_refs`, so the
+ // first build that warns before a delete would still have said nothing
+ // about the avatar already set — the one case the feature exists for.
+ //
+ // Idempotent by construction: `not exists` on the same key
+ // `setMediaUsageRef` writes, so re-running inserts nothing and the row a
+ // later avatar change moves is the row this created.
+ //
+ // `028`, skipping `027`, on purpose: #335 is in review and already claims
+ // `027_data_tables_created_by_plugin`. Ids are only ever sorted, so a gap
+ // costs nothing — and whichever of the two lands first, neither has to be
+ // renumbered. A migration an installation has already recorded can never
+ // be renamed: the runner keys on the full id, so a new one re-runs SQL
+ // that is not idempotent and fails the boot.
+ id: '028_backfill_avatar_usage_refs',
+ sql: `
+ insert into media_usage_refs (asset_id, ref_kind, ref_id, ref_path)
+ select u.avatar_media_id, 'user.avatar', u.id, ''
+ from users u
+ where u.avatar_media_id is not null
+ and not exists (
+ select 1 from media_usage_refs r
+ where r.asset_id = u.avatar_media_id
+ and r.ref_kind = 'user.avatar'
+ and r.ref_id = u.id
+ and r.ref_path = ''
+ );
+ `,
+ },
]
diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts
index db3deb93a..958e24bb5 100644
--- a/server/db/migrations-sqlite.ts
+++ b/server/db/migrations-sqlite.ts
@@ -1266,4 +1266,34 @@ export const sqliteMigrations: Migration[] = [
on plugin_media_sources (asset_id);
`,
},
+ {
+ // Existing avatars predate anything writing `media_usage_refs`, so the
+ // first build that warns before a delete would still have said nothing
+ // about the avatar already set — the one case the feature exists for.
+ //
+ // Idempotent by construction: `not exists` on the same key
+ // `setMediaUsageRef` writes, so re-running inserts nothing and the row a
+ // later avatar change moves is the row this created.
+ //
+ // `028`, skipping `027`, on purpose: #335 is in review and already claims
+ // `027_data_tables_created_by_plugin`. Ids are only ever sorted, so a gap
+ // costs nothing — and whichever of the two lands first, neither has to be
+ // renumbered. A migration an installation has already recorded can never
+ // be renamed: the runner keys on the full id, so a new one re-runs SQL
+ // that is not idempotent and fails the boot.
+ id: '028_backfill_avatar_usage_refs',
+ sql: `
+ insert into media_usage_refs (asset_id, ref_kind, ref_id, ref_path)
+ select u.avatar_media_id, 'user.avatar', u.id, ''
+ from users u
+ where u.avatar_media_id is not null
+ and not exists (
+ select 1 from media_usage_refs r
+ where r.asset_id = u.avatar_media_id
+ and r.ref_kind = 'user.avatar'
+ and r.ref_id = u.id
+ and r.ref_path = ''
+ );
+ `,
+ },
]
diff --git a/server/handlers/cms/me.ts b/server/handlers/cms/me.ts
index 46eb9feb2..b40ef4a36 100644
--- a/server/handlers/cms/me.ts
+++ b/server/handlers/cms/me.ts
@@ -54,6 +54,7 @@ import {
acceptUploadedMedia,
readUploadForm,
} from './mediaUpload'
+import { setMediaUsageRef } from '../../repositories/media'
import { Type } from '@core/utils/typeboxHelpers'
import { isValidEmail } from '@core/utils/email'
import { MIN_PASSWORD_LENGTH, PASSWORD_TOO_SHORT_MESSAGE } from '@core/utils/passwordPolicy'
@@ -328,6 +329,15 @@ export async function handleMeRoutes(
if (asset instanceof Response) return asset
const updated = await setUserAvatarMediaId(db, user.id, asset.id)
+ // Register the dependency so the media library stops treating a profile
+ // picture as an anonymous upload. Without it the asset is indistinguishable
+ // from a decorative one, and purging it nulls `avatar_media_id` through
+ // the column's `on delete set null` — silently, from the operator's side.
+ await setMediaUsageRef(db, {
+ assetId: asset.id,
+ refKind: 'user.avatar',
+ refId: user.id,
+ })
if (!updated) {
// The user row vanished between auth and the update (e.g. concurrent
// soft-delete). The uploaded asset stays in the media library — it's
@@ -351,6 +361,10 @@ export async function handleMeRoutes(
const updated = await setUserAvatarMediaId(db, user.id, null)
if (!updated) return jsonResponse({ error: 'User not found' }, { status: 404 })
+ // The asset stays in the library on purpose (see the file header), but it
+ // is no longer depended on — so the warning has to stop firing for it.
+ await setMediaUsageRef(db, { assetId: null, refKind: 'user.avatar', refId: user.id })
+
await createAuditEvent(db, {
actorUserId: user.id,
action: 'user.update',
diff --git a/server/handlers/cms/media.ts b/server/handlers/cms/media.ts
index 00c122be2..51d3bc7ef 100644
--- a/server/handlers/cms/media.ts
+++ b/server/handlers/cms/media.ts
@@ -14,6 +14,10 @@
* permitted on already-trashed
* assets) and removes the file
* (`media.delete`)
+ * POST /admin/api/cms/media/usage — which of these assets are still
+ * depended on, and by what:
+ * recorded settings refs plus
+ * page content, computed live
* POST /admin/api/cms/media/:id/restore — restore a soft-deleted asset
* (`media.write`)
* POST /admin/api/cms/media/:id/replace — overwrite the bytes for an asset
@@ -38,11 +42,13 @@
*/
import type { DbClient } from '../../db/client'
import { requireCapability } from '../../auth/authz'
+import { collectContentUsageRefs } from '../../media/contentUsage'
import {
assignAssetToFolders,
deleteMediaAsset,
getMediaAsset,
listMediaAssets,
+ listMediaUsageRefs,
restoreMediaAsset,
softDeleteMediaAsset,
updateMediaAssetMetadata,
@@ -293,7 +299,41 @@ async function handleDeleteMedia(
const ID_PATTERN = '(?[^/]+)'
+/**
+ * Which of the given assets something still depends on.
+ *
+ * A POST because the id list is a selection and can be long — a query string
+ * of a hundred ids is the wrong shape for a read this cheap.
+ *
+ * The UI calls this before a destructive action so it can name what is about
+ * to break instead of warning in the abstract. Requires `media.read`: the
+ * response reveals nothing beyond what the library already lists.
+ */
+async function handleMediaUsage(req: Request, db: DbClient): Promise {
+ const user = await requireCapability(req, db, 'media.read')
+ if (user instanceof Response) return user
+
+ const body = await readValidatedBody(req, MediaUsageQuerySchema)
+ if (!body) return badRequest('Invalid request body')
+
+ // Two sources, one answer. Settings (an avatar, later a favicon) are
+ // recorded in `media_usage_refs` because they have one writer and an
+ // explicit set/unset. Page content is COMPUTED, because it has neither —
+ // see `server/media/contentUsage.ts` for why a table would go wrong there.
+ // Both run in parallel; the caller cannot tell which side a ref came from.
+ const [stored, content] = await Promise.all([
+ listMediaUsageRefs(db, body.assetIds),
+ collectContentUsageRefs(db, body.assetIds),
+ ])
+ return jsonResponse({ usage: [...stored, ...content] })
+}
+
+const MediaUsageQuerySchema = Type.Object({
+ assetIds: Type.Array(Type.String(), { maxItems: 500 }),
+}, { additionalProperties: false })
+
const MEDIA_ROUTES: readonly Route<[]>[] = [
+ { method: 'POST', pattern: `${MEDIA_PREFIX}/usage`, handler: handleMediaUsage },
{ method: 'GET', pattern: MEDIA_PREFIX, handler: handleListMedia },
{ method: 'POST', pattern: MEDIA_PREFIX, handler: handleUploadMedia },
{
diff --git a/server/media/contentUsage.ts b/server/media/contentUsage.ts
new file mode 100644
index 000000000..b97b585e3
--- /dev/null
+++ b/server/media/contentUsage.ts
@@ -0,0 +1,126 @@
+/**
+ * Which pages use these files — worked out from the site itself, not from a
+ * stored index.
+ *
+ * The counterpart to `media_usage_refs`, and the split between them is the
+ * point: a stored reference suits a SETTING, which has one writer and an
+ * explicit set/unset — an avatar, a favicon, a logo. Page content has
+ * neither. It is written continuously by the collab relay, and removing an
+ * image produces no event at all, so a table would fill with references to
+ * nodes that no longer exist and the warning would start being wrong.
+ *
+ * A wrong warning is worse than none: an operator who is misled once stops
+ * reading it. So this computes the answer at the moment it is asked, from
+ * `getDraftSiteDocument` — which cannot drift, because there is nothing to
+ * keep in sync.
+ *
+ * The cost lands where it belongs. Walking every page tree is O(site), and it
+ * happens only when someone asks to permanently delete something — never on a
+ * page load, never on a trash. For the site sizes this product is built for,
+ * that is a few milliseconds on an action that is about to be irreversible.
+ * If a site ever grows past that, the fix is to cache this — with the walk
+ * still the source of truth, so the cache can be checked against it.
+ *
+ * Deliberately reads the DRAFT document, not the published artefacts: an
+ * image placed on an unpublished page is still in use, and a warning that
+ * only knew about live pages would let a delete quietly break the next
+ * publish.
+ */
+
+// Registry population. The walk asks the registry which props are
+// image/media-typed, so without the base modules registered it matches
+// nothing and reports NO usage — a warning that is silently always empty,
+// which is the one failure mode worse than not having it. Same import
+// `pageDiff.ts` and the collab relay make, and for the same reason.
+import '@modules/base'
+import { registry } from '@core/module-engine'
+import { collectSiteStyleBackgroundImagePaths } from '@core/publisher'
+import { placeholder, type DbClient } from '../db/client'
+import { getDraftSiteDocument } from '../repositories/publish'
+import { collectPageMediaPaths } from '../publish/mediaPrefetch'
+import type { MediaUsageRef } from '../repositories/media'
+
+/**
+ * `ref_kind` values this module produces. They share the namespace with the
+ * stored kinds (`user.avatar`), so a caller merges the two lists without
+ * caring which side each one came from.
+ */
+export const PAGE_CONTENT_REF_KIND = 'page.content'
+export const SITE_STYLES_REF_KIND = 'site.styles'
+
+/**
+ * Map the requested asset ids to the `public_path` each one is stored under.
+ *
+ * Content props hold the path, not the id — and `replaceMediaAssetBinary`
+ * keeps the path stable across a file swap precisely so page references
+ * survive it. The path is therefore the join key, and this is the one query
+ * that translates.
+ */
+async function pathsForAssetIds(
+ db: DbClient,
+ assetIds: string[],
+): Promise
}
+ onCancel={() => {}}
+ onConfirm={() => {}}
+ />,
+ )
+ expect(screen.getByText('1 of 3 is still in use:')).toBeTruthy()
+ })
+})
+
+describe('resolveUsageWarning', () => {
+ it('never rejects, so the dialog it precedes always opens', async () => {
+ // Every surface opens its dialog from a floating `void (async () => …)()`.
+ // A rejection there is dropped silently and the dialog never appears — a
+ // delete button that does nothing at all.
+ const { resolveUsageWarning } = await import('@admin/pages/media/utils/usageWarning')
+ const warning = await resolveUsageWarning(async () => {
+ throw new Error('network')
+ }, ['a1'])
+ expect(warning).toBeNull()
+ })
+
+ it('does not ask the server about an empty selection', async () => {
+ const { resolveUsageWarning } = await import('@admin/pages/media/utils/usageWarning')
+ let called = false
+ const warning = await resolveUsageWarning(async () => {
+ called = true
+ return []
+ }, [])
+ expect(called).toBe(false)
+ expect(warning).toBeNull()
+ })
+})
diff --git a/src/__tests__/server/mediaContentUsage.test.ts b/src/__tests__/server/mediaContentUsage.test.ts
new file mode 100644
index 000000000..099232a1c
--- /dev/null
+++ b/src/__tests__/server/mediaContentUsage.test.ts
@@ -0,0 +1,185 @@
+/**
+ * Which pages use a file — worked out from the site, not from a stored index.
+ *
+ * The avatar case could be RECORDED because a profile picture has one writer
+ * and an explicit set/unset. Page content has neither: it is written
+ * continuously by the collab relay, and deleting an image emits no event at
+ * all — so a table would slowly fill with references to nodes that no longer
+ * exist, and the delete warning would start naming pages that are fine.
+ *
+ * That is the failure worth testing against, because a warning that is
+ * sometimes wrong is worse than no warning: it gets ignored. Everything here
+ * pins the property that makes it impossible — the answer comes from the
+ * current document, so it cannot describe a state the document is not in.
+ */
+
+import { afterEach, describe, expect, it } from 'bun:test'
+import type { SiteShell } from '@core/page-tree'
+import { normalizeSiteRuntimeConfig } from '@core/site-runtime'
+import { createTestDb } from '../helpers/createTestDb'
+import { saveDraftSite } from '../../../server/repositories/site'
+import { createDataRow, saveDataRowDraft } from '../../../server/repositories/data'
+import { pageToCells } from '../../../src/core/data/pageFromRow'
+import { collectContentUsageRefs } from '../../../server/media/contentUsage'
+
+const cleanups: Array<() => Promise> = []
+afterEach(async () => {
+ for (const cleanup of cleanups.splice(0)) await cleanup()
+})
+
+const HERO_PATH = '/uploads/hero.png'
+
+function siteShell(overrides: Partial = {}): SiteShell {
+ return {
+ id: 'project_1',
+ name: 'Site',
+ files: [],
+ visualComponents: [],
+ breakpoints: [{ id: 'desktop', label: 'Desktop', width: 1440, icon: 'monitor' }],
+ settings: { shortcuts: {} },
+ styleRules: {},
+ packageJson: { dependencies: {}, devDependencies: {} },
+ runtime: normalizeSiteRuntimeConfig(undefined),
+ createdAt: 1000,
+ updatedAt: 2000,
+ ...overrides,
+ }
+}
+
+/** A page whose single image node points at `src`, or none when null. */
+function pageWith(id: string, title: string, slug: string, src: string | null) {
+ const nodes: Record = {
+ root: {
+ id: 'root',
+ moduleId: 'base.body',
+ props: {},
+ breakpointOverrides: {},
+ children: src === null ? [] : ['img_1'],
+ classIds: [],
+ },
+ }
+ if (src !== null) {
+ nodes['img_1'] = {
+ id: 'img_1',
+ moduleId: 'base.image',
+ props: { src },
+ breakpointOverrides: {},
+ children: [],
+ classIds: [],
+ }
+ }
+ return { id, title, slug, rootNodeId: 'root', nodes }
+}
+
+async function freshDb() {
+ const { db, cleanup } = await createTestDb()
+ cleanups.push(cleanup)
+ // `data_rows.created_by` is a foreign key into `users`.
+ await db`
+ insert into users (id, email, email_normalized, display_name, password_hash, status, role_id)
+ values ('admin_1', 'ada@example.com', 'ada@example.com', 'Ada', 'hash', 'active', 'owner')
+ `
+ await db`
+ insert into media_assets (id, filename, mime_type, size_bytes, storage_path, public_path)
+ values ('a1', 'hero.png', 'image/png', 10, '/s/a1', ${HERO_PATH})
+ `
+ await db`
+ insert into media_assets (id, filename, mime_type, size_bytes, storage_path, public_path)
+ values ('a2', 'unused.png', 'image/png', 10, '/s/a2', '/uploads/unused.png')
+ `
+ return db
+}
+
+async function seedPage(
+ db: Awaited>,
+ page: ReturnType,
+) {
+ await createDataRow(db, {
+ id: page.id,
+ tableId: 'pages',
+ cells: pageToCells(page as never),
+ slug: page.slug,
+ }, 'admin_1')
+}
+
+describe('media used by page content', () => {
+ it('names the page an image sits on', async () => {
+ const db = await freshDb()
+ await saveDraftSite(db, siteShell())
+ await seedPage(db, pageWith('page_home', 'Home', 'index', HERO_PATH))
+
+ const refs = await collectContentUsageRefs(db, ['a1'])
+ expect(refs).toHaveLength(1)
+ expect(refs[0]!.refKind).toBe('page.content')
+ expect(refs[0]!.label).toBe('Home')
+ expect(refs[0]!.assetId).toBe('a1')
+ })
+
+ it('reports nothing for a file no page references', async () => {
+ const db = await freshDb()
+ await saveDraftSite(db, siteShell())
+ await seedPage(db, pageWith('page_home', 'Home', 'index', HERO_PATH))
+
+ expect(await collectContentUsageRefs(db, ['a2'])).toEqual([])
+ })
+
+ it('names every page, because every one of them breaks', async () => {
+ const db = await freshDb()
+ await saveDraftSite(db, siteShell())
+ await seedPage(db, pageWith('page_home', 'Home', 'index', HERO_PATH))
+ await seedPage(db, pageWith('page_about', 'About us', 'about', HERO_PATH))
+
+ const labels = (await collectContentUsageRefs(db, ['a1'])).map((r) => r.label)
+ expect(labels.sort()).toEqual(['About us', 'Home'])
+ })
+
+ it('stops reporting as soon as the image is taken off the page', async () => {
+ // The whole reason this is computed rather than recorded. Removing an
+ // image emits no event a table could listen for, so a stored reference
+ // would still be pointing at this page — and the warning would send the
+ // operator to fix something that is already fine.
+ const db = await freshDb()
+ await saveDraftSite(db, siteShell())
+ await seedPage(db, pageWith('page_home', 'Home', 'index', HERO_PATH))
+ expect(await collectContentUsageRefs(db, ['a1'])).toHaveLength(1)
+
+ await saveDataRowDraft(db, 'page_home', {
+ cells: pageToCells(pageWith('page_home', 'Home', 'index', null) as never),
+ slug: 'index',
+ }, 'admin_1')
+
+ expect(await collectContentUsageRefs(db, ['a1'])).toEqual([])
+ })
+
+ it('sees a draft page, not just what has been published', async () => {
+ // An image on an unpublished page is still in use: deleting it would
+ // break the page the moment it goes live. Reading published artefacts
+ // instead of the draft document would have missed exactly this.
+ const db = await freshDb()
+ await saveDraftSite(db, siteShell())
+ await seedPage(db, pageWith('page_draft', 'Not yet live', 'soon', HERO_PATH))
+
+ const refs = await collectContentUsageRefs(db, ['a1'])
+ expect(refs).toHaveLength(1)
+ expect(refs[0]!.label).toBe('Not yet live')
+ })
+
+ it('answers for a whole selection in one call', async () => {
+ const db = await freshDb()
+ await saveDraftSite(db, siteShell())
+ await seedPage(db, pageWith('page_home', 'Home', 'index', HERO_PATH))
+
+ const refs = await collectContentUsageRefs(db, ['a1', 'a2'])
+ expect(refs.map((r) => r.assetId)).toEqual(['a1'])
+ })
+
+ it('returns nothing for an empty selection without loading the site', async () => {
+ const db = await freshDb()
+ expect(await collectContentUsageRefs(db, [])).toEqual([])
+ })
+
+ it('reports nothing when there is no site document yet', async () => {
+ const db = await freshDb()
+ expect(await collectContentUsageRefs(db, ['a1'])).toEqual([])
+ })
+})
diff --git a/src/__tests__/server/mediaUsageRefs.test.ts b/src/__tests__/server/mediaUsageRefs.test.ts
new file mode 100644
index 000000000..9dc1104a3
--- /dev/null
+++ b/src/__tests__/server/mediaUsageRefs.test.ts
@@ -0,0 +1,188 @@
+/**
+ * Knowing that something still depends on a media asset.
+ *
+ * `media_usage_refs` shipped with the media schema and nothing ever wrote to
+ * it, so the library could not tell a decorative upload from an asset the
+ * product depends on. A profile picture is stored as an ordinary library row
+ * with no marker of any kind — so tidying up the library swept one into the
+ * trash, purging it hard-deleted the row, and `users.avatar_media_id` went
+ * quietly to NULL through its `on delete set null` foreign key. The profile
+ * fell back to a Gravatar identicon with nothing to explain why.
+ *
+ * These cover the two behaviours the warning depends on: a reference MOVES
+ * rather than accumulating, and a cleared one stops reporting.
+ */
+
+import { afterEach, describe, expect, it } from 'bun:test'
+import { createTestDb } from '../helpers/createTestDb'
+import { pgMigrations } from '../../../server/db/migrations-pg'
+import { sqliteMigrations } from '../../../server/db/migrations-sqlite'
+import {
+ listMediaUsageRefs,
+ setMediaUsageRef,
+} from '../../../server/repositories/media'
+
+const cleanups: Array<() => Promise> = []
+
+afterEach(async () => {
+ for (const cleanup of cleanups.splice(0)) await cleanup()
+})
+
+async function freshDb() {
+ const { db, cleanup } = await createTestDb()
+ cleanups.push(cleanup)
+ await db`
+ insert into users (id, email, email_normalized, display_name, password_hash, status, role_id)
+ values ('u1', 'ada@example.com', 'ada@example.com', 'Ada Lovelace', 'hash', 'active', 'owner')
+ `
+ return db
+}
+
+async function insertAsset(db: Awaited>, id: string) {
+ await db`
+ insert into media_assets (id, filename, mime_type, size_bytes, storage_path, public_path)
+ values (${id}, ${`${id}.png`}, 'image/png', 10, ${`/s/${id}`}, ${`/uploads/${id}.png`})
+ `
+}
+
+describe('media usage references', () => {
+ it('reports nothing for an asset nobody depends on', async () => {
+ const db = await freshDb()
+ await insertAsset(db, 'a1')
+ expect(await listMediaUsageRefs(db, ['a1'])).toEqual([])
+ })
+
+ it('names the person whose avatar it is, not the raw id', async () => {
+ // The whole point is a confirmation an operator can act on. "u1" tells
+ // them nothing; "Ada Lovelace" tells them what breaks.
+ const db = await freshDb()
+ await insertAsset(db, 'a1')
+ await setMediaUsageRef(db, { assetId: 'a1', refKind: 'user.avatar', refId: 'u1' })
+
+ const refs = await listMediaUsageRefs(db, ['a1'])
+ expect(refs).toHaveLength(1)
+ expect(refs[0]!.label).toBe('Ada Lovelace')
+ expect(refs[0]!.refKind).toBe('user.avatar')
+ })
+
+ it('falls back to the email when there is no display name', async () => {
+ const db = await freshDb()
+ await db`update users set display_name = '' where id = 'u1'`
+ await insertAsset(db, 'a1')
+ await setMediaUsageRef(db, { assetId: 'a1', refKind: 'user.avatar', refId: 'u1' })
+ expect((await listMediaUsageRefs(db, ['a1']))[0]!.label).toBe('ada@example.com')
+ })
+
+ it('MOVES the reference when the avatar is replaced', async () => {
+ // Four avatar changes must leave one row, not four — otherwise deleting
+ // the fifth picture would warn about ones replaced months ago, and the
+ // warning becomes noise the operator learns to click past.
+ const db = await freshDb()
+ await insertAsset(db, 'old')
+ await insertAsset(db, 'new')
+ await setMediaUsageRef(db, { assetId: 'old', refKind: 'user.avatar', refId: 'u1' })
+ await setMediaUsageRef(db, { assetId: 'new', refKind: 'user.avatar', refId: 'u1' })
+
+ expect(await listMediaUsageRefs(db, ['old'])).toEqual([])
+ expect((await listMediaUsageRefs(db, ['new']))[0]!.label).toBe('Ada Lovelace')
+ })
+
+ it('stops reporting once the avatar is cleared', async () => {
+ // The asset deliberately stays in the library, but nothing depends on it
+ // any more — so deleting it should no longer warn.
+ const db = await freshDb()
+ await insertAsset(db, 'a1')
+ await setMediaUsageRef(db, { assetId: 'a1', refKind: 'user.avatar', refId: 'u1' })
+ await setMediaUsageRef(db, { assetId: null, refKind: 'user.avatar', refId: 'u1' })
+ expect(await listMediaUsageRefs(db, ['a1'])).toEqual([])
+ })
+
+ it('answers for a whole selection in one call', async () => {
+ // The deletion path asks about every selected file at once; asking per
+ // file would mean one round trip per row of a bulk delete.
+ const db = await freshDb()
+ for (const id of ['a1', 'a2', 'a3']) await insertAsset(db, id)
+ await setMediaUsageRef(db, { assetId: 'a2', refKind: 'user.avatar', refId: 'u1' })
+
+ const refs = await listMediaUsageRefs(db, ['a1', 'a2', 'a3'])
+ expect(refs).toHaveLength(1)
+ expect(refs[0]!.assetId).toBe('a2')
+ })
+
+ it('returns nothing for an empty selection without touching the database', async () => {
+ const db = await freshDb()
+ expect(await listMediaUsageRefs(db, [])).toEqual([])
+ })
+})
+
+/**
+ * Run one shipped migration's own SQL, by id.
+ *
+ * `createTestDb` has already applied every migration before the test writes a
+ * row, so the backfill ran against an empty `users` table and the tracker now
+ * says it is done. Replaying its SQL directly is what actually exercises it —
+ * and running it twice is the only honest test of the `not exists` guard.
+ */
+async function replayMigration(db: Awaited>, id: string) {
+ const list = db.dialect === 'postgres' ? pgMigrations : sqliteMigrations
+ const migration = list.find((m) => m.id === id)
+ if (!migration) throw new Error(`No migration ${id} — was it renamed?`)
+ await db.unsafe(migration.sql)
+}
+
+const BACKFILL = '028_backfill_avatar_usage_refs'
+
+describe('the avatar backfill', () => {
+ it('protects an avatar that was set before anything recorded usage', async () => {
+ // Every install that already has an avatar is in exactly this state.
+ // Without the backfill, the first build that warns before a delete would
+ // still say nothing about the picture already set — the one case the
+ // whole feature exists for.
+ const db = await freshDb()
+ await insertAsset(db, 'a1')
+ await db`update users set avatar_media_id = 'a1' where id = 'u1'`
+
+ await replayMigration(db, BACKFILL)
+
+ const refs = await listMediaUsageRefs(db, ['a1'])
+ expect(refs).toHaveLength(1)
+ expect(refs[0]!.refKind).toBe('user.avatar')
+ expect(refs[0]!.label).toBe('Ada Lovelace')
+ })
+
+ it('adds nothing on top of a reference that is already there', async () => {
+ const db = await freshDb()
+ await insertAsset(db, 'a1')
+ await db`update users set avatar_media_id = 'a1' where id = 'u1'`
+ await setMediaUsageRef(db, { assetId: 'a1', refKind: 'user.avatar', refId: 'u1' })
+
+ await replayMigration(db, BACKFILL)
+ await replayMigration(db, BACKFILL)
+
+ expect(await listMediaUsageRefs(db, ['a1'])).toHaveLength(1)
+ })
+
+ it('leaves a user with no avatar alone', async () => {
+ const db = await freshDb()
+ await insertAsset(db, 'a1')
+ await replayMigration(db, BACKFILL)
+ expect(await listMediaUsageRefs(db, ['a1'])).toEqual([])
+ })
+
+ it('writes the row a later avatar change will MOVE, not a second one', async () => {
+ // The backfilled row has to be indistinguishable from one the app wrote,
+ // or changing the avatar afterwards would leave the old picture warning
+ // forever. `setMediaUsageRef` deletes on (ref_kind, ref_id, ref_path) —
+ // so the backfill must write the same key.
+ const db = await freshDb()
+ await insertAsset(db, 'old')
+ await insertAsset(db, 'new')
+ await db`update users set avatar_media_id = 'old' where id = 'u1'`
+ await replayMigration(db, BACKFILL)
+
+ await setMediaUsageRef(db, { assetId: 'new', refKind: 'user.avatar', refId: 'u1' })
+
+ expect(await listMediaUsageRefs(db, ['old'])).toEqual([])
+ expect(await listMediaUsageRefs(db, ['new'])).toHaveLength(1)
+ })
+})
diff --git a/src/admin/pages/media/MediaPage.tsx b/src/admin/pages/media/MediaPage.tsx
index f81e3aa29..8b2246188 100644
--- a/src/admin/pages/media/MediaPage.tsx
+++ b/src/admin/pages/media/MediaPage.tsx
@@ -65,6 +65,7 @@ export function MediaPage() {
replaceAssetFile: workspace.replaceAssetFile,
restoreAsset: workspace.restoreAsset,
purgeAsset: workspace.purgeAsset,
+ lookupUsage: workspace.lookupUsage,
}
: null
diff --git a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
index 3dba08963..fe200e671 100644
--- a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
+++ b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
@@ -19,6 +19,7 @@ import { FilterBar, type FilterBarItem } from '@ui/components/FilterBar'
import { Select } from '@ui/components/Select'
import { Skeleton } from '@ui/components/Skeleton'
import { canDeleteMedia, canWriteMedia } from '@admin/access'
+import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog'
import { useCurrentAdminUser } from '@admin/sessionContext'
import {
ExplorerItemContextMenu,
@@ -53,6 +54,8 @@ import {
writeMediaFolderDragData,
} from '../../utils/mediaDragDrop'
import { useMediaDnd } from '../../hooks/useMediaDnd'
+import { UsageWarningNotice } from '../UsageWarningNotice'
+import { resolveUsageWarning } from '../../utils/usageWarning'
import styles from './MediaCanvas.module.css'
import {
AssetRow,
@@ -122,6 +125,7 @@ function folderMatchesQuery(folder: CmsMediaFolder, query: string): boolean {
export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanvasProps) {
const currentUser = useCurrentAdminUser()
+ const confirmDelete = useConfirmDelete()
const [viewMode, setViewModeState] = useState(readStoredMediaViewMode)
const [contextMenu, setContextMenu] = useState(null)
const [renameTarget, setRenameTarget] = useState(null)
@@ -530,8 +534,30 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv
onDelete={() => {
const target = contextMenu.asset
setContextMenu(null)
- if (trashView) void workspace.purgeAsset(target.id)
- else void workspace.trashAsset(target.id)
+ if (!trashView) {
+ void workspace.trashAsset(target.id)
+ return
+ }
+ // Purging is the one media action nothing can undo: it removes the
+ // original and every generated size from the storage adapter, not
+ // just the row. `alwaysConfirm` because the `confirmBeforeDelete`
+ // preference defaults off, and an operator who turned it off was
+ // opting out of confirming a TRASH, which is reversible.
+ //
+ // The usage lookup is awaited BEFORE the dialog opens so the
+ // warning is on screen when the operator reads it, not after.
+ void (async () => {
+ const warning = await resolveUsageWarning(workspace.lookupUsage, [target.id])
+ confirmDelete({
+ title: `Delete "${target.filename}" permanently?`,
+ description:
+ 'This removes the file and every generated size from disk. It cannot be undone.',
+ details: ,
+ confirmLabel: 'Delete permanently',
+ alwaysConfirm: true,
+ commit: () => void workspace.purgeAsset(target.id),
+ })
+ })()
}}
showRename={canWrite}
showDelete={canDelete}
diff --git a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
index 27b3a6a3b..6bd340ff1 100644
--- a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
+++ b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
@@ -27,6 +27,7 @@
import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@ui/components/Button'
+import { Dialog } from '@ui/components/Dialog'
import { Input, Textarea } from '@ui/components/Input'
import { canDeleteMedia, canReplaceMedia, canWriteMedia } from '@admin/access'
import { useCurrentAdminUser } from '@admin/sessionContext'
@@ -37,7 +38,14 @@ import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid'
import { VideoSolidIcon } from 'pixel-art-icons/icons/video-solid'
import { PanelHeader } from '@admin/shared/PanelHeader'
import { useDraggablePanel } from '@admin/shared/FloatingWindow'
-import type { CmsMediaAsset, CmsMediaFolder, UpdateCmsMediaAssetInput } from '@core/persistence/cmsMedia'
+import type {
+ CmsMediaAsset,
+ CmsMediaFolder,
+ CmsMediaUsageRef,
+ UpdateCmsMediaAssetInput,
+} from '@core/persistence/cmsMedia'
+import { resolveUsageWarning, type UsageWarning } from '../../utils/usageWarning'
+import { UsageWarningNotice } from '../UsageWarningNotice'
import { bucketForMime } from '../../utils/filters'
import { useDebouncedSave } from '../../hooks/useDebouncedSave'
import { TagEditor } from '../TagEditor/TagEditor'
@@ -63,6 +71,7 @@ export interface MediaAssetEditor {
replaceAssetFile: (id: string, file: File) => Promise
restoreAsset: (id: string) => Promise
purgeAsset: (id: string) => Promise
+ lookupUsage: (assetIds: string[]) => Promise
}
interface MediaViewerWindowProps {
@@ -101,6 +110,8 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
const currentUser = useCurrentAdminUser()
const { asset } = editor
const [replaceOpen, setReplaceOpen] = useState(false)
+ const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
+ const [purgeWarning, setPurgeWarning] = useState(null)
const bucket = bucketForMime(asset.mimeType)
const canWrite = canWriteMedia(currentUser)
const canReplace = canReplaceMedia(currentUser)
@@ -328,7 +339,17 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {