merge: 최신 main을 외교 권한 복수 임명 수정에 최종 통합한다

This commit is contained in:
2026-08-24 08:47:05 +00:00
9 changed files with 313 additions and 49 deletions
+8
View File
@@ -86,6 +86,14 @@ is not silently resurrected. The original Ref path, `IMGSVR`, returned path and
byte SHA-256 remain in `legacy_data`. Picture collisions across owners fail the byte SHA-256 remain in `legacy_data`. Picture collisions across owners fail the
transaction. transaction.
Historical bytes that violate the Ref validation contract block preflight.
Operators may list a reviewed member in `excludedMemberNumbers`; the importer
then proves the file is still invalid and records the reason. It never uploads
that byte or creates a `user_icon` row. If the target still selects the rejected
Ref path it moves only that selection to `default.jpg`; a newer Core selection
is preserved. A stale exclusion whose file has become valid also blocks the
plan, so this cannot become a general skip-errors switch.
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
required before an imported row is marked Kakao-verified. A parseable legacy required before an imported row is marked Kakao-verified. A parseable legacy
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the `token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
+6
View File
@@ -44,6 +44,12 @@ is mandatory. Mount Ref's `d_pic` directory read-only as `sourceDirectory`, and
mount the Core2026 sam-image upload secret as a mode-0600 `uploadSecretFile`. mount the Core2026 sam-image upload secret as a mode-0600 `uploadSecretFile`.
The two URL fields normally point to `https://sam-image.hided.net` and its The two URL fields normally point to `https://sam-image.hided.net` and its
`/icons` path. The importer never adds account images to the image Git tree. `/icons` path. The importer never adds account images to the image Git tree.
An invalid historical file blocks the plan by default. After byte-level review,
its member number may be listed in `excludedMemberNumbers`; the exclusion is
accepted only while that exact member still has invalid image geometry/format.
A valid file or stale/missing member exclusion fails closed. An unchanged
invalid Ref selection is reset to the default icon instead of publishing bad
bytes; a newer Core selection is preserved.
```sh ```sh
mkdir -p tools/legacy-db-migration/secrets mkdir -p tools/legacy-db-migration/secrets
@@ -14,7 +14,8 @@
"sourceDirectory": "/run/sammo-migration/user-icons", "sourceDirectory": "/run/sammo-migration/user-icons",
"uploadBaseUrl": "https://sam-image.hided.net", "uploadBaseUrl": "https://sam-image.hided.net",
"publicBaseUrl": "https://sam-image.hided.net/icons", "publicBaseUrl": "https://sam-image.hided.net/icons",
"uploadSecretFile": "/run/secrets/image_upload_core2026_secret" "uploadSecretFile": "/run/secrets/image_upload_core2026_secret",
"excludedMemberNumbers": []
}, },
"targetUrlEnv": "GATEWAY_DATABASE_URL" "targetUrlEnv": "GATEWAY_DATABASE_URL"
}, },
+19 -2
View File
@@ -169,7 +169,11 @@ const resolveUserIcons = async (
label: string label: string
): Promise<LegacyUserIconTransferConfig> => { ): Promise<LegacyUserIconTransferConfig> => {
const record = asRecord(value, label); const record = asRecord(value, label);
rejectUnknownKeys(record, ['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile'], label); rejectUnknownKeys(
record,
['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile', 'excludedMemberNumbers'],
label
);
const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label)); const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label));
const sourceInfo = await lstat(sourceDirectory); const sourceInfo = await lstat(sourceDirectory);
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
@@ -180,7 +184,20 @@ const resolveUserIcons = async (
const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label)); const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label));
const uploadSecret = (await readSecureText(secretPath, `${label}.uploadSecretFile`)).replace(/\r?\n$/u, ''); 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`); if (uploadSecret.length < 32) throw new Error(`${label}.uploadSecretFile must contain at least 32 characters`);
return { sourceDirectory, uploadBaseUrl, publicBaseUrl, uploadSecret }; const excludedMemberNumbers = record.excludedMemberNumbers ?? [];
if (
!Array.isArray(excludedMemberNumbers) ||
excludedMemberNumbers.some((value) => !Number.isSafeInteger(value) || Number(value) <= 0)
) {
throw new Error(`${label}.excludedMemberNumbers must contain only positive safe integers`);
}
return {
sourceDirectory,
uploadBaseUrl,
publicBaseUrl,
uploadSecret,
excludedMemberNumbers: excludedMemberNumbers as number[],
};
}; };
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => { export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
+27 -8
View File
@@ -31,9 +31,11 @@ import {
normalizeLegacyIconPicture, normalizeLegacyIconPicture,
prepareLegacyUserIcons, prepareLegacyUserIcons,
syncImportedUserIcons, syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconPreparation, type LegacyUserIconPreparation,
type LegacyUserIconTransferConfig, type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon, type PreparedLegacyUserIcon,
type RejectedLegacyUserIcon,
} from './legacyUserIcons.js'; } from './legacyUserIcons.js';
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js'; import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
@@ -119,7 +121,8 @@ export const mapMember = (
row: SourceRow, row: SourceRow,
migratedAt: Date, migratedAt: Date,
lastLoginAt: Date | null, lastLoginAt: Date | null,
importedIcon?: PreparedLegacyUserIcon importedIcon?: PreparedLegacyUserIcon,
rejectedIcon?: RejectedLegacyUserIcon
): TargetRow => { ): TargetRow => {
const memberNo = toNumber(row.NO, 'member.NO'); const memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`); const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
@@ -139,6 +142,7 @@ export const mapMember = (
picture: rawPicture, picture: rawPicture,
imageServer, imageServer,
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}), ...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
...(rejectedIcon ? { rejectedPictureReason: rejectedIcon.reason } : {}),
tokenValidUntil: toNullableString(row.token_valid_until), tokenValidUntil: toNullableString(row.token_valid_until),
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`), regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`), blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
@@ -157,8 +161,8 @@ export const mapMember = (
oauth_id: oauthId, oauth_id: oauthId,
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null, email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo), oauth_info: jsonParameter(oauthInfo),
picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture), picture: importedIcon?.picture ?? (rejectedIcon ? 'default.jpg' : normalizeLegacyIconPicture(rawPicture)),
image_server: importedIcon?.imageServer ?? imageServer, image_server: importedIcon?.imageServer ?? (rejectedIcon ? 0 : imageServer),
icon_updated_at: null, icon_updated_at: null,
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0, third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null, terms_accepted_at: null,
@@ -196,21 +200,24 @@ const processMembers = async (
apply: boolean, apply: boolean,
migratedAt: Date, migratedAt: Date,
counts: Record<string, number>, counts: Record<string, number>,
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon> preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>,
rejectedIcons: ReadonlyMap<number, RejectedLegacyUserIcon>
): Promise<void> => { ): Promise<void> => {
const lastLogins = await loadLastLogins(source); const lastLogins = await loadLastLogins(source);
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) { for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
const mapped = rows.map((row) => { const mapped = rows.map((row) => {
const memberNo = toNumber(row.NO, 'member.NO'); const memberNo = toNumber(row.NO, 'member.NO');
const importedIcon = preparedIcons.get(memberNo); const importedIcon = preparedIcons.get(memberNo);
const rejectedIcon = rejectedIcons.get(memberNo);
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg'; const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
if ( if (
(sourcePicture !== 'default.jpg' && !importedIcon) || (sourcePicture !== 'default.jpg' && !importedIcon && !rejectedIcon) ||
(importedIcon && importedIcon.sourcePicture !== sourcePicture) (importedIcon && importedIcon.sourcePicture !== sourcePicture) ||
(rejectedIcon && rejectedIcon.sourcePicture !== sourcePicture)
) { ) {
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`); throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
} }
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon); return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon, rejectedIcon);
}); });
if (target) { if (target) {
await preflightMemberConflicts(target, mapped); await preflightMemberConflicts(target, mapped);
@@ -228,6 +235,17 @@ const processMembers = async (
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted; 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_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved; counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
const rejected = await syncRejectedUserIcons(
target,
rows
.map((row) => rejectedIcons.get(toNumber(row.NO, 'member.NO')))
.filter((icon): icon is RejectedLegacyUserIcon => Boolean(icon)),
migratedAt
);
counts.user_icon_rejected_current_reset =
(counts.user_icon_rejected_current_reset ?? 0) + rejected.currentReset;
counts.user_icon_rejected_target_preserved =
(counts.user_icon_rejected_target_preserved ?? 0) + rejected.targetPreserved;
} }
counts.member = (counts.member ?? 0) + mapped.length; counts.member = (counts.member ?? 0) + mapped.length;
} }
@@ -351,10 +369,11 @@ export const migrateGateway = async (
counts.user_icon_legacy_file = prepared.counts.legacyFiles; counts.user_icon_legacy_file = prepared.counts.legacyFiles;
counts.user_icon_existing_upload = prepared.counts.existingUploads; counts.user_icon_existing_upload = prepared.counts.existingUploads;
counts.user_icon_uploaded = prepared.counts.uploaded; counts.user_icon_uploaded = prepared.counts.uploaded;
counts.user_icon_rejected = prepared.counts.rejected;
}; };
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => { const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
recordIconCounts(prepared); recordIconCounts(prepared);
await processMembers(source, client, apply, migratedAt, counts, prepared.icons); await processMembers(source, client, apply, migratedAt, counts, prepared.icons, prepared.rejected);
progress.member = { progress.member = {
strategy: 'rescan', strategy: 'rescan',
startAfterId: null, startAfterId: null,
+118 -11
View File
@@ -27,6 +27,7 @@ export interface LegacyUserIconTransferConfig {
uploadBaseUrl: string; uploadBaseUrl: string;
publicBaseUrl: string; publicBaseUrl: string;
uploadSecret: string; uploadSecret: string;
excludedMemberNumbers: readonly number[];
} }
export interface PreparedLegacyUserIcon { export interface PreparedLegacyUserIcon {
@@ -44,14 +45,25 @@ export interface PreparedLegacyUserIcon {
export interface LegacyUserIconPreparation { export interface LegacyUserIconPreparation {
icons: Map<number, PreparedLegacyUserIcon>; icons: Map<number, PreparedLegacyUserIcon>;
rejected: Map<number, RejectedLegacyUserIcon>;
counts: { counts: {
custom: number; custom: number;
legacyFiles: number; legacyFiles: number;
existingUploads: number; existingUploads: number;
uploaded: number; uploaded: number;
rejected: number;
}; };
} }
export interface RejectedLegacyUserIcon {
memberNo: number;
userId: string;
sourcePicture: string;
normalizedSourcePicture: string;
sourceImageServer: number;
reason: string;
}
export interface LegacyUserIconSyncCounts { export interface LegacyUserIconSyncCounts {
currentLinked: number; currentLinked: number;
libraryInserted: number; libraryInserted: number;
@@ -59,6 +71,11 @@ export interface LegacyUserIconSyncCounts {
targetPreserved: number; targetPreserved: number;
} }
export interface RejectedLegacyUserIconSyncCounts {
currentReset: number;
targetPreserved: number;
}
interface ValidatedImage { interface ValidatedImage {
body: Buffer; body: Buffer;
extension: string; extension: string;
@@ -66,6 +83,8 @@ interface ValidatedImage {
sha256: string; sha256: string;
} }
class LegacyUserIconValidationError extends Error {}
const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/'); const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, ''); export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, '');
@@ -82,21 +101,29 @@ const iconCreatedAt = (sourcePicture: string, fallback: Date): Date => {
const validateImage = async (body: Buffer, label: string): Promise<ValidatedImage> => { const validateImage = async (body: Buffer, label: string): Promise<ValidatedImage> => {
if (body.length === 0 || body.length > MAX_ICON_BYTES) { if (body.length === 0 || body.length > MAX_ICON_BYTES) {
throw new Error(`${label} must be non-empty and at most 50 KiB`); throw new LegacyUserIconValidationError(`${label} must be non-empty and at most 50 KiB`);
} }
let metadata: { mediaType?: string; format?: string; width?: number; height?: number }; let metadata: {
mediaType?: string;
format?: string;
width?: number;
height?: number;
pageHeight?: number;
pages?: number;
};
try { try {
metadata = await sharp(body, { animated: true }).metadata(); metadata = await sharp(body, { animated: true }).metadata();
} catch (error) { } catch (error) {
throw new Error(`${label} is not a decodable image`, { cause: error }); throw new LegacyUserIconValidationError(`${label} is not a decodable image`, { cause: error });
} }
const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format; const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
const extension = detected === 'jpeg' ? 'jpg' : detected; const extension = detected === 'jpeg' ? 'jpg' : detected;
if (!extension || !CONTENT_TYPES[extension]) { if (!extension || !CONTENT_TYPES[extension]) {
throw new Error(`${label} must be avif, webp, jpeg, png, or gif`); throw new LegacyUserIconValidationError(`${label} must be avif, webp, jpeg, png, or gif`);
} }
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) { const frameHeight = metadata.pages && metadata.pages > 1 ? metadata.pageHeight : metadata.height;
throw new Error(`${label} must be a square image from 64x64 through 128x128`); 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 { return {
body, body,
@@ -119,7 +146,7 @@ const readLegacyIcon = async (directory: string, picture: string, memberNo: numb
throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`); throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`);
} }
if (info.size === 0 || info.size > MAX_ICON_BYTES) { if (info.size === 0 || info.size > MAX_ICON_BYTES) {
throw new Error(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`); 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); const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
try { try {
@@ -248,22 +275,35 @@ export const prepareLegacyUserIcons = async (
throw new Error('Gateway source has custom icons but gateway.userIcons is not configured'); throw new Error('Gateway source has custom icons but gateway.userIcons is not configured');
} }
if (!config) { if (!config) {
return { icons: new Map(), counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0 } }; return {
icons: new Map(),
rejected: new Map(),
counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0, rejected: 0 },
};
} }
if (config.uploadSecret.length < 32) { if (config.uploadSecret.length < 32) {
throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters'); throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters');
} }
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const now = options.now ?? Date.now; 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 validated = await mapWithConcurrency(customRows, options.concurrency ?? 8, async (row) => {
const memberNo = toNumber(row.NO, 'member.NO'); const memberNo = toNumber(row.NO, 'member.NO');
const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`); const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`);
const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture); const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture);
const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`); const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`); const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`);
try {
if (REMOTE_PICTURE.test(normalizedSourcePicture)) { if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl); 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 { return {
kind: 'icon' as const,
base: { base: {
memberNo, memberNo,
userId: legacyUserId(memberNo), userId: legacyUserId(memberNo),
@@ -283,7 +323,11 @@ export const prepareLegacyUserIcons = async (
throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`); throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`);
} }
const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo); 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 { return {
kind: 'icon' as const,
base: { base: {
memberNo, memberNo,
userId: legacyUserId(memberNo), userId: legacyUserId(memberNo),
@@ -298,16 +342,38 @@ export const prepareLegacyUserIcons = async (
image, image,
picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, 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>(); const pictures = new Map<string, number>();
for (const icon of validated) { for (const icon of validIcons) {
const owner = pictures.get(icon.picture); const owner = pictures.get(icon.picture);
if (owner !== undefined && owner !== icon.base.memberNo) { if (owner !== undefined && owner !== icon.base.memberNo) {
throw new Error('Legacy user icon picture is shared by multiple source accounts'); throw new Error('Legacy user icon picture is shared by multiple source accounts');
} }
pictures.set(icon.picture, icon.base.memberNo); pictures.set(icon.picture, icon.base.memberNo);
} }
const prepared = await mapWithConcurrency(validated, options.concurrency ?? 8, async (icon) => { const prepared = await mapWithConcurrency(validIcons, options.concurrency ?? 8, async (icon) => {
const picture = const picture =
apply && icon.base.source === 'legacy-file' apply && icon.base.source === 'legacy-file'
? await uploadLegacyIcon( ? await uploadLegacyIcon(
@@ -323,15 +389,56 @@ export const prepareLegacyUserIcons = async (
}); });
return { return {
icons: new Map(prepared.map((icon) => [icon.memberNo, icon])), icons: new Map(prepared.map((icon) => [icon.memberNo, icon])),
rejected: new Map(rejectedIcons.map((icon) => [icon.memberNo, icon])),
counts: { counts: {
custom: prepared.length, custom: validated.length,
legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length, legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length,
existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length, existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length,
uploaded: apply ? prepared.filter((icon) => icon.source === 'legacy-file').length : 0, 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 ( export const syncImportedUserIcons = async (
target: PoolClient, target: PoolClient,
icons: readonly PreparedLegacyUserIcon[], icons: readonly PreparedLegacyUserIcon[],
+2 -1
View File
@@ -24,7 +24,7 @@ export interface PlanRunSummary {
interface StagePreflight { interface StagePreflight {
battleResults?: { seasons: number; files: number; bytes: number }; battleResults?: { seasons: number; files: number; bytes: number };
battleResultManifests?: readonly BattleResultSeasonManifest[]; battleResultManifests?: readonly BattleResultSeasonManifest[];
userIcons?: { custom: number; legacyFiles: number; existingUploads: number }; userIcons?: { custom: number; legacyFiles: number; existingUploads: number; rejected: number };
} }
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => { const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
@@ -90,6 +90,7 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
custom: prepared.counts.custom, custom: prepared.counts.custom,
legacyFiles: prepared.counts.legacyFiles, legacyFiles: prepared.counts.legacyFiles,
existingUploads: prepared.counts.existingUploads, existingUploads: prepared.counts.existingUploads,
rejected: prepared.counts.rejected,
}, },
}; };
} }
@@ -2,7 +2,7 @@ import { Pool } from 'pg';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js'; import { legacyUserId } from '../src/identity.js';
import { syncImportedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js'; import { syncImportedUserIcons, syncRejectedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
const databaseUrl = process.env.LEGACY_ICON_TEST_DATABASE_URL; const databaseUrl = process.env.LEGACY_ICON_TEST_DATABASE_URL;
@@ -69,6 +69,37 @@ describe.skipIf(!databaseUrl)('legacy user icon PostgreSQL synchronization', ()
); );
expect(library.rows).toHaveLength(3); expect(library.rows).toHaveLength(3);
expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1); expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1);
const rejectedUserId = legacyUserId(700_004);
await client.query(
`INSERT INTO "app_user"
("id", "login_id", "display_name", "password_hash", "password_salt",
"updated_at", "picture", "image_server")
VALUES ($1, 'icon-test-rejected', '아이콘테스트-제외', 'hash', 'salt',
CURRENT_TIMESTAMP, 'invalid.gif?=20260809', 1)`,
[rejectedUserId]
);
await expect(
syncRejectedUserIcons(
client,
[
{
memberNo: 700_004,
userId: rejectedUserId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
},
],
new Date('2026-08-24T00:00:00Z')
)
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
const rejectedAccount = await client.query<{ picture: string; image_server: number }>(
`SELECT "picture", "image_server" FROM "app_user" WHERE "id" = $1`,
[rejectedUserId]
);
expect(rejectedAccount.rows[0]).toEqual({ picture: 'default.jpg', image_server: 0 });
} finally { } finally {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
client.release(); client.release();
@@ -11,6 +11,7 @@ import { legacyUserId } from '../src/identity.js';
import { import {
prepareLegacyUserIcons, prepareLegacyUserIcons,
syncImportedUserIcons, syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconTransferConfig, type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon, type PreparedLegacyUserIcon,
} from '../src/legacyUserIcons.js'; } from '../src/legacyUserIcons.js';
@@ -36,6 +37,7 @@ const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig;
uploadBaseUrl: 'https://upload.test', uploadBaseUrl: 'https://upload.test',
publicBaseUrl: 'https://public.test/icons', publicBaseUrl: 'https://public.test/icons',
uploadSecret: 's'.repeat(32), uploadSecret: 's'.repeat(32),
excludedMemberNumbers: [],
}, },
png, png,
}; };
@@ -57,7 +59,13 @@ describe('legacy user icon transfer', () => {
const first = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl }); const first = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
const second = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl }); const second = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
expect(first.counts).toEqual({ custom: 1, legacyFiles: 1, existingUploads: 0, uploaded: 0 }); expect(first.counts).toEqual({
custom: 1,
legacyFiles: 1,
existingUploads: 0,
uploaded: 0,
rejected: 0,
});
expect(first.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u); expect(first.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u);
expect(second.icons.get(7)?.picture).toBe(first.icons.get(7)?.picture); expect(second.icons.get(7)?.picture).toBe(first.icons.get(7)?.picture);
expect(fetchImpl).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled();
@@ -123,7 +131,13 @@ describe('legacy user icon transfer', () => {
{ fetchImpl } { fetchImpl }
); );
expect(result.counts).toEqual({ custom: 1, legacyFiles: 0, existingUploads: 1, uploaded: 0 }); expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 1,
uploaded: 0,
rejected: 0,
});
expect(result.icons.get(7)?.picture).toBe(picture); expect(result.icons.get(7)?.picture).toBe(picture);
expect(fetchImpl).toHaveBeenCalledTimes(1); expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(`https://public.test/icons/${picture}`); expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(`https://public.test/icons/${picture}`);
@@ -135,6 +149,40 @@ describe('legacy user icon transfer', () => {
); );
}); });
it('permits only an explicitly reviewed member exclusion whose bytes remain invalid', async () => {
const { config } = await createFixture();
const invalidGif = await sharp({
create: { width: 64, height: 65, channels: 4, background: '#336699ff' },
})
.gif()
.toBuffer();
await writeFile(path.join(config.sourceDirectory, 'invalid.gif'), invalidGif);
config.excludedMemberNumbers = [7];
const result = await prepareLegacyUserIcons([sourceRow({ PICTURE: 'invalid.gif?=20260809' })], config, true, {
fetchImpl: vi.fn<typeof fetch>(),
});
expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 0,
uploaded: 0,
rejected: 1,
});
expect(result.rejected.get(7)?.reason).toContain('square image');
expect(result.icons.size).toBe(0);
});
it('rejects a stale exclusion when the configured member icon is valid', async () => {
const { config } = await createFixture();
config.excludedMemberNumbers = [7];
await expect(prepareLegacyUserIcons([sourceRow()], config, false)).rejects.toThrow(
'configured as excluded but its icon is valid'
);
});
it('links an unchanged Ref selection while preserving newer Core selections', async () => { it('links an unchanged Ref selection while preserving newer Core selections', async () => {
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({ const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo, memberNo,
@@ -179,4 +227,30 @@ describe('legacy user icon transfer', () => {
expect(inserts).toHaveLength(3); expect(inserts).toHaveLength(3);
expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z')); expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z'));
}); });
it('resets only the still-selected invalid Ref icon', async () => {
const userId = legacyUserId(7);
const rejected = {
memberNo: 7,
userId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
};
const query = vi.fn(async (sql: string, _parameters?: readonly unknown[]) => {
if (sql.includes('FROM "app_user"')) {
return {
rows: [{ id: userId, picture: rejected.sourcePicture, image_server: 1 }],
rowCount: 1,
} as QueryResult;
}
return { rows: [], rowCount: 1 } as unknown as QueryResult;
});
await expect(
syncRejectedUserIcons({ query } as unknown as PoolClient, [rejected], new Date('2026-08-24T00:00:00Z'))
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
expect(String(query.mock.calls[1]?.[0])).toContain(`SET "picture" = 'default.jpg'`);
});
}); });