merge: 최신 main을 추방 보호 변경에 통합한다

This commit is contained in:
2026-08-24 08:34:36 +00:00
16 changed files with 941 additions and 37 deletions
+40 -6
View File
@@ -61,10 +61,10 @@ const castleFixtures = [
{ id: 2, level: 1, layoutLevel: 8, x: 200, y: 100, width: 16, height: 15 },
{ id: 3, level: 2, layoutLevel: 8, x: 300, y: 100, width: 20, height: 14 },
{ id: 4, level: 3, layoutLevel: 8, x: 400, y: 100, width: 14, height: 14 },
{ id: 5, level: 4, layoutLevel: 8, x: 100, y: 220, width: 20, height: 15 },
{ id: 6, level: 5, layoutLevel: 8, x: 200, y: 220, width: 24, height: 16 },
{ id: 7, level: 6, layoutLevel: 8, x: 300, y: 220, width: 26, height: 18 },
{ id: 8, level: 7, layoutLevel: 8, x: 400, y: 220, width: 28, height: 20 },
{ id: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 },
{ id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 },
{ id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 },
{ id: 8, name: '대', level: 7, layoutLevel: 8, x: 450, y: 480, width: 28, height: 20 },
] as const;
const map = {
result: true,
@@ -82,9 +82,9 @@ const map = {
};
const layout = {
mapName: 'che',
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y }) => ({
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y, ...fixture }) => ({
id,
name: id === 1 ? '업' : `${id}`,
name: id === 1 ? '업' : 'name' in fixture ? fixture.name : `${id}`,
level,
region: 1,
x,
@@ -550,6 +550,40 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
for (const cityName of ['남만', '교지', '남해', '대']) {
await page.getByRole('link', { name: cityName, exact: true }).hover();
await expect(page.locator('.map-tooltip')).toBeVisible();
const geometry = await page.locator('.map-area').evaluate((mapArea, expectedCityName) => {
const cityElement = Array.from(mapArea.querySelectorAll<HTMLElement>('.city-base')).find(
(element) => element.getAttribute('aria-label') === expectedCityName
);
const tooltip = mapArea.querySelector<HTMLElement>('.map-tooltip');
if (!cityElement || !tooltip) throw new Error(`Missing bottom-city hover geometry for ${expectedCityName}`);
const mapRect = mapArea.getBoundingClientRect();
const cityRect = cityElement.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const controls = mapArea.querySelector<HTMLElement>('.map-controls');
const tooltipStyle = getComputedStyle(tooltip);
return {
map: { top: mapRect.top, bottom: mapRect.bottom },
city: { top: cityRect.top, bottom: cityRect.bottom },
tooltip: { top: tooltipRect.top, bottom: tooltipRect.bottom, height: tooltipRect.height },
tooltipZIndex: Number(tooltipStyle.zIndex),
controlsZIndex: controls ? Number(getComputedStyle(controls).zIndex) : null,
pointerEvents: tooltipStyle.pointerEvents,
};
}, cityName);
expect(geometry.tooltip.top).toBeGreaterThanOrEqual(geometry.map.top);
expect(geometry.tooltip.bottom).toBeLessThanOrEqual(geometry.map.bottom);
expect(geometry.tooltip.bottom).toBeLessThan(geometry.city.top);
expect(geometry.tooltip.height).toBeGreaterThanOrEqual(32);
expect(geometry.tooltipZIndex).toBeGreaterThan(geometry.controlsZIndex ?? 0);
expect(geometry.pointerEvents).toBe('none');
}
await page.screenshot({ path: testInfo.outputPath('desktop-map-bottom-tooltip.png'), fullPage: true });
await desktopCity.hover();
await desktopCity.click();
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
await page.goBack();
@@ -92,6 +92,8 @@ const BASE_MAP_WIDTH = 700;
const BASE_MAP_HEIGHT = 500;
const SMALL_MAP_SCALE = 5 / 7;
const MAP_BACKGROUND_TRANSITION_MS = 480;
const TOOLTIP_FALLBACK_HEIGHT = 32;
const TOOLTIP_VERTICAL_OFFSET = 30;
const decodedImageCache = new Map<string, Promise<void>>();
const decodedImageElements = new Map<string, HTMLImageElement>();
@@ -146,6 +148,7 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
const mapControls = ref<HTMLElement | null>(null);
const tooltipElement = ref<HTMLElement | null>(null);
const mapOptionsOpen = ref(false);
const mapOptionsMenuId = `map-options-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody);
@@ -568,10 +571,15 @@ const tooltipPosition = computed(() => {
const width = 120;
const offset = 10;
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value;
const mapPixelHeight = BASE_MAP_HEIGHT * mapScale.value;
const tooltipHeight = tooltipElement.value?.offsetHeight ?? TOOLTIP_FALLBACK_HEIGHT;
const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset;
const belowTop = elementY.value + TOOLTIP_VERTICAL_OFFSET;
const top =
belowTop + tooltipHeight > mapPixelHeight ? elementY.value - tooltipHeight - TOOLTIP_VERTICAL_OFFSET : belowTop;
return {
left: `${Math.max(0, left)}px`,
top: `${elementY.value + 30}px`,
top: `${Math.max(0, top)}px`,
};
});
@@ -698,7 +706,7 @@ const selectCity = (cityId: number) => {
>
현재
</div>
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
<div v-if="hoveredCity" ref="tooltipElement" class="map-tooltip" :style="tooltipPosition">
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div>
+28 -8
View File
@@ -53,19 +53,39 @@ recovery dump.
### Gateway
| Legacy table | Target | Policy |
| --------------- | ----------------------------- | ------------------------------------------------------------------------------- |
| `member` | `app_user` plus `legacy_data` | Preserve identity, roles/ACL, sanctions, OAuth metadata, password hash and salt |
| `member_log` | `legacy_member_log` | Preserve complete JSON action history |
| `banned_member` | `legacy_banned_member` | Preserve hashed-email ban |
| `storage` | `legacy_root_key_value` | Preserve raw namespace/key/JSON value |
| `system` | `system` | Preserve registration/login switches and notice |
| `login_token` | none | Exclude expired bearer tokens, IP addresses and obsolete PHP sessions |
| Legacy table | Target | Policy |
| --------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
| `member` | `app_user`, `user_icon`, `legacy_data` | Preserve identity, account icon, roles/ACL, sanctions, OAuth metadata, password hash and salt |
| `member_log` | `legacy_member_log` | Preserve complete JSON action history |
| `banned_member` | `legacy_banned_member` | Preserve hashed-email ban |
| `storage` | `legacy_root_key_value` | Preserve raw namespace/key/JSON value |
| `system` | `system` | Preserve registration/login switches and notice |
| `login_token` | none | Exclude expired bearer tokens, IP addresses and obsolete PHP sessions |
Legacy member numbers map to deterministic UUIDs. Existing rows are updated by
that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump.
Ref appends `?=YYYYMMDD` to a custom icon filename as an HTTP cache marker; it
is not part of the stored filename. A Gateway plan with `userIcons` validates
every referenced byte before any upload. It reads legacy `d_pic` files without
following symlinks, checks the Ref 50 KiB/64~128px square/format contract, and
uploads the original bytes through sam-image's signed immutable upload API.
The deterministic per-account object name makes an interrupted apply safe to
repeat without putting user data in the image Git repository. Existing
`users/core/...` upload paths are fetched and validated instead of copied.
Only after every source icon validates and every legacy file upload succeeds
does the PostgreSQL transaction begin. The returned `icons/users/core2026/...`
path is checked exactly, stored as `users/core2026/...` with `image_server=0`,
and connected to an owned `user_icon` row. An unchanged Ref selection is moved
to that path. A newer Core selection is not overwritten; its imported Ref icon
is retained as another library entry. If the Core account is currently on the
default icon, the imported Ref entry is recorded retired so a prior selection
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.
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
+3
View File
@@ -566,6 +566,9 @@ importers:
pg:
specifier: ^8.16.3
version: 8.23.0
sharp:
specifier: ^0.35.0
version: 0.35.3(@types/node@26.2.0)
devDependencies:
'@types/node':
specifier: ^26.2.0
+12 -2
View File
@@ -39,6 +39,12 @@ Gateway and each game profile. A password can come from a separate mode-0600
file (recommended), an environment variable, or directly from the mode-0600
plan. Target PostgreSQL URLs remain in the named environment variables.
When the Gateway source contains a non-default member icon, `gateway.userIcons`
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.
```sh
mkdir -p tools/legacy-db-migration/secrets
chmod 700 tools/legacy-db-migration/secrets
@@ -69,7 +75,9 @@ host alias; do not put credentials in the plan.
`check-plan` opens every source and target without writing. Its stage JSON lists
every included item as `inventory`, including source, target, strategy and the
information transferred. A configured battle-result source also reports its
information transferred. Gateway preflight validates every local or already
uploaded custom icon and reports the source split without issuing a PUT. A
configured battle-result source also reports its
season/file/byte counts. `run-plan` also
preflights every stage before the first import, is a dry-run without `--apply`,
and stops at the first failed stage. Completed earlier stages remain committed;
@@ -143,7 +151,9 @@ database.
The individual commands also accept `--mode incremental` and `--source-key`.
Use the ordered plan for production so every configured connection is checked
before the Gateway stage starts.
before the Gateway stage starts. The direct `gateway` command intentionally
fails closed when its source contains custom icons because it has no secure
structured icon-upload configuration; use `run-plan` for that source.
### Isolated current-season comparison fixture
@@ -10,6 +10,12 @@
"passwordFile": "./secrets/mysql-root-password",
"tls": true
},
"userIcons": {
"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"
},
"targetUrlEnv": "GATEWAY_DATABASE_URL"
},
"profiles": [
+2 -1
View File
@@ -17,7 +17,8 @@
"dependencies": {
"@sammo-ts/common": "workspace:*",
"mariadb": "3.5.3",
"pg": "^8.16.3"
"pg": "^8.16.3",
"sharp": "^0.35.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
+45 -1
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';
import type { LegacyUserIconTransferConfig } from './legacyUserIcons.js';
export interface ResolvedMigrationStage {
kind: 'gateway' | 'game';
@@ -15,6 +16,7 @@ export interface ResolvedMigrationStage {
targetUrl: string;
sourceIdentity: MigrationSourceIdentity;
battleResults?: BattleResultSourceConfig;
userIcons?: LegacyUserIconTransferConfig;
}
export interface ResolvedMigrationPlan {
@@ -139,11 +141,48 @@ const resolveTargetUrl = (record: Record<string, unknown>, label: string): strin
const parseStage = (value: unknown, label: string): Record<string, unknown> => {
const record = asRecord(value, label);
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults'], label);
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults', 'userIcons'], label);
if (!('source' in record)) throw new Error(`${label}.source is required`);
return record;
};
const resolveWebBaseUrl = (value: string, label: string): string => {
let url: URL;
try {
url = new URL(value);
} catch (error) {
throw new Error(`${label} must be an absolute URL`, { cause: error });
}
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
throw new Error(`${label} must use HTTPS except for a loopback test service`);
}
if (url.username || url.password || url.search || url.hash) {
throw new Error(`${label} must not contain credentials, a query, or a fragment`);
}
return url.toString().replace(/\/$/u, '');
};
const resolveUserIcons = async (
value: unknown,
configDirectory: string,
label: string
): Promise<LegacyUserIconTransferConfig> => {
const record = asRecord(value, label);
rejectUnknownKeys(record, ['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile'], label);
const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label));
const sourceInfo = await lstat(sourceDirectory);
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
throw new Error(`${label}.sourceDirectory must be a directory and not a symbolic link`);
}
const uploadBaseUrl = resolveWebBaseUrl(requiredString(record, 'uploadBaseUrl', label), `${label}.uploadBaseUrl`);
const publicBaseUrl = resolveWebBaseUrl(requiredString(record, 'publicBaseUrl', label), `${label}.publicBaseUrl`);
const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label));
const uploadSecret = (await readSecureText(secretPath, `${label}.uploadSecretFile`)).replace(/\r?\n$/u, '');
if (uploadSecret.length < 32) throw new Error(`${label}.uploadSecretFile must contain at least 32 characters`);
return { sourceDirectory, uploadBaseUrl, publicBaseUrl, uploadSecret };
};
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
const configPath = path.resolve(configPathInput);
const rawText = await readSecureText(configPath, 'Migration config');
@@ -164,6 +203,10 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
if (root.gateway !== undefined) {
const gateway = parseStage(root.gateway, 'gateway');
const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source');
const userIcons =
gateway.userIcons === undefined
? undefined
: await resolveUserIcons(gateway.userIcons, configDirectory, 'gateway.userIcons');
stages.push({
kind: 'gateway',
name: 'gateway',
@@ -173,6 +216,7 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
key: `${sourceSet}:gateway`,
fingerprint: fingerprintMariaConnection(sourceUrl),
},
...(userIcons ? { userIcons } : {}),
});
}
+71 -12
View File
@@ -27,6 +27,14 @@ import {
type MigrationExecutionOptions,
type MigrationProgress,
} from './incremental.js';
import {
normalizeLegacyIconPicture,
prepareLegacyUserIcons,
syncImportedUserIcons,
type LegacyUserIconPreparation,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
} from './legacyUserIcons.js';
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
export interface MigrationSummary {
@@ -105,7 +113,14 @@ export const preflightMemberConflicts = async (target: PoolClient, rows: readonl
}
};
export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
export { normalizeLegacyIconPicture } from './legacyUserIcons.js';
export const mapMember = (
row: SourceRow,
migratedAt: Date,
lastLoginAt: Date | null,
importedIcon?: PreparedLegacyUserIcon
): TargetRow => {
const memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
@@ -114,11 +129,16 @@ export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date |
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
const oauthId = toNullableString(row.oauth_id)?.trim() || null;
const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`);
const rawPicture = toNullableString(row.PICTURE) ?? 'default.jpg';
const imageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
const legacyData: JsonValue = {
memberNo,
grade,
acl,
penalty,
picture: rawPicture,
imageServer,
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
tokenValidUntil: toNullableString(row.token_valid_until),
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
@@ -137,8 +157,8 @@ export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date |
oauth_id: oauthId,
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo),
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
image_server: toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`),
picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture),
image_server: importedIcon?.imageServer ?? imageServer,
icon_updated_at: null,
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null,
@@ -175,19 +195,39 @@ const processMembers = async (
target: PoolClient | null,
apply: boolean,
migratedAt: Date,
counts: Record<string, number>
counts: Record<string, number>,
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>
): Promise<void> => {
const lastLogins = await loadLastLogins(source);
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
const mapped = rows.map((row) => {
const memberNo = toNumber(row.NO, 'member.NO');
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
const importedIcon = preparedIcons.get(memberNo);
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
if (
(sourcePicture !== 'default.jpg' && !importedIcon) ||
(importedIcon && importedIcon.sourcePicture !== sourcePicture)
) {
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
}
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon);
});
if (target) {
await preflightMemberConflicts(target, mapped);
}
if (target && apply) {
await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS });
const synced = await syncImportedUserIcons(
target,
rows
.map((row) => preparedIcons.get(toNumber(row.NO, 'member.NO')))
.filter((icon): icon is PreparedLegacyUserIcon => Boolean(icon)),
migratedAt
);
counts.user_icon_current_linked = (counts.user_icon_current_linked ?? 0) + synced.currentLinked;
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted;
counts.user_icon_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
}
counts.member = (counts.member ?? 0) + mapped.length;
}
@@ -285,7 +325,8 @@ export const migrateGateway = async (
targetPool: PgPool | null,
apply: boolean,
migratedAt: Date,
execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root')
execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root'),
userIconConfig?: LegacyUserIconTransferConfig
): Promise<MigrationSummary> => {
validateSourceIdentity(execution.source);
if (execution.mode === 'incremental' && !targetPool) {
@@ -300,8 +341,20 @@ export const migrateGateway = async (
const client = targetPool ? await targetPool.connect() : null;
let importRunId: string | null = null;
try {
const run = async (runId: string | null): Promise<void> => {
await processMembers(source, client, apply, migratedAt, counts);
const sourceIconRows = await querySource(
source,
`SELECT NO, PICTURE, IMGSVR, REG_DATE
FROM member WHERE PICTURE <> 'default.jpg' ORDER BY NO`
);
const recordIconCounts = (prepared: LegacyUserIconPreparation): void => {
counts.user_icon_source = prepared.counts.custom;
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
counts.user_icon_existing_upload = prepared.counts.existingUploads;
counts.user_icon_uploaded = prepared.counts.uploaded;
};
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
recordIconCounts(prepared);
await processMembers(source, client, apply, migratedAt, counts, prepared.icons);
progress.member = {
strategy: 'rescan',
startAfterId: null,
@@ -365,9 +418,13 @@ export const migrateGateway = async (
);
importRunId = created.rows[0]?.id ?? null;
if (!importRunId) throw new Error('Failed to create legacy gateway import run');
await client.query('BEGIN');
let transactionStarted = false;
try {
await run(importRunId);
const prepared = await prepareLegacyUserIcons(sourceIconRows, userIconConfig, true);
recordIconCounts(prepared);
await client.query('BEGIN');
transactionStarted = true;
await run(importRunId, prepared);
await client.query(
`UPDATE "legacy_import_run"
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
@@ -376,8 +433,9 @@ export const migrateGateway = async (
[importRunId, JSON.stringify(counts), JSON.stringify(progress)]
);
await client.query('COMMIT');
transactionStarted = false;
} catch (error) {
await client.query('ROLLBACK');
if (transactionStarted) await client.query('ROLLBACK');
const message =
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
await client.query(
@@ -391,7 +449,8 @@ export const migrateGateway = async (
}
});
} else {
await run(null);
const prepared = await prepareLegacyUserIcons(sourceIconRows, userIconConfig, false);
await run(null, prepared);
}
} finally {
client?.release();
+2 -2
View File
@@ -10,9 +10,9 @@ export interface MigrationInventoryItem {
export const GATEWAY_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [
{
source: 'member',
target: 'app_user + legacy_data',
target: 'app_user + user_icon + legacy_data',
strategy: 'rescan',
contents: '계정 식별자, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료',
contents: '계정 식별자, 전용 아이콘 목록, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료',
},
{
source: 'member_log',
@@ -0,0 +1,403 @@
import { constants } from 'node:fs';
import { lstat, open } from 'node:fs/promises';
import path from 'node:path';
import { createHash, createHmac } from 'node:crypto';
import sharp from 'sharp';
import type { PoolClient } from 'pg';
import { legacyUserId } from './identity.js';
import { toDate, toNumber, toStringValue, type SourceRow } from './db.js';
const MAX_ICON_BYTES = 50 * 1024;
const LEGACY_CACHE_SUFFIX = /\?=([0-9]{8})$/u;
const REMOTE_PICTURE = /^users\/(?:core|core2026)\/[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$/u;
const LOCAL_PICTURE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,190}\.(?:avif|webp|jpe?g|png|gif)$/u;
const CONTENT_TYPES: Record<string, string> = {
avif: 'image/avif',
webp: 'image/webp',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
};
export interface LegacyUserIconTransferConfig {
sourceDirectory: string;
uploadBaseUrl: string;
publicBaseUrl: string;
uploadSecret: string;
}
export interface PreparedLegacyUserIcon {
memberNo: number;
userId: string;
sourcePicture: string;
normalizedSourcePicture: string;
sourceImageServer: number;
picture: string;
imageServer: 0;
createdAt: Date;
source: 'legacy-file' | 'existing-upload';
sha256: string;
}
export interface LegacyUserIconPreparation {
icons: Map<number, PreparedLegacyUserIcon>;
counts: {
custom: number;
legacyFiles: number;
existingUploads: number;
uploaded: number;
};
}
export interface LegacyUserIconSyncCounts {
currentLinked: number;
libraryInserted: number;
libraryRetired: number;
targetPreserved: number;
}
interface ValidatedImage {
body: Buffer;
extension: string;
contentType: string;
sha256: string;
}
const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, '');
const iconCreatedAt = (sourcePicture: string, fallback: Date): Date => {
const marker = sourcePicture.match(LEGACY_CACHE_SUFFIX)?.[1];
if (!marker) return fallback;
const year = Number(marker.slice(0, 4));
const month = Number(marker.slice(4, 6));
const day = Number(marker.slice(6, 8));
const parsed = new Date(Date.UTC(year, month - 1, day, -9));
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
};
const validateImage = async (body: Buffer, label: string): Promise<ValidatedImage> => {
if (body.length === 0 || body.length > MAX_ICON_BYTES) {
throw new Error(`${label} must be non-empty and at most 50 KiB`);
}
let metadata: { mediaType?: string; format?: string; width?: number; height?: number };
try {
metadata = await sharp(body, { animated: true }).metadata();
} catch (error) {
throw new Error(`${label} is not a decodable image`, { cause: error });
}
const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
const extension = detected === 'jpeg' ? 'jpg' : detected;
if (!extension || !CONTENT_TYPES[extension]) {
throw new Error(`${label} must be avif, webp, jpeg, png, or gif`);
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new Error(`${label} must be a square image from 64x64 through 128x128`);
}
return {
body,
extension,
contentType: CONTENT_TYPES[extension]!,
sha256: createHash('sha256').update(body).digest('hex'),
};
};
const readLegacyIcon = async (directory: string, picture: string, memberNo: number): Promise<ValidatedImage> => {
if (!LOCAL_PICTURE.test(picture) || path.basename(picture) !== picture) {
throw new Error(`member.${memberNo}.PICTURE is not a safe Ref d_pic filename`);
}
const filePath = path.resolve(directory, picture);
if (path.dirname(filePath) !== path.resolve(directory)) {
throw new Error(`member.${memberNo}.PICTURE escapes the Ref d_pic directory`);
}
const info = await lstat(filePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`);
}
if (info.size === 0 || info.size > MAX_ICON_BYTES) {
throw new Error(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`);
}
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
return await validateImage(await handle.readFile(), `member.${memberNo}.PICTURE`);
} finally {
await handle.close();
}
};
const fetchExistingIcon = async (
config: LegacyUserIconTransferConfig,
picture: string,
memberNo: number,
fetchImpl: typeof fetch
): Promise<ValidatedImage> => {
if (!REMOTE_PICTURE.test(picture)) {
throw new Error(`member.${memberNo}.PICTURE is neither a Ref d_pic filename nor a sam-image upload path`);
}
const response = await fetchImpl(`${config.publicBaseUrl.replace(/\/$/u, '')}/${encodedPicturePath(picture)}`, {
headers: { accept: 'image/avif,image/webp,image/png,image/jpeg,image/gif' },
});
if (!response.ok) {
throw new Error(`member.${memberNo}.PICTURE is unavailable from sam-image (HTTP ${response.status})`);
}
const contentLength = Number(response.headers.get('content-length') ?? 0);
if (contentLength > MAX_ICON_BYTES) {
throw new Error(`member.${memberNo}.PICTURE exceeds 50 KiB on sam-image`);
}
const body = Buffer.from(await response.arrayBuffer());
return validateImage(body, `member.${memberNo}.PICTURE`);
};
const deterministicUploadName = (memberNo: number, sourcePicture: string, image: ValidatedImage): string => {
const stem = createHash('sha256')
.update('legacy-ref-user-icon-v1\0')
.update(String(memberNo))
.update('\0')
.update(sourcePicture)
.update('\0')
.update(image.sha256)
.digest('hex')
.slice(0, 32);
return `${stem}.${image.extension}`;
};
const uploadSignature = (
secret: string,
expires: string,
requestId: string,
pathname: string,
contentType: string,
body: Buffer
): string => {
const digest = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret)
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
.digest('hex');
};
const uploadLegacyIcon = async (
config: LegacyUserIconTransferConfig,
memberNo: number,
sourcePicture: string,
image: ValidatedImage,
fetchImpl: typeof fetch,
now: () => number
): Promise<string> => {
const filename = deterministicUploadName(memberNo, sourcePicture, image);
const pathname = `/v1/uploads/user-icons/core2026/${filename}`;
const expires = String(Math.floor(now() / 1000) + 60);
const requestId = `legacy-ref-${filename.slice(0, 32)}`;
const response = await fetchImpl(`${config.uploadBaseUrl.replace(/\/$/u, '')}${pathname}`, {
method: 'PUT',
headers: {
'content-type': image.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': uploadSignature(
config.uploadSecret,
expires,
requestId,
pathname,
image.contentType,
image.body
),
},
body: new Uint8Array(image.body),
});
if (!response.ok) {
throw new Error(`member.${memberNo}.PICTURE upload failed with HTTP ${response.status}`);
}
const picture = `users/core2026/${filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
throw new Error(`member.${memberNo}.PICTURE upload returned an unexpected path`);
}
return picture;
};
const mapWithConcurrency = async <T, R>(
values: readonly T[],
concurrency: number,
mapper: (value: T) => Promise<R>
): Promise<R[]> => {
const results = new Array<R>(values.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < values.length) {
const index = nextIndex++;
results[index] = await mapper(values[index]!);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
return results;
};
export const prepareLegacyUserIcons = async (
rows: readonly SourceRow[],
config: LegacyUserIconTransferConfig | undefined,
apply: boolean,
options: { fetchImpl?: typeof fetch; now?: () => number; concurrency?: number } = {}
): Promise<LegacyUserIconPreparation> => {
const customRows = rows.filter((row) => (row.PICTURE ?? 'default.jpg') !== 'default.jpg');
if (customRows.length > 0 && !config) {
throw new Error('Gateway source has custom icons but gateway.userIcons is not configured');
}
if (!config) {
return { icons: new Map(), counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0 } };
}
if (config.uploadSecret.length < 32) {
throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters');
}
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.now ?? Date.now;
const validated = await mapWithConcurrency(customRows, options.concurrency ?? 8, async (row) => {
const memberNo = toNumber(row.NO, 'member.NO');
const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`);
const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture);
const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`);
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
return {
base: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
imageServer: 0 as const,
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
source: 'existing-upload' as const,
sha256: image.sha256,
},
image,
picture: normalizedSourcePicture,
};
}
if (sourceImageServer !== 1) {
throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`);
}
const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo);
return {
base: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
imageServer: 0 as const,
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
source: 'legacy-file' as const,
sha256: image.sha256,
},
image,
picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, image)}`,
};
});
const pictures = new Map<string, number>();
for (const icon of validated) {
const owner = pictures.get(icon.picture);
if (owner !== undefined && owner !== icon.base.memberNo) {
throw new Error('Legacy user icon picture is shared by multiple source accounts');
}
pictures.set(icon.picture, icon.base.memberNo);
}
const prepared = await mapWithConcurrency(validated, options.concurrency ?? 8, async (icon) => {
const picture =
apply && icon.base.source === 'legacy-file'
? await uploadLegacyIcon(
config,
icon.base.memberNo,
icon.base.normalizedSourcePicture,
icon.image,
fetchImpl,
now
)
: icon.picture;
return { ...icon.base, picture } satisfies PreparedLegacyUserIcon;
});
return {
icons: new Map(prepared.map((icon) => [icon.memberNo, icon])),
counts: {
custom: prepared.length,
legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length,
existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length,
uploaded: apply ? prepared.filter((icon) => icon.source === 'legacy-file').length : 0,
},
};
};
export const syncImportedUserIcons = async (
target: PoolClient,
icons: readonly PreparedLegacyUserIcon[],
migratedAt: Date
): Promise<LegacyUserIconSyncCounts> => {
if (icons.length === 0) {
return { currentLinked: 0, libraryInserted: 0, libraryRetired: 0, targetPreserved: 0 };
}
const userIds = icons.map((icon) => icon.userId);
const accounts = await target.query<{ id: string; picture: string; image_server: number }>(
`SELECT "id", "picture", "image_server" FROM "app_user" WHERE "id" = ANY($1::text[]) FOR UPDATE`,
[userIds]
);
const byId = new Map(accounts.rows.map((account) => [account.id, account]));
const collisions = await target.query<{ picture: string }>(
`SELECT imported."picture"
FROM "user_icon" AS existing
JOIN unnest($1::text[], $2::text[]) AS imported("user_id", "picture")
ON imported."picture" = existing."picture"
WHERE existing."user_id" <> imported."user_id"
LIMIT 1`,
[userIds, icons.map((icon) => icon.picture)]
);
if (collisions.rowCount) {
throw new Error('Legacy user icon picture is already owned by another target account');
}
const counts: LegacyUserIconSyncCounts = {
currentLinked: 0,
libraryInserted: 0,
libraryRetired: 0,
targetPreserved: 0,
};
for (const icon of icons) {
const account = byId.get(icon.userId);
if (!account) throw new Error(`Imported member account is missing for member.${icon.memberNo}`);
const sourceMatchesCurrent =
account.picture === icon.sourcePicture || account.picture === icon.normalizedSourcePicture;
if (sourceMatchesCurrent && (account.picture !== icon.picture || account.image_server !== 0)) {
const linked = await target.query(
`UPDATE "app_user"
SET "picture" = $2, "image_server" = 0,
"icon_revision" = GREATEST(
COALESCE("icon_revision", "icon_updated_at", "created_at"),
$3::timestamptz
)
WHERE "id" = $1 AND "picture" = $4 AND "image_server" = $5`,
[icon.userId, icon.picture, migratedAt, account.picture, account.image_server]
);
if (linked.rowCount !== 1) {
throw new Error(`Target account icon changed concurrently for member.${icon.memberNo}`);
}
account.picture = icon.picture;
account.image_server = 0;
counts.currentLinked += 1;
} else if (!sourceMatchesCurrent && account.picture !== icon.picture) {
counts.targetPreserved += 1;
}
const retiredAt = account.picture === 'default.jpg' ? migratedAt : null;
const inserted = await target.query(
`INSERT INTO "user_icon" ("user_id", "picture", "image_server", "created_at", "retired_at")
VALUES ($1, $2, 0, $3, $4)
ON CONFLICT ("picture") DO NOTHING`,
[icon.userId, icon.picture, icon.createdAt, retiredAt]
);
counts.libraryInserted += inserted.rowCount ?? 0;
if (retiredAt && inserted.rowCount) counts.libraryRetired += 1;
}
return counts;
};
+20 -2
View File
@@ -6,6 +6,7 @@ import { migrateGateway, type MigrationSummary } from './gateway.js';
import type { MigrationMode } from './incremental.js';
import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js';
import { migrationInventoryForStage } from './inventory.js';
import { prepareLegacyUserIcons } from './legacyUserIcons.js';
export interface PlanRunSummary {
command: 'run-plan';
@@ -23,6 +24,7 @@ export interface PlanRunSummary {
interface StagePreflight {
battleResults?: { seasons: number; files: number; bytes: number };
battleResultManifests?: readonly BattleResultSeasonManifest[];
userIcons?: { custom: number; legacyFiles: number; existingUploads: number };
}
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
@@ -76,7 +78,22 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
if (!targetReady.rows[0]?.table_name) {
throw new Error(`Target migrations are not current for ${stage.name}; missing ${targetDataTable}`);
}
if (stage.kind === 'game' && stage.battleResults) {
if (stage.kind === 'gateway') {
const iconRows = await querySource(
source,
`SELECT NO, PICTURE, IMGSVR, REG_DATE
FROM member WHERE PICTURE <> 'default.jpg' ORDER BY NO`
);
const prepared = await prepareLegacyUserIcons(iconRows, stage.userIcons, false);
return {
userIcons: {
custom: prepared.counts.custom,
legacyFiles: prepared.counts.legacyFiles,
existingUploads: prepared.counts.existingUploads,
},
};
}
if (stage.battleResults) {
const battleResultReady = await target.query<{ table_name: string | null }>(
'SELECT to_regclass($1) AS table_name',
['legacy_archive.general_battle_result']
@@ -113,6 +130,7 @@ export const checkMigrationPlan = async (plan: ResolvedMigrationPlan): Promise<R
status: 'READY',
inventory: migrationInventoryForStage(stage),
...(preflight.battleResults ? { battleResults: preflight.battleResults } : {}),
...(preflight.userIcons ? { userIcons: preflight.userIcons } : {}),
});
}
return {
@@ -140,7 +158,7 @@ export const runMigrationPlan = async (
const execution = { mode, source: stage.sourceIdentity } as const;
const summary =
stage.kind === 'gateway'
? await migrateGateway(source, target, apply, migratedAt, execution)
? await migrateGateway(source, target, apply, migratedAt, execution, stage.userIcons)
: await migrateGame(source, target, apply, stage.profile!, execution);
const battleResults =
stage.kind === 'game' && stage.battleResults
@@ -17,6 +17,8 @@ const writeFixture = async (mode = 0o600): Promise<string> => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-legacy-plan-'));
workDirectories.push(directory);
await writeFile(path.join(directory, 'mysql-password'), 'secret-value\n', { mode: 0o600 });
await writeFile(path.join(directory, 'image-upload-secret'), `${'u'.repeat(32)}\n`, { mode: 0o600 });
const iconDirectory = await mkdtemp(path.join(directory, 'icons-'));
const configPath = path.join(directory, 'migration-plan.json');
await writeFile(
configPath,
@@ -30,6 +32,12 @@ const writeFixture = async (mode = 0o600): Promise<string> => {
user: 'migration_reader',
passwordFile: './mysql-password',
},
userIcons: {
sourceDirectory: iconDirectory,
uploadBaseUrl: 'https://sam-image.hided.net',
publicBaseUrl: 'https://sam-image.hided.net/icons',
uploadSecretFile: './image-upload-secret',
},
targetUrlEnv: 'TEST_GATEWAY_DATABASE_URL',
},
}),
@@ -51,6 +59,11 @@ describe('legacy migration plan config', () => {
expect(source.username).toBe('migration_reader');
expect(source.password).toBe('secret-value');
expect(plan.stages[0]!.sourceIdentity.key).toBe('fixture-cutover:gateway');
expect(plan.stages[0]!.userIcons).toMatchObject({
uploadBaseUrl: 'https://sam-image.hided.net',
publicBaseUrl: 'https://sam-image.hided.net/icons',
uploadSecret: 'u'.repeat(32),
});
});
it('rejects a config readable by group or other users', async () => {
+26 -1
View File
@@ -3,7 +3,13 @@ import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import { upsertRows } from '../src/db.js';
import { mapMember, MEMBER_PRESERVED_COLUMNS, migrateGateway, preflightMemberConflicts } from '../src/gateway.js';
import {
mapMember,
MEMBER_PRESERVED_COLUMNS,
migrateGateway,
normalizeLegacyIconPicture,
preflightMemberConflicts,
} from '../src/gateway.js';
const memberRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7,
@@ -44,6 +50,25 @@ describe('legacy gateway member migration', () => {
});
});
it('removes only the Ref cache marker while preserving the original icon metadata', () => {
const mapped = mapMember(
memberRow({ PICTURE: 'users/core/' + 'a'.repeat(32) + '.png?=20260809', IMGSVR: 0 }),
new Date('2026-08-17T00:00:00.000Z'),
null
);
expect(normalizeLegacyIconPicture('legacy.png?=20260809')).toBe('legacy.png');
expect(normalizeLegacyIconPicture('literal.png?other')).toBe('literal.png?other');
expect(mapped).toMatchObject({
picture: 'users/core/' + 'a'.repeat(32) + '.png',
image_server: 0,
});
expect((mapped.legacy_data as { value: unknown }).value).toMatchObject({
picture: 'users/core/' + 'a'.repeat(32) + '.png?=20260809',
imageServer: 0,
});
});
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
const client = { query } as unknown as PoolClient;
@@ -0,0 +1,78 @@
import { Pool } from 'pg';
import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import { syncImportedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
const databaseUrl = process.env.LEGACY_ICON_TEST_DATABASE_URL;
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture: sourcePicture.replace(/\?=[0-9]{8}$/u, ''),
sourceImageServer: 1,
picture,
imageServer: 0,
createdAt: new Date('2026-08-09T00:00:00.000Z'),
source: 'legacy-file',
sha256: 'a'.repeat(64),
});
describe.skipIf(!databaseUrl)('legacy user icon PostgreSQL synchronization', () => {
it('updates only an unchanged Ref selection and preserves newer Core state', async () => {
const pool = new Pool({ connectionString: databaseUrl });
const client = await pool.connect();
const icons = [
imported(700_001, 'first.png?=20260809', `users/core2026/${'1'.repeat(32)}.png`),
imported(700_002, 'second.png?=20260809', `users/core2026/${'2'.repeat(32)}.png`),
imported(700_003, 'third.png?=20260809', `users/core2026/${'3'.repeat(32)}.png`),
];
try {
await client.query('BEGIN');
for (const [index, icon] of icons.entries()) {
const currentPicture =
index === 0
? icon.sourcePicture
: index === 1
? `users/core2026/${'8'.repeat(32)}.png`
: 'default.jpg';
await client.query(
`INSERT INTO "app_user"
("id", "login_id", "display_name", "password_hash", "password_salt",
"updated_at", "picture", "image_server")
VALUES ($1, $2, $3, 'hash', 'salt', CURRENT_TIMESTAMP, $4, $5)`,
[icon.userId, `icon-test-${index}`, `아이콘테스트-${index}`, currentPicture, index === 0 ? 1 : 0]
);
}
await expect(syncImportedUserIcons(client, icons, new Date('2026-08-24T00:00:00Z'))).resolves.toEqual({
currentLinked: 1,
libraryInserted: 3,
libraryRetired: 1,
targetPreserved: 2,
});
const accounts = await client.query<{ id: string; picture: string; image_server: number }>(
`SELECT "id", "picture", "image_server" FROM "app_user"
WHERE "id" = ANY($1::text[]) ORDER BY "login_id"`,
[icons.map((icon) => icon.userId)]
);
expect(accounts.rows.map(({ picture, image_server: imageServer }) => ({ picture, imageServer }))).toEqual([
{ picture: icons[0]!.picture, imageServer: 0 },
{ picture: `users/core2026/${'8'.repeat(32)}.png`, imageServer: 0 },
{ picture: 'default.jpg', imageServer: 0 },
]);
const library = await client.query<{ picture: string; retired_at: Date | null }>(
`SELECT "picture", "retired_at" FROM "user_icon"
WHERE "user_id" = ANY($1::text[]) ORDER BY "picture"`,
[icons.map((icon) => icon.userId)]
);
expect(library.rows).toHaveLength(3);
expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1);
} finally {
await client.query('ROLLBACK');
client.release();
await pool.end();
}
});
});
@@ -0,0 +1,182 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createHash, createHmac } from 'node:crypto';
import type { PoolClient, QueryResult } from 'pg';
import sharp from 'sharp';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import {
prepareLegacyUserIcons,
syncImportedUserIcons,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
} from '../src/legacyUserIcons.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig; png: Buffer }> => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-user-icons-'));
temporaryDirectories.push(directory);
const png = await sharp({ create: { width: 64, height: 64, channels: 4, background: '#336699ff' } })
.png()
.toBuffer();
await writeFile(path.join(directory, 'legacy.png'), png);
return {
config: {
sourceDirectory: directory,
uploadBaseUrl: 'https://upload.test',
publicBaseUrl: 'https://public.test/icons',
uploadSecret: 's'.repeat(32),
},
png,
};
};
const sourceRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7,
PICTURE: 'legacy.png?=20260809',
IMGSVR: 1,
REG_DATE: '2020-01-01 00:00:00',
...overrides,
});
describe('legacy user icon transfer', () => {
it('validates a Ref file and derives a deterministic API path without writing during dry-run', async () => {
const { config } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>();
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.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();
});
it('uploads through the signed API and accepts only the exact returned path', async () => {
const { config, png } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
const pathname = new URL(String(input)).pathname;
const headers = new Headers(init?.headers);
const expires = headers.get('x-image-expires')!;
const requestId = headers.get('x-image-request-id')!;
const contentType = headers.get('content-type')!;
const expectedSignature = createHmac('sha256', config.uploadSecret)
.update(
`${expires}.${requestId}.${pathname}.${contentType}.${createHash('sha256').update(png).digest('hex')}`
)
.digest('hex');
expect(init?.method).toBe('PUT');
expect(Buffer.from(init?.body as Uint8Array)).toEqual(png);
expect(headers.get('x-image-client')).toBe('core2026');
expect(headers.get('x-image-signature')).toBe(expectedSignature);
return new Response(JSON.stringify({ path: pathname.replace('/v1/uploads/user-icons/', 'icons/users/') }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
});
const result = await prepareLegacyUserIcons([sourceRow()], config, true, {
fetchImpl,
now: () => Date.parse('2026-08-24T00:00:00.000Z'),
});
expect(result.counts.uploaded).toBe(1);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(result.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u);
});
it('validates all source files before starting any permanent upload', async () => {
const { config } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>();
await expect(
prepareLegacyUserIcons(
[sourceRow(), sourceRow({ NO: 8, PICTURE: 'missing.png?=20260809' })],
config,
true,
{ fetchImpl }
)
).rejects.toThrow();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('verifies an existing sam-image object without uploading it again', async () => {
const { config, png } = await createFixture();
const picture = `users/core/${'a'.repeat(32)}.png`;
const fetchImpl = vi.fn<typeof fetch>(async () => new Response(png, { status: 200 }));
const result = await prepareLegacyUserIcons(
[sourceRow({ PICTURE: `${picture}?=20260809`, IMGSVR: 0 })],
config,
true,
{ fetchImpl }
);
expect(result.counts).toEqual({ custom: 1, legacyFiles: 0, existingUploads: 1, uploaded: 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}`);
});
it('fails closed when custom icons exist without an API transfer configuration', async () => {
await expect(prepareLegacyUserIcons([sourceRow()], undefined, false)).rejects.toThrow(
'gateway.userIcons is not configured'
);
});
it('links an unchanged Ref selection while preserving newer Core selections', async () => {
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture: sourcePicture.replace(/\?=[0-9]{8}$/u, ''),
sourceImageServer: 1,
picture,
imageServer: 0,
createdAt: new Date('2026-08-09T00:00:00.000Z'),
source: 'legacy-file',
sha256: 'a'.repeat(64),
});
const icons = [
imported(7, 'first.png?=20260809', `users/core2026/${'1'.repeat(32)}.png`),
imported(8, 'second.png?=20260809', `users/core2026/${'2'.repeat(32)}.png`),
imported(9, 'third.png?=20260809', `users/core2026/${'3'.repeat(32)}.png`),
];
const query = vi.fn(async (sql: string, _parameters?: readonly unknown[]) => {
if (sql.includes('FROM "app_user"')) {
return {
rows: [
{ id: legacyUserId(7), picture: 'first.png?=20260809', image_server: 1 },
{ id: legacyUserId(8), picture: `users/core2026/${'8'.repeat(32)}.png`, image_server: 0 },
{ id: legacyUserId(9), picture: 'default.jpg', image_server: 0 },
],
rowCount: 3,
} as QueryResult;
}
if (sql.includes('JOIN unnest')) return { rows: [], rowCount: 0 } as unknown as QueryResult;
return { rows: [], rowCount: 1 } as unknown as QueryResult;
});
const target = { query } as unknown as PoolClient;
await expect(syncImportedUserIcons(target, icons, new Date('2026-08-24T00:00:00Z'))).resolves.toEqual({
currentLinked: 1,
libraryInserted: 3,
libraryRetired: 1,
targetPreserved: 2,
});
const inserts = query.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO "user_icon"'));
expect(inserts).toHaveLength(3);
expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z'));
});
});