Skip to content
Open
30 changes: 30 additions & 0 deletions server/db/migrations-pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
);
`,
},
]
30 changes: 30 additions & 0 deletions server/db/migrations-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
);
`,
},
]
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
40 changes: 40 additions & 0 deletions server/handlers/cms/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -293,7 +299,41 @@ 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')

// 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 },
{
Expand Down
126 changes: 126 additions & 0 deletions server/media/contentUsage.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, string>> {
const placeholders = assetIds.map((_, i) => placeholder(db.dialect, i + 1)).join(', ')
const { rows } = await db.unsafe<{ id: string; public_path: string }>(
`select id, public_path from media_assets where id in (${placeholders})`,
assetIds,
)
const byPath = new Map<string, string>()
for (const row of rows) byPath.set(row.public_path, row.id)
return byPath
}

/**
* Which of `assetIds` the site's own content references, and where.
*
* One ref per (asset, page) — a file used by four nodes on one page is one
* page to fix, and repeating its title four times would turn the warning into
* the wall of text it exists to avoid.
*/
export async function collectContentUsageRefs(
db: DbClient,
assetIds: string[],
): Promise<MediaUsageRef[]> {
if (assetIds.length === 0) return []

const assetIdByPath = await pathsForAssetIds(db, assetIds)
if (assetIdByPath.size === 0) return []

const site = await getDraftSiteDocument(db)
if (!site) return []

const refs: MediaUsageRef[] = []

for (const page of site.pages) {
// `collectPageMediaPaths` descends into the definition tree of every
// Visual Component the page references, so an image inside a VC body is
// attributed to the page that renders it — which is the page that would
// break, and so the one worth naming.
const used = collectPageMediaPaths(page, site, registry)
for (const path of used) {
const assetId = assetIdByPath.get(path)
if (!assetId) continue
refs.push({
assetId,
refKind: PAGE_CONTENT_REF_KIND,
refId: page.id,
label: page.title || page.slug,
})
}
}

// Site-level style backgrounds belong to no single page — every page that
// matches the rule renders them, so naming one page would be misleading.
for (const path of collectSiteStyleBackgroundImagePaths(site)) {
const assetId = assetIdByPath.get(path)
if (!assetId) continue
refs.push({
assetId,
refKind: SITE_STYLES_REF_KIND,
refId: 'site',
label: 'site styles',
})
}

return refs
}
23 changes: 21 additions & 2 deletions server/publish/mediaPrefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,18 @@ interface MediaPrefetchOptions {

/**
* Collect every `/uploads/...` path referenced by an image/media-typed prop
* across the page tree.
* in ONE page's tree.
*
* Site-level style backgrounds are deliberately NOT included: they belong to
* the site, not to any page that happens to be rendering. The prefetch adds
* them separately, and the media-usage lookup attributes them to the site so
* a delete warning can say where a file is actually used.
*/
function collectMediaPaths(page: Page, site: SiteDocument, registry: IModuleRegistry): Set<string> {
export function collectPageMediaPaths(
page: Page,
site: SiteDocument,
registry: IModuleRegistry,
): Set<string> {
const paths = new Set<string>()
// Descend into referenced VC definition trees so an image/media prop inside a
// VC body is resolved too (ISS-022).
Expand All @@ -64,6 +73,16 @@ function collectMediaPaths(page: Page, site: SiteDocument, registry: IModuleRegi
paths.add(value)
}
})
return paths
}

/** The page's own references plus the site-level style backgrounds. */
function collectMediaPaths(
page: Page,
site: SiteDocument,
registry: IModuleRegistry,
): Set<string> {
const paths = collectPageMediaPaths(page, site, registry)
for (const path of collectSiteStyleBackgroundImagePaths(site)) {
paths.add(path)
}
Expand Down
Loading
Loading