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
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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'`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user