Files
core2026/tools/legacy-db-migration/src/legacyUserIcons.ts
T
Hide_D cd0f50f89d fix(migration): 검증 실패 아이콘을 명시적으로 제외한다
검토한 회원 번호의 이미지가 계속 유효성 조건을 어길 때만 업로드에서 제외한다. 기존 Ref 경로가 현재 선택인 경우 기본 아이콘으로 정리하고 최신 Core 선택은 보존한다.
2026-08-24 08:45:47 +00:00

511 lines
21 KiB
TypeScript

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<string, string> = {
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<number, PreparedLegacyUserIcon>;
rejected: Map<number, RejectedLegacyUserIcon>;
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<ValidatedImage> => {
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<ValidatedImage> => {
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<ValidatedImage> => {
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<string> => {
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 <T, R>(
values: readonly T[],
concurrency: number,
mapper: (value: T) => Promise<R>
): Promise<R[]> => {
const results = new Array<R>(values.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
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<LegacyUserIconPreparation> => {
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<string, number>();
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<RejectedLegacyUserIconSyncCounts> => {
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<LegacyUserIconSyncCounts> => {
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;
};