Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions server/db/migrations-pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,4 +1359,33 @@ export const pgMigrations: Migration[] = [
id: '030_iso_timestamps',
sql: 'select 1',
},
{
// 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. Re-running is also
// what makes the id itself safe to change: an installation that recorded
// it under another number runs it again and inserts nothing.
//
// `032` sits between two ids other open PRs claim: `031` (#495) and `033`
// (#335). Ids are only ever sorted, so the gaps cost nothing, and
// whichever lands first, nobody has to renumber.
id: '032_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 = ''
);
`,
},
]
29 changes: 29 additions & 0 deletions server/db/migrations-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,4 +1515,33 @@ export const sqliteMigrations: Migration[] = [
id: '030_iso_timestamps',
sql: isoTimestampRewrite030(),
},
{
// 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. Re-running is also
// what makes the id itself safe to change: an installation that recorded
// it under another number runs it again and inserts nothing.
//
// `032` sits between two ids other open PRs claim: `031` (#495) and `033`
// (#335). Ids are only ever sorted, so the gaps cost nothing, and
// whichever lands first, nobody has to renumber.
id: '032_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 = ''
);
`,
},
]
14 changes: 14 additions & 0 deletions server/handlers/cms/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down
28 changes: 28 additions & 0 deletions server/handlers/cms/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* 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
* 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
Expand Down Expand Up @@ -43,6 +45,7 @@ import {
deleteMediaAsset,
getMediaAsset,
listMediaAssets,
listMediaUsageRefs,
restoreMediaAsset,
softDeleteMediaAsset,
updateMediaAssetMetadata,
Expand Down Expand Up @@ -293,7 +296,32 @@ async function handleDeleteMedia(

const ID_PATTERN = '(?<id>[^/]+)'

/**
* 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<Response> {
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')

return jsonResponse({ usage: await listMediaUsageRefs(db, body.assetIds) })
}

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 },
{
Expand Down
83 changes: 83 additions & 0 deletions server/repositories/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,86 @@ export async function importMediaAsset(
externally_hosted = excluded.externally_hosted
`
}

// ---------------------------------------------------------------------------
// Usage references
//
// `media_usage_refs` has existed since the media schema landed but nothing
// wrote to it, so the library could not tell a decorative upload from an
// asset something depends on. That gap is how a profile picture — stored as
// an ordinary library row, with no marker distinguishing it — could be swept
// into the trash during a tidy-up and purged, silently nulling
// `users.avatar_media_id` through its `on delete set null` foreign key.
//
// `ref_kind` namespaces the source so more can be registered without touching
// consumers: `user.avatar` here, page nodes and site settings next.
// ---------------------------------------------------------------------------

/** A thing that depends on an asset, resolved for display. */
export interface MediaUsageRef {
assetId: string
refKind: string
refId: string
/** Human-readable, e.g. a person's name for an avatar. Never a raw id. */
label: string
}

/**
* Point a `(kind, id)` pair at an asset, replacing whatever it pointed at
* before.
*
* Deleting first is what makes this a MOVE rather than an accumulation: a
* user who changes their avatar four times should leave one row, not four,
* or the fifth deletion would warn about pictures they replaced months ago.
*/
export async function setMediaUsageRef(
db: DbClient,
args: { assetId: string | null; refKind: string; refId: string; refPath?: string },
): Promise<void> {
const refPath = args.refPath ?? ''
await db`
delete from media_usage_refs
where ref_kind = ${args.refKind} and ref_id = ${args.refId} and ref_path = ${refPath}
`
if (!args.assetId) return
await db`
insert into media_usage_refs (asset_id, ref_kind, ref_id, ref_path)
values (${args.assetId}, ${args.refKind}, ${args.refId}, ${refPath})
`
}

/**
* Which of these assets are still depended on, with something an operator can
* recognise.
*
* Takes a LIST because the question is always asked about a selection — the
* deletion path needs one round trip, not one per file.
*/
export async function listMediaUsageRefs(
db: DbClient,
assetIds: string[],
): Promise<MediaUsageRef[]> {
if (assetIds.length === 0) return []
const placeholders = assetIds.map((_, i) => placeholder(db.dialect, i + 1)).join(", ")
const { rows } = await db.unsafe<{
asset_id: string
ref_kind: string
ref_id: string
label: string | null
}>(
`select r.asset_id, r.ref_kind, r.ref_id,
case when r.ref_kind = 'user.avatar'
then coalesce(nullif(u.display_name, ''), u.email)
else null end as label
from media_usage_refs r
left join users u on u.id = r.ref_id and r.ref_kind = 'user.avatar'
where r.asset_id in (${placeholders})`,
assetIds,
)
return rows.map((row) => ({
assetId: row.asset_id,
refKind: row.ref_kind,
refId: row.ref_id,
label: row.label ?? row.ref_kind,
}))
}
70 changes: 70 additions & 0 deletions src/__tests__/media/usageWarning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* What a destructive confirmation says when part of the selection is in use.
*
* The scenario that produced these rules: eleven files selected, one of them
* a profile picture, and the operator about to purge the lot. A warning that
* says "some of these are in use" tells them nothing actionable; one that
* blocks the delete stops them replacing their own avatar.
*/

import { describe, expect, it } from 'bun:test'
import { buildUsageWarning } from '@admin/pages/media/utils/usageWarning'

const avatar = (assetId: string, label: string) => ({
assetId, refKind: 'user.avatar', refId: 'u1', label,
})

describe('the usage warning', () => {
it('says nothing when nothing is depended on', () => {
// The ordinary confirmation stands on its own; an empty warning box
// would train people to ignore the space it occupies.
expect(buildUsageWarning(11, [])).toBeNull()
})

it('separates the used from the safe', () => {
// "1 of 11" is the whole point — it tells the operator the other ten
// carry no risk, which a blanket warning never does.
const warning = buildUsageWarning(11, [avatar('a1', 'Ada Lovelace')])
expect(warning?.heading).toBe('1 of 11 is still in use:')
})

it('drops the count when everything selected is in use', () => {
// "2 of 2" reads like arithmetic. Naming the state is clearer.
const warning = buildUsageWarning(2, [avatar('a1', 'Ada'), avatar('a2', 'Grace')])
expect(warning?.heading).toBe('These files are still in use:')
})

it('names what breaks rather than describing the reference', () => {
const warning = buildUsageWarning(3, [avatar('a1', 'Ada Lovelace')])
expect(warning?.lines).toEqual(['profile picture — Ada Lovelace'])
})

it('counts an asset once even when two things depend on it', () => {
// One file is lost, not two — the count is about what disappears.
const warning = buildUsageWarning(4, [
avatar('a1', 'Ada Lovelace'),
{ assetId: 'a1', refKind: 'user.avatar', refId: 'u2', label: 'Grace Hopper' },
])
expect(warning?.heading).toBe('1 of 4 is still in use:')
expect(warning?.lines).toHaveLength(1)
})

it('summarises past three so the dialog stays readable', () => {
const refs = ['a1', 'a2', 'a3', 'a4', 'a5'].map((id, i) => avatar(id, `User ${i + 1}`))
const warning = buildUsageWarning(20, refs)
expect(warning?.lines).toHaveLength(4)
expect(warning?.lines.at(-1)).toBe('and 2 more')
})

it('does not summarise at exactly three', () => {
const refs = ['a1', 'a2', 'a3'].map((id, i) => avatar(id, `User ${i + 1}`))
expect(buildUsageWarning(9, refs)?.lines).toHaveLength(3)
})

it('uses singular and plural correctly', () => {
expect(buildUsageWarning(5, [avatar('a1', 'Ada')])?.heading).toContain(' is still')
expect(
buildUsageWarning(5, [avatar('a1', 'Ada'), avatar('a2', 'Grace')])?.heading,
).toContain(' are still')
})
})
Loading
Loading