fix(migration): Ref 전용 아이콘을 업로드 API로 이관한다
전용 아이콘 바이트를 모두 검증한 뒤 sam-image 서명 API에 결정적 경로로 등록한다. API 반환 경로를 계정과 아이콘 소유 목록에 연결하고 Core에서 바뀐 현재 선택은 보존한다.
This commit is contained in:
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
|
||||
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
|
||||
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';
|
||||
import type { LegacyUserIconTransferConfig } from './legacyUserIcons.js';
|
||||
|
||||
export interface ResolvedMigrationStage {
|
||||
kind: 'gateway' | 'game';
|
||||
@@ -15,6 +16,7 @@ export interface ResolvedMigrationStage {
|
||||
targetUrl: string;
|
||||
sourceIdentity: MigrationSourceIdentity;
|
||||
battleResults?: BattleResultSourceConfig;
|
||||
userIcons?: LegacyUserIconTransferConfig;
|
||||
}
|
||||
|
||||
export interface ResolvedMigrationPlan {
|
||||
@@ -139,11 +141,48 @@ const resolveTargetUrl = (record: Record<string, unknown>, label: string): strin
|
||||
|
||||
const parseStage = (value: unknown, label: string): Record<string, unknown> => {
|
||||
const record = asRecord(value, label);
|
||||
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults'], label);
|
||||
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults', 'userIcons'], label);
|
||||
if (!('source' in record)) throw new Error(`${label}.source is required`);
|
||||
return record;
|
||||
};
|
||||
|
||||
const resolveWebBaseUrl = (value: string, label: string): string => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} must be an absolute URL`, { cause: error });
|
||||
}
|
||||
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
||||
throw new Error(`${label} must use HTTPS except for a loopback test service`);
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new Error(`${label} must not contain credentials, a query, or a fragment`);
|
||||
}
|
||||
return url.toString().replace(/\/$/u, '');
|
||||
};
|
||||
|
||||
const resolveUserIcons = async (
|
||||
value: unknown,
|
||||
configDirectory: string,
|
||||
label: string
|
||||
): Promise<LegacyUserIconTransferConfig> => {
|
||||
const record = asRecord(value, label);
|
||||
rejectUnknownKeys(record, ['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile'], label);
|
||||
const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label));
|
||||
const sourceInfo = await lstat(sourceDirectory);
|
||||
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
|
||||
throw new Error(`${label}.sourceDirectory must be a directory and not a symbolic link`);
|
||||
}
|
||||
const uploadBaseUrl = resolveWebBaseUrl(requiredString(record, 'uploadBaseUrl', label), `${label}.uploadBaseUrl`);
|
||||
const publicBaseUrl = resolveWebBaseUrl(requiredString(record, 'publicBaseUrl', label), `${label}.publicBaseUrl`);
|
||||
const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label));
|
||||
const uploadSecret = (await readSecureText(secretPath, `${label}.uploadSecretFile`)).replace(/\r?\n$/u, '');
|
||||
if (uploadSecret.length < 32) throw new Error(`${label}.uploadSecretFile must contain at least 32 characters`);
|
||||
return { sourceDirectory, uploadBaseUrl, publicBaseUrl, uploadSecret };
|
||||
};
|
||||
|
||||
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
|
||||
const configPath = path.resolve(configPathInput);
|
||||
const rawText = await readSecureText(configPath, 'Migration config');
|
||||
@@ -164,6 +203,10 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
|
||||
if (root.gateway !== undefined) {
|
||||
const gateway = parseStage(root.gateway, 'gateway');
|
||||
const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source');
|
||||
const userIcons =
|
||||
gateway.userIcons === undefined
|
||||
? undefined
|
||||
: await resolveUserIcons(gateway.userIcons, configDirectory, 'gateway.userIcons');
|
||||
stages.push({
|
||||
kind: 'gateway',
|
||||
name: 'gateway',
|
||||
@@ -173,6 +216,7 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
|
||||
key: `${sourceSet}:gateway`,
|
||||
fingerprint: fingerprintMariaConnection(sourceUrl),
|
||||
},
|
||||
...(userIcons ? { userIcons } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
type MigrationExecutionOptions,
|
||||
type MigrationProgress,
|
||||
} from './incremental.js';
|
||||
import {
|
||||
normalizeLegacyIconPicture,
|
||||
prepareLegacyUserIcons,
|
||||
syncImportedUserIcons,
|
||||
type LegacyUserIconPreparation,
|
||||
type LegacyUserIconTransferConfig,
|
||||
type PreparedLegacyUserIcon,
|
||||
} from './legacyUserIcons.js';
|
||||
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
|
||||
|
||||
export interface MigrationSummary {
|
||||
@@ -105,7 +113,14 @@ export const preflightMemberConflicts = async (target: PoolClient, rows: readonl
|
||||
}
|
||||
};
|
||||
|
||||
export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
|
||||
export { normalizeLegacyIconPicture } from './legacyUserIcons.js';
|
||||
|
||||
export const mapMember = (
|
||||
row: SourceRow,
|
||||
migratedAt: Date,
|
||||
lastLoginAt: Date | null,
|
||||
importedIcon?: PreparedLegacyUserIcon
|
||||
): TargetRow => {
|
||||
const memberNo = toNumber(row.NO, 'member.NO');
|
||||
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
||||
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
|
||||
@@ -114,11 +129,16 @@ export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date |
|
||||
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
|
||||
const oauthId = toNullableString(row.oauth_id)?.trim() || null;
|
||||
const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`);
|
||||
const rawPicture = toNullableString(row.PICTURE) ?? 'default.jpg';
|
||||
const imageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
|
||||
const legacyData: JsonValue = {
|
||||
memberNo,
|
||||
grade,
|
||||
acl,
|
||||
penalty,
|
||||
picture: rawPicture,
|
||||
imageServer,
|
||||
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
|
||||
tokenValidUntil: toNullableString(row.token_valid_until),
|
||||
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
|
||||
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
|
||||
@@ -137,8 +157,8 @@ export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date |
|
||||
oauth_id: oauthId,
|
||||
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
||||
oauth_info: jsonParameter(oauthInfo),
|
||||
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
|
||||
image_server: toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`),
|
||||
picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture),
|
||||
image_server: importedIcon?.imageServer ?? imageServer,
|
||||
icon_updated_at: null,
|
||||
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
||||
terms_accepted_at: null,
|
||||
@@ -175,19 +195,39 @@ const processMembers = async (
|
||||
target: PoolClient | null,
|
||||
apply: boolean,
|
||||
migratedAt: Date,
|
||||
counts: Record<string, number>
|
||||
counts: Record<string, number>,
|
||||
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>
|
||||
): Promise<void> => {
|
||||
const lastLogins = await loadLastLogins(source);
|
||||
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
|
||||
const mapped = rows.map((row) => {
|
||||
const memberNo = toNumber(row.NO, 'member.NO');
|
||||
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
|
||||
const importedIcon = preparedIcons.get(memberNo);
|
||||
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
|
||||
if (
|
||||
(sourcePicture !== 'default.jpg' && !importedIcon) ||
|
||||
(importedIcon && importedIcon.sourcePicture !== sourcePicture)
|
||||
) {
|
||||
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
|
||||
}
|
||||
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon);
|
||||
});
|
||||
if (target) {
|
||||
await preflightMemberConflicts(target, mapped);
|
||||
}
|
||||
if (target && apply) {
|
||||
await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS });
|
||||
const synced = await syncImportedUserIcons(
|
||||
target,
|
||||
rows
|
||||
.map((row) => preparedIcons.get(toNumber(row.NO, 'member.NO')))
|
||||
.filter((icon): icon is PreparedLegacyUserIcon => Boolean(icon)),
|
||||
migratedAt
|
||||
);
|
||||
counts.user_icon_current_linked = (counts.user_icon_current_linked ?? 0) + synced.currentLinked;
|
||||
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted;
|
||||
counts.user_icon_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
|
||||
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
|
||||
}
|
||||
counts.member = (counts.member ?? 0) + mapped.length;
|
||||
}
|
||||
@@ -285,7 +325,8 @@ export const migrateGateway = async (
|
||||
targetPool: PgPool | null,
|
||||
apply: boolean,
|
||||
migratedAt: Date,
|
||||
execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root')
|
||||
execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root'),
|
||||
userIconConfig?: LegacyUserIconTransferConfig
|
||||
): Promise<MigrationSummary> => {
|
||||
validateSourceIdentity(execution.source);
|
||||
if (execution.mode === 'incremental' && !targetPool) {
|
||||
@@ -300,8 +341,20 @@ export const migrateGateway = async (
|
||||
const client = targetPool ? await targetPool.connect() : null;
|
||||
let importRunId: string | null = null;
|
||||
try {
|
||||
const run = async (runId: string | null): Promise<void> => {
|
||||
await processMembers(source, client, apply, migratedAt, counts);
|
||||
const sourceIconRows = await querySource(
|
||||
source,
|
||||
`SELECT NO, PICTURE, IMGSVR, REG_DATE
|
||||
FROM member WHERE PICTURE <> 'default.jpg' ORDER BY NO`
|
||||
);
|
||||
const recordIconCounts = (prepared: LegacyUserIconPreparation): void => {
|
||||
counts.user_icon_source = prepared.counts.custom;
|
||||
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
|
||||
counts.user_icon_existing_upload = prepared.counts.existingUploads;
|
||||
counts.user_icon_uploaded = prepared.counts.uploaded;
|
||||
};
|
||||
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
|
||||
recordIconCounts(prepared);
|
||||
await processMembers(source, client, apply, migratedAt, counts, prepared.icons);
|
||||
progress.member = {
|
||||
strategy: 'rescan',
|
||||
startAfterId: null,
|
||||
@@ -365,9 +418,13 @@ export const migrateGateway = async (
|
||||
);
|
||||
importRunId = created.rows[0]?.id ?? null;
|
||||
if (!importRunId) throw new Error('Failed to create legacy gateway import run');
|
||||
await client.query('BEGIN');
|
||||
let transactionStarted = false;
|
||||
try {
|
||||
await run(importRunId);
|
||||
const prepared = await prepareLegacyUserIcons(sourceIconRows, userIconConfig, true);
|
||||
recordIconCounts(prepared);
|
||||
await client.query('BEGIN');
|
||||
transactionStarted = true;
|
||||
await run(importRunId, prepared);
|
||||
await client.query(
|
||||
`UPDATE "legacy_import_run"
|
||||
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
|
||||
@@ -376,8 +433,9 @@ export const migrateGateway = async (
|
||||
[importRunId, JSON.stringify(counts), JSON.stringify(progress)]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
transactionStarted = false;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
if (transactionStarted) await client.query('ROLLBACK');
|
||||
const message =
|
||||
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
|
||||
await client.query(
|
||||
@@ -391,7 +449,8 @@ export const migrateGateway = async (
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await run(null);
|
||||
const prepared = await prepareLegacyUserIcons(sourceIconRows, userIconConfig, false);
|
||||
await run(null, prepared);
|
||||
}
|
||||
} finally {
|
||||
client?.release();
|
||||
|
||||
@@ -10,9 +10,9 @@ export interface MigrationInventoryItem {
|
||||
export const GATEWAY_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [
|
||||
{
|
||||
source: 'member',
|
||||
target: 'app_user + legacy_data',
|
||||
target: 'app_user + user_icon + legacy_data',
|
||||
strategy: 'rescan',
|
||||
contents: '계정 식별자, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료',
|
||||
contents: '계정 식별자, 전용 아이콘 목록, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료',
|
||||
},
|
||||
{
|
||||
source: 'member_log',
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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>;
|
||||
counts: {
|
||||
custom: number;
|
||||
legacyFiles: number;
|
||||
existingUploads: number;
|
||||
uploaded: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LegacyUserIconSyncCounts {
|
||||
currentLinked: number;
|
||||
libraryInserted: number;
|
||||
libraryRetired: number;
|
||||
targetPreserved: number;
|
||||
}
|
||||
|
||||
interface ValidatedImage {
|
||||
body: Buffer;
|
||||
extension: string;
|
||||
contentType: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
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 Error(`${label} must be non-empty and at most 50 KiB`);
|
||||
}
|
||||
let metadata: { mediaType?: string; format?: string; width?: number; height?: number };
|
||||
try {
|
||||
metadata = await sharp(body, { animated: true }).metadata();
|
||||
} catch (error) {
|
||||
throw new Error(`${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 Error(`${label} must be avif, webp, jpeg, png, or gif`);
|
||||
}
|
||||
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
|
||||
throw new Error(`${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 Error(`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(), counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 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 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`);
|
||||
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
|
||||
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
|
||||
return {
|
||||
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);
|
||||
return {
|
||||
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)}`,
|
||||
};
|
||||
});
|
||||
const pictures = new Map<string, number>();
|
||||
for (const icon of validated) {
|
||||
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(validated, 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])),
|
||||
counts: {
|
||||
custom: prepared.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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { migrateGateway, type MigrationSummary } from './gateway.js';
|
||||
import type { MigrationMode } from './incremental.js';
|
||||
import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js';
|
||||
import { migrationInventoryForStage } from './inventory.js';
|
||||
import { prepareLegacyUserIcons } from './legacyUserIcons.js';
|
||||
|
||||
export interface PlanRunSummary {
|
||||
command: 'run-plan';
|
||||
@@ -23,6 +24,7 @@ export interface PlanRunSummary {
|
||||
interface StagePreflight {
|
||||
battleResults?: { seasons: number; files: number; bytes: number };
|
||||
battleResultManifests?: readonly BattleResultSeasonManifest[];
|
||||
userIcons?: { custom: number; legacyFiles: number; existingUploads: number };
|
||||
}
|
||||
|
||||
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
|
||||
@@ -76,7 +78,22 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
|
||||
if (!targetReady.rows[0]?.table_name) {
|
||||
throw new Error(`Target migrations are not current for ${stage.name}; missing ${targetDataTable}`);
|
||||
}
|
||||
if (stage.kind === 'game' && stage.battleResults) {
|
||||
if (stage.kind === 'gateway') {
|
||||
const iconRows = await querySource(
|
||||
source,
|
||||
`SELECT NO, PICTURE, IMGSVR, REG_DATE
|
||||
FROM member WHERE PICTURE <> 'default.jpg' ORDER BY NO`
|
||||
);
|
||||
const prepared = await prepareLegacyUserIcons(iconRows, stage.userIcons, false);
|
||||
return {
|
||||
userIcons: {
|
||||
custom: prepared.counts.custom,
|
||||
legacyFiles: prepared.counts.legacyFiles,
|
||||
existingUploads: prepared.counts.existingUploads,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (stage.battleResults) {
|
||||
const battleResultReady = await target.query<{ table_name: string | null }>(
|
||||
'SELECT to_regclass($1) AS table_name',
|
||||
['legacy_archive.general_battle_result']
|
||||
@@ -113,6 +130,7 @@ export const checkMigrationPlan = async (plan: ResolvedMigrationPlan): Promise<R
|
||||
status: 'READY',
|
||||
inventory: migrationInventoryForStage(stage),
|
||||
...(preflight.battleResults ? { battleResults: preflight.battleResults } : {}),
|
||||
...(preflight.userIcons ? { userIcons: preflight.userIcons } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -140,7 +158,7 @@ export const runMigrationPlan = async (
|
||||
const execution = { mode, source: stage.sourceIdentity } as const;
|
||||
const summary =
|
||||
stage.kind === 'gateway'
|
||||
? await migrateGateway(source, target, apply, migratedAt, execution)
|
||||
? await migrateGateway(source, target, apply, migratedAt, execution, stage.userIcons)
|
||||
: await migrateGame(source, target, apply, stage.profile!, execution);
|
||||
const battleResults =
|
||||
stage.kind === 'game' && stage.battleResults
|
||||
|
||||
Reference in New Issue
Block a user