merge: 최신 main을 외교 권한 복수 임명 수정에 최종 통합한다
This commit is contained in:
@@ -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
|
||||
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
|
||||
required before an imported row is marked Kakao-verified. A parseable legacy
|
||||
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
|
||||
|
||||
@@ -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`.
|
||||
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.
|
||||
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
|
||||
mkdir -p tools/legacy-db-migration/secrets
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"sourceDirectory": "/run/sammo-migration/user-icons",
|
||||
"uploadBaseUrl": "https://sam-image.hided.net",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -169,7 +169,11 @@ const resolveUserIcons = async (
|
||||
label: string
|
||||
): Promise<LegacyUserIconTransferConfig> => {
|
||||
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 sourceInfo = await lstat(sourceDirectory);
|
||||
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
|
||||
@@ -180,7 +184,20 @@ const resolveUserIcons = async (
|
||||
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 };
|
||||
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> => {
|
||||
|
||||
@@ -31,9 +31,11 @@ import {
|
||||
normalizeLegacyIconPicture,
|
||||
prepareLegacyUserIcons,
|
||||
syncImportedUserIcons,
|
||||
syncRejectedUserIcons,
|
||||
type LegacyUserIconPreparation,
|
||||
type LegacyUserIconTransferConfig,
|
||||
type PreparedLegacyUserIcon,
|
||||
type RejectedLegacyUserIcon,
|
||||
} from './legacyUserIcons.js';
|
||||
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
|
||||
|
||||
@@ -119,7 +121,8 @@ export const mapMember = (
|
||||
row: SourceRow,
|
||||
migratedAt: Date,
|
||||
lastLoginAt: Date | null,
|
||||
importedIcon?: PreparedLegacyUserIcon
|
||||
importedIcon?: PreparedLegacyUserIcon,
|
||||
rejectedIcon?: RejectedLegacyUserIcon
|
||||
): TargetRow => {
|
||||
const memberNo = toNumber(row.NO, 'member.NO');
|
||||
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
||||
@@ -139,6 +142,7 @@ export const mapMember = (
|
||||
picture: rawPicture,
|
||||
imageServer,
|
||||
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
|
||||
...(rejectedIcon ? { rejectedPictureReason: rejectedIcon.reason } : {}),
|
||||
tokenValidUntil: toNullableString(row.token_valid_until),
|
||||
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
|
||||
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
|
||||
@@ -157,8 +161,8 @@ export const mapMember = (
|
||||
oauth_id: oauthId,
|
||||
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
||||
oauth_info: jsonParameter(oauthInfo),
|
||||
picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture),
|
||||
image_server: importedIcon?.imageServer ?? imageServer,
|
||||
picture: importedIcon?.picture ?? (rejectedIcon ? 'default.jpg' : normalizeLegacyIconPicture(rawPicture)),
|
||||
image_server: importedIcon?.imageServer ?? (rejectedIcon ? 0 : imageServer),
|
||||
icon_updated_at: null,
|
||||
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
||||
terms_accepted_at: null,
|
||||
@@ -196,21 +200,24 @@ const processMembers = async (
|
||||
apply: boolean,
|
||||
migratedAt: Date,
|
||||
counts: Record<string, number>,
|
||||
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>
|
||||
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>,
|
||||
rejectedIcons: ReadonlyMap<number, RejectedLegacyUserIcon>
|
||||
): 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');
|
||||
const importedIcon = preparedIcons.get(memberNo);
|
||||
const rejectedIcon = rejectedIcons.get(memberNo);
|
||||
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
|
||||
if (
|
||||
(sourcePicture !== 'default.jpg' && !importedIcon) ||
|
||||
(importedIcon && importedIcon.sourcePicture !== sourcePicture)
|
||||
(sourcePicture !== 'default.jpg' && !importedIcon && !rejectedIcon) ||
|
||||
(importedIcon && importedIcon.sourcePicture !== sourcePicture) ||
|
||||
(rejectedIcon && rejectedIcon.sourcePicture !== sourcePicture)
|
||||
) {
|
||||
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) {
|
||||
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_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
|
||||
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;
|
||||
}
|
||||
@@ -351,10 +369,11 @@ export const migrateGateway = async (
|
||||
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
|
||||
counts.user_icon_existing_upload = prepared.counts.existingUploads;
|
||||
counts.user_icon_uploaded = prepared.counts.uploaded;
|
||||
counts.user_icon_rejected = prepared.counts.rejected;
|
||||
};
|
||||
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
|
||||
recordIconCounts(prepared);
|
||||
await processMembers(source, client, apply, migratedAt, counts, prepared.icons);
|
||||
await processMembers(source, client, apply, migratedAt, counts, prepared.icons, prepared.rejected);
|
||||
progress.member = {
|
||||
strategy: 'rescan',
|
||||
startAfterId: null,
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface LegacyUserIconTransferConfig {
|
||||
uploadBaseUrl: string;
|
||||
publicBaseUrl: string;
|
||||
uploadSecret: string;
|
||||
excludedMemberNumbers: readonly number[];
|
||||
}
|
||||
|
||||
export interface PreparedLegacyUserIcon {
|
||||
@@ -44,14 +45,25 @@ export interface PreparedLegacyUserIcon {
|
||||
|
||||
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;
|
||||
@@ -59,6 +71,11 @@ export interface LegacyUserIconSyncCounts {
|
||||
targetPreserved: number;
|
||||
}
|
||||
|
||||
export interface RejectedLegacyUserIconSyncCounts {
|
||||
currentReset: number;
|
||||
targetPreserved: number;
|
||||
}
|
||||
|
||||
interface ValidatedImage {
|
||||
body: Buffer;
|
||||
extension: string;
|
||||
@@ -66,6 +83,8 @@ interface ValidatedImage {
|
||||
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, '');
|
||||
@@ -82,21 +101,29 @@ const iconCreatedAt = (sourcePicture: string, fallback: Date): Date => {
|
||||
|
||||
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`);
|
||||
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 {
|
||||
metadata = await sharp(body, { animated: true }).metadata();
|
||||
} 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 extension = detected === 'jpeg' ? 'jpg' : detected;
|
||||
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) {
|
||||
throw new Error(`${label} must be a square image from 64x64 through 128x128`);
|
||||
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,
|
||||
@@ -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`);
|
||||
}
|
||||
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);
|
||||
try {
|
||||
@@ -248,22 +275,59 @@ export const prepareLegacyUserIcons = async (
|
||||
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 } };
|
||||
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`);
|
||||
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
|
||||
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
|
||||
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),
|
||||
@@ -272,42 +336,44 @@ export const prepareLegacyUserIcons = async (
|
||||
sourceImageServer,
|
||||
imageServer: 0 as const,
|
||||
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
|
||||
source: 'existing-upload' as const,
|
||||
source: 'legacy-file' as const,
|
||||
sha256: image.sha256,
|
||||
},
|
||||
image,
|
||||
picture: normalizedSourcePicture,
|
||||
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,
|
||||
};
|
||||
}
|
||||
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 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 validated) {
|
||||
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(validated, options.concurrency ?? 8, async (icon) => {
|
||||
const prepared = await mapWithConcurrency(validIcons, options.concurrency ?? 8, async (icon) => {
|
||||
const picture =
|
||||
apply && icon.base.source === 'legacy-file'
|
||||
? await uploadLegacyIcon(
|
||||
@@ -323,15 +389,56 @@ export const prepareLegacyUserIcons = async (
|
||||
});
|
||||
return {
|
||||
icons: new Map(prepared.map((icon) => [icon.memberNo, icon])),
|
||||
rejected: new Map(rejectedIcons.map((icon) => [icon.memberNo, icon])),
|
||||
counts: {
|
||||
custom: prepared.length,
|
||||
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[],
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface PlanRunSummary {
|
||||
interface StagePreflight {
|
||||
battleResults?: { seasons: number; files: number; bytes: number };
|
||||
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> => {
|
||||
@@ -90,6 +90,7 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
|
||||
custom: prepared.counts.custom,
|
||||
legacyFiles: prepared.counts.legacyFiles,
|
||||
existingUploads: prepared.counts.existingUploads,
|
||||
rejected: prepared.counts.rejected,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Pool } from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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;
|
||||
|
||||
@@ -69,6 +69,37 @@ describe.skipIf(!databaseUrl)('legacy user icon PostgreSQL synchronization', ()
|
||||
);
|
||||
expect(library.rows).toHaveLength(3);
|
||||
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 {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { legacyUserId } from '../src/identity.js';
|
||||
import {
|
||||
prepareLegacyUserIcons,
|
||||
syncImportedUserIcons,
|
||||
syncRejectedUserIcons,
|
||||
type LegacyUserIconTransferConfig,
|
||||
type PreparedLegacyUserIcon,
|
||||
} from '../src/legacyUserIcons.js';
|
||||
@@ -36,6 +37,7 @@ const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig;
|
||||
uploadBaseUrl: 'https://upload.test',
|
||||
publicBaseUrl: 'https://public.test/icons',
|
||||
uploadSecret: 's'.repeat(32),
|
||||
excludedMemberNumbers: [],
|
||||
},
|
||||
png,
|
||||
};
|
||||
@@ -57,7 +59,13 @@ describe('legacy user icon transfer', () => {
|
||||
const first = 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(second.icons.get(7)?.picture).toBe(first.icons.get(7)?.picture);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
@@ -123,7 +131,13 @@ describe('legacy user icon transfer', () => {
|
||||
{ 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(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
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 () => {
|
||||
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
|
||||
memberNo,
|
||||
@@ -179,4 +227,30 @@ describe('legacy user icon transfer', () => {
|
||||
expect(inserts).toHaveLength(3);
|
||||
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'`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user