import { constants } from 'node:fs'; import { lstat, open } from 'node:fs/promises'; import path from 'node:path'; import { createHash, createHmac } from 'node:crypto'; import sharp from 'sharp'; import type { PoolClient } from 'pg'; import { legacyUserId } from './identity.js'; import { toDate, toNumber, toStringValue, type SourceRow } from './db.js'; const MAX_ICON_BYTES = 50 * 1024; const LEGACY_CACHE_SUFFIX = /\?=([0-9]{8})$/u; const REMOTE_PICTURE = /^users\/(?:core|core2026)\/[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$/u; const LOCAL_PICTURE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,190}\.(?:avif|webp|jpe?g|png|gif)$/u; const CONTENT_TYPES: Record = { avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', }; export interface LegacyUserIconTransferConfig { sourceDirectory: string; uploadBaseUrl: string; publicBaseUrl: string; uploadSecret: string; excludedMemberNumbers: readonly number[]; } export interface PreparedLegacyUserIcon { memberNo: number; userId: string; sourcePicture: string; normalizedSourcePicture: string; sourceImageServer: number; picture: string; imageServer: 0; createdAt: Date; source: 'legacy-file' | 'existing-upload'; sha256: string; } export interface LegacyUserIconPreparation { icons: Map; rejected: Map; counts: { custom: number; legacyFiles: number; existingUploads: number; uploaded: number; rejected: number; }; } export interface RejectedLegacyUserIcon { memberNo: number; userId: string; sourcePicture: string; normalizedSourcePicture: string; sourceImageServer: number; reason: string; } export interface LegacyUserIconSyncCounts { currentLinked: number; libraryInserted: number; libraryRetired: number; targetPreserved: number; } export interface RejectedLegacyUserIconSyncCounts { currentReset: number; targetPreserved: number; } interface ValidatedImage { body: Buffer; extension: string; contentType: string; sha256: string; } class LegacyUserIconValidationError extends Error {} const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/'); export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, ''); const iconCreatedAt = (sourcePicture: string, fallback: Date): Date => { const marker = sourcePicture.match(LEGACY_CACHE_SUFFIX)?.[1]; if (!marker) return fallback; const year = Number(marker.slice(0, 4)); const month = Number(marker.slice(4, 6)); const day = Number(marker.slice(6, 8)); const parsed = new Date(Date.UTC(year, month - 1, day, -9)); return Number.isNaN(parsed.getTime()) ? fallback : parsed; }; const validateImage = async (body: Buffer, label: string): Promise => { if (body.length === 0 || body.length > MAX_ICON_BYTES) { throw new LegacyUserIconValidationError(`${label} must be non-empty and at most 50 KiB`); } let metadata: { mediaType?: string; format?: string; width?: number; height?: number; pageHeight?: number; pages?: number; }; try { metadata = await sharp(body, { animated: true }).metadata(); } catch (error) { throw new LegacyUserIconValidationError(`${label} is not a decodable image`, { cause: error }); } const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format; const extension = detected === 'jpeg' ? 'jpg' : detected; if (!extension || !CONTENT_TYPES[extension]) { throw new LegacyUserIconValidationError(`${label} must be avif, webp, jpeg, png, or gif`); } const frameHeight = metadata.pages && metadata.pages > 1 ? metadata.pageHeight : metadata.height; if (!metadata.width || metadata.width < 64 || metadata.width > 128 || frameHeight !== metadata.width) { throw new LegacyUserIconValidationError(`${label} must be a square image from 64x64 through 128x128`); } return { body, extension, contentType: CONTENT_TYPES[extension]!, sha256: createHash('sha256').update(body).digest('hex'), }; }; const readLegacyIcon = async (directory: string, picture: string, memberNo: number): Promise => { if (!LOCAL_PICTURE.test(picture) || path.basename(picture) !== picture) { throw new Error(`member.${memberNo}.PICTURE is not a safe Ref d_pic filename`); } const filePath = path.resolve(directory, picture); if (path.dirname(filePath) !== path.resolve(directory)) { throw new Error(`member.${memberNo}.PICTURE escapes the Ref d_pic directory`); } const info = await lstat(filePath); if (!info.isFile() || info.isSymbolicLink()) { throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`); } if (info.size === 0 || info.size > MAX_ICON_BYTES) { throw new LegacyUserIconValidationError(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`); } const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW); try { return await validateImage(await handle.readFile(), `member.${memberNo}.PICTURE`); } finally { await handle.close(); } }; const fetchExistingIcon = async ( config: LegacyUserIconTransferConfig, picture: string, memberNo: number, fetchImpl: typeof fetch ): Promise => { if (!REMOTE_PICTURE.test(picture)) { throw new Error(`member.${memberNo}.PICTURE is neither a Ref d_pic filename nor a sam-image upload path`); } const response = await fetchImpl(`${config.publicBaseUrl.replace(/\/$/u, '')}/${encodedPicturePath(picture)}`, { headers: { accept: 'image/avif,image/webp,image/png,image/jpeg,image/gif' }, }); if (!response.ok) { throw new Error(`member.${memberNo}.PICTURE is unavailable from sam-image (HTTP ${response.status})`); } const contentLength = Number(response.headers.get('content-length') ?? 0); if (contentLength > MAX_ICON_BYTES) { throw new Error(`member.${memberNo}.PICTURE exceeds 50 KiB on sam-image`); } const body = Buffer.from(await response.arrayBuffer()); return validateImage(body, `member.${memberNo}.PICTURE`); }; const deterministicUploadName = (memberNo: number, sourcePicture: string, image: ValidatedImage): string => { const stem = createHash('sha256') .update('legacy-ref-user-icon-v1\0') .update(String(memberNo)) .update('\0') .update(sourcePicture) .update('\0') .update(image.sha256) .digest('hex') .slice(0, 32); return `${stem}.${image.extension}`; }; const uploadSignature = ( secret: string, expires: string, requestId: string, pathname: string, contentType: string, body: Buffer ): string => { const digest = createHash('sha256').update(body).digest('hex'); return createHmac('sha256', secret) .update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`) .digest('hex'); }; const uploadLegacyIcon = async ( config: LegacyUserIconTransferConfig, memberNo: number, sourcePicture: string, image: ValidatedImage, fetchImpl: typeof fetch, now: () => number ): Promise => { const filename = deterministicUploadName(memberNo, sourcePicture, image); const pathname = `/v1/uploads/user-icons/core2026/${filename}`; const expires = String(Math.floor(now() / 1000) + 60); const requestId = `legacy-ref-${filename.slice(0, 32)}`; const response = await fetchImpl(`${config.uploadBaseUrl.replace(/\/$/u, '')}${pathname}`, { method: 'PUT', headers: { 'content-type': image.contentType, 'x-image-client': 'core2026', 'x-image-expires': expires, 'x-image-request-id': requestId, 'x-image-signature': uploadSignature( config.uploadSecret, expires, requestId, pathname, image.contentType, image.body ), }, body: new Uint8Array(image.body), }); if (!response.ok) { throw new Error(`member.${memberNo}.PICTURE upload failed with HTTP ${response.status}`); } const picture = `users/core2026/${filename}`; const payload: unknown = await response.json(); if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) { throw new Error(`member.${memberNo}.PICTURE upload returned an unexpected path`); } return picture; }; const mapWithConcurrency = async ( values: readonly T[], concurrency: number, mapper: (value: T) => Promise ): Promise => { const results = new Array(values.length); let nextIndex = 0; const worker = async (): Promise => { while (nextIndex < values.length) { const index = nextIndex++; results[index] = await mapper(values[index]!); } }; await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker)); return results; }; export const prepareLegacyUserIcons = async ( rows: readonly SourceRow[], config: LegacyUserIconTransferConfig | undefined, apply: boolean, options: { fetchImpl?: typeof fetch; now?: () => number; concurrency?: number } = {} ): Promise => { const customRows = rows.filter((row) => (row.PICTURE ?? 'default.jpg') !== 'default.jpg'); if (customRows.length > 0 && !config) { throw new Error('Gateway source has custom icons but gateway.userIcons is not configured'); } if (!config) { return { icons: new Map(), rejected: new Map(), counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0, rejected: 0 }, }; } if (config.uploadSecret.length < 32) { throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters'); } const fetchImpl = options.fetchImpl ?? fetch; const now = options.now ?? Date.now; const exclusions = new Set(config.excludedMemberNumbers); if (exclusions.size !== config.excludedMemberNumbers.length) { throw new Error('gateway.userIcons.excludedMemberNumbers must not contain duplicates'); } const validated = await mapWithConcurrency(customRows, options.concurrency ?? 8, async (row) => { const memberNo = toNumber(row.NO, 'member.NO'); const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`); const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture); const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`); const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`); try { if (REMOTE_PICTURE.test(normalizedSourcePicture)) { const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl); if (exclusions.has(memberNo)) { throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`); } return { kind: 'icon' as const, base: { memberNo, userId: legacyUserId(memberNo), sourcePicture, normalizedSourcePicture, sourceImageServer, imageServer: 0 as const, createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt), source: 'existing-upload' as const, sha256: image.sha256, }, image, picture: normalizedSourcePicture, }; } if (sourceImageServer !== 1) { throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`); } const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo); if (exclusions.has(memberNo)) { throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`); } return { kind: 'icon' as const, base: { memberNo, userId: legacyUserId(memberNo), sourcePicture, normalizedSourcePicture, sourceImageServer, imageServer: 0 as const, createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt), source: 'legacy-file' as const, sha256: image.sha256, }, image, picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, image)}`, }; } catch (error) { if (!(error instanceof LegacyUserIconValidationError) || !exclusions.has(memberNo)) throw error; return { kind: 'rejected' as const, rejected: { memberNo, userId: legacyUserId(memberNo), sourcePicture, normalizedSourcePicture, sourceImageServer, reason: error.message, } satisfies RejectedLegacyUserIcon, }; } }); const missingExclusions = [...exclusions].filter( (memberNo) => !validated.some((result) => result.kind === 'rejected' && result.rejected.memberNo === memberNo) ); if (missingExclusions.length) { throw new Error(`Configured user-icon exclusions were not rejected: ${missingExclusions.join(', ')}`); } const validIcons = validated.filter((result) => result.kind === 'icon'); const rejectedIcons = validated.filter((result) => result.kind === 'rejected').map((result) => result.rejected); const pictures = new Map(); for (const icon of validIcons) { const owner = pictures.get(icon.picture); if (owner !== undefined && owner !== icon.base.memberNo) { throw new Error('Legacy user icon picture is shared by multiple source accounts'); } pictures.set(icon.picture, icon.base.memberNo); } const prepared = await mapWithConcurrency(validIcons, options.concurrency ?? 8, async (icon) => { const picture = apply && icon.base.source === 'legacy-file' ? await uploadLegacyIcon( config, icon.base.memberNo, icon.base.normalizedSourcePicture, icon.image, fetchImpl, now ) : icon.picture; return { ...icon.base, picture } satisfies PreparedLegacyUserIcon; }); return { icons: new Map(prepared.map((icon) => [icon.memberNo, icon])), rejected: new Map(rejectedIcons.map((icon) => [icon.memberNo, icon])), counts: { custom: validated.length, legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length, existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length, uploaded: apply ? prepared.filter((icon) => icon.source === 'legacy-file').length : 0, rejected: rejectedIcons.length, }, }; }; export const syncRejectedUserIcons = async ( target: PoolClient, rejected: readonly RejectedLegacyUserIcon[], migratedAt: Date ): Promise => { if (rejected.length === 0) return { currentReset: 0, targetPreserved: 0 }; const accounts = await target.query<{ id: string; picture: string; image_server: number }>( `SELECT "id", "picture", "image_server" FROM "app_user" WHERE "id" = ANY($1::text[]) FOR UPDATE`, [rejected.map((icon) => icon.userId)] ); const byId = new Map(accounts.rows.map((account) => [account.id, account])); const counts = { currentReset: 0, targetPreserved: 0 }; for (const icon of rejected) { const account = byId.get(icon.userId); if (!account) throw new Error(`Imported member account is missing for member.${icon.memberNo}`); const sourceMatchesCurrent = account.picture === icon.sourcePicture || account.picture === icon.normalizedSourcePicture; if (sourceMatchesCurrent) { const reset = await target.query( `UPDATE "app_user" SET "picture" = 'default.jpg', "image_server" = 0, "icon_revision" = GREATEST( COALESCE("icon_revision", "icon_updated_at", "created_at"), $2::timestamptz ) WHERE "id" = $1 AND "picture" = $3 AND "image_server" = $4`, [icon.userId, migratedAt, account.picture, account.image_server] ); if (reset.rowCount !== 1) { throw new Error(`Target account icon changed concurrently for member.${icon.memberNo}`); } counts.currentReset += 1; } else { counts.targetPreserved += 1; } } return counts; }; export const syncImportedUserIcons = async ( target: PoolClient, icons: readonly PreparedLegacyUserIcon[], migratedAt: Date ): Promise => { if (icons.length === 0) { return { currentLinked: 0, libraryInserted: 0, libraryRetired: 0, targetPreserved: 0 }; } const userIds = icons.map((icon) => icon.userId); const accounts = await target.query<{ id: string; picture: string; image_server: number }>( `SELECT "id", "picture", "image_server" FROM "app_user" WHERE "id" = ANY($1::text[]) FOR UPDATE`, [userIds] ); const byId = new Map(accounts.rows.map((account) => [account.id, account])); const collisions = await target.query<{ picture: string }>( `SELECT imported."picture" FROM "user_icon" AS existing JOIN unnest($1::text[], $2::text[]) AS imported("user_id", "picture") ON imported."picture" = existing."picture" WHERE existing."user_id" <> imported."user_id" LIMIT 1`, [userIds, icons.map((icon) => icon.picture)] ); if (collisions.rowCount) { throw new Error('Legacy user icon picture is already owned by another target account'); } const counts: LegacyUserIconSyncCounts = { currentLinked: 0, libraryInserted: 0, libraryRetired: 0, targetPreserved: 0, }; for (const icon of icons) { const account = byId.get(icon.userId); if (!account) throw new Error(`Imported member account is missing for member.${icon.memberNo}`); const sourceMatchesCurrent = account.picture === icon.sourcePicture || account.picture === icon.normalizedSourcePicture; if (sourceMatchesCurrent && (account.picture !== icon.picture || account.image_server !== 0)) { const linked = await target.query( `UPDATE "app_user" SET "picture" = $2, "image_server" = 0, "icon_revision" = GREATEST( COALESCE("icon_revision", "icon_updated_at", "created_at"), $3::timestamptz ) WHERE "id" = $1 AND "picture" = $4 AND "image_server" = $5`, [icon.userId, icon.picture, migratedAt, account.picture, account.image_server] ); if (linked.rowCount !== 1) { throw new Error(`Target account icon changed concurrently for member.${icon.memberNo}`); } account.picture = icon.picture; account.image_server = 0; counts.currentLinked += 1; } else if (!sourceMatchesCurrent && account.picture !== icon.picture) { counts.targetPreserved += 1; } const retiredAt = account.picture === 'default.jpg' ? migratedAt : null; const inserted = await target.query( `INSERT INTO "user_icon" ("user_id", "picture", "image_server", "created_at", "retired_at") VALUES ($1, $2, 0, $3, $4) ON CONFLICT ("picture") DO NOTHING`, [icon.userId, icon.picture, icon.createdAt, retiredAt] ); counts.libraryInserted += inserted.rowCount ?? 0; if (retiredAt && inserted.rowCount) counts.libraryRetired += 1; } return counts; };