merge: 최신 main을 자원 수입 문구 수정에 통합한다

This commit is contained in:
2026-08-24 08:37:16 +00:00
20 changed files with 997 additions and 40 deletions
@@ -1997,7 +1997,7 @@ async function handleKick(
} }
const target = world.getGeneralById(command.destGeneralId); const target = world.getGeneralById(command.destGeneralId);
if (!target || target.id === general.id || target.nationId !== general.nationId) { if (!target || target.nationId !== general.nationId) {
return { return {
type: 'kick', type: 'kick',
ok: false, ok: false,
@@ -2005,7 +2005,18 @@ async function handleKick(
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.', reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.',
}; };
} }
if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') { if (target.id === general.id) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '본인은 추방할 수 없습니다.' };
}
// Ref 화면은 군주와 본인을 후보에서 제외하지만 서버는 조작 요청을 막지 못했다.
// 국가 소유권을 깨뜨리는 대상은 UI와 무관하게 durable command 경계에서 거부한다.
if (target.id === nation.chiefGeneralId || target.officerLevel === 12) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '군주는 추방할 수 없습니다.' };
}
if (target.officerLevel >= 5) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌는 추방할 수 없습니다.' };
}
if (resolvePermissionKind(target) === 'ambassador') {
return { return {
type: 'kick', type: 'kick',
ok: false, ok: false,
@@ -312,6 +312,39 @@ describe('nation personnel world commands', () => {
expect(fixture.world.peekDirtyState().logs).toHaveLength(2); expect(fixture.world.peekDirtyState().logs).toHaveLength(2);
}); });
it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => {
const cases = [
{ label: 'self', targetId: 2, reason: '본인은 추방할 수 없습니다.' },
{ label: 'ruler', targetId: 1, reason: '군주는 추방할 수 없습니다.' },
{ label: 'head officer', targetId: 3, reason: '수뇌는 추방할 수 없습니다.' },
{ label: 'ambassador', targetId: 4, reason: '외교권자는 추방할 수 없습니다.' },
] as const;
for (const testCase of cases) {
const fixture = buildWorld({
generals: [
buildGeneral(1, { officerLevel: 12 }),
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3, { officerLevel: 7 }),
buildGeneral(4, {
meta: { killturn: 12, belong: 5, permission: 'ambassador' },
penalty: { noAmbassador: true },
}),
buildGeneral(5),
],
});
const originalTarget = fixture.world.getGeneralById(testCase.targetId);
await expect(
fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId })
).resolves.toMatchObject({ ok: false, reason: testCase.reason });
expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget);
expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12);
expect(fixture.world.peekDirtyState().logs, testCase.label).toEqual([]);
expect(fixture.world.peekDirtyState().nations, testCase.label).toEqual([]);
}
});
it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => { it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => {
const early = buildWorld({ const early = buildWorld({
currentYear: 181, currentYear: 181,
+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: 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: 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: 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: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 },
{ id: 6, level: 5, layoutLevel: 8, x: 200, y: 220, width: 24, height: 16 }, { id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 },
{ id: 7, level: 6, layoutLevel: 8, x: 300, y: 220, width: 26, height: 18 }, { id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 },
{ id: 8, level: 7, layoutLevel: 8, x: 400, y: 220, width: 28, height: 20 }, { id: 8, name: '대', level: 7, layoutLevel: 8, x: 450, y: 480, width: 28, height: 20 },
] as const; ] as const;
const map = { const map = {
result: true, result: true,
@@ -82,9 +82,9 @@ const map = {
}; };
const layout = { const layout = {
mapName: 'che', mapName: 'che',
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y }) => ({ cityList: castleFixtures.map(({ id, layoutLevel: level, x, y, ...fixture }) => ({
id, id,
name: id === 1 ? '업' : `${id}`, name: id === 1 ? '업' : 'name' in fixture ? fixture.name : `${id}`,
level, level,
region: 1, region: 1,
x, 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 expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover(); await desktopCity.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업'); 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 desktopCity.click();
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u); await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
await page.goBack(); await page.goBack();
@@ -412,6 +412,13 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
expect(rowGeometry.gradientCount).toBe(0); expect(rowGeometry.gradientCount).toBe(0);
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible();
await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([
'장수 선택',
'하후돈 (70/70/70)',
'곽가 (70/70/70)',
'정욱 (70/70/70)',
'장료 (70/70/70)',
]);
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click(); await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog'); const picker = page.getByTestId('personnel-selection-dialog');
@@ -92,6 +92,8 @@ const BASE_MAP_WIDTH = 700;
const BASE_MAP_HEIGHT = 500; const BASE_MAP_HEIGHT = 500;
const SMALL_MAP_SCALE = 5 / 7; const SMALL_MAP_SCALE = 5 / 7;
const MAP_BACKGROUND_TRANSITION_MS = 480; const MAP_BACKGROUND_TRANSITION_MS = 480;
const TOOLTIP_FALLBACK_HEIGHT = 32;
const TOOLTIP_VERTICAL_OFFSET = 30;
const decodedImageCache = new Map<string, Promise<void>>(); const decodedImageCache = new Map<string, Promise<void>>();
const decodedImageElements = new Map<string, HTMLImageElement>(); const decodedImageElements = new Map<string, HTMLImageElement>();
@@ -146,6 +148,7 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const mapArea = ref<HTMLElement | null>(null); const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null); const mapBody = ref<HTMLElement | null>(null);
const mapControls = ref<HTMLElement | null>(null); const mapControls = ref<HTMLElement | null>(null);
const tooltipElement = ref<HTMLElement | null>(null);
const mapOptionsOpen = ref(false); const mapOptionsOpen = ref(false);
const mapOptionsMenuId = `map-options-${useId()}`; const mapOptionsMenuId = `map-options-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody); const { width: mapBodyWidth } = useElementSize(mapBody);
@@ -568,10 +571,15 @@ const tooltipPosition = computed(() => {
const width = 120; const width = 120;
const offset = 10; const offset = 10;
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value; 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 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 { return {
left: `${Math.max(0, left)}px`, 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>
<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-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div> <div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div> </div>
@@ -99,7 +99,9 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => {
return candidates; return candidates;
}; };
const kickCandidates = computed(() => const kickCandidates = computed(() =>
(data.value?.generals ?? []).filter((general) => general.id !== data.value?.me.id) (data.value?.generals ?? []).filter(
(general) => general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador'
)
); );
const awardText = (entries: PersonnelResponse['awards']['tigers']): string => const awardText = (entries: PersonnelResponse['awards']['tigers']): string =>
entries.map((entry) => `${entry.name}${entry.value.toLocaleString('ko-KR')}`).join(', '); entries.map((entry) => `${entry.name}${entry.value.toLocaleString('ko-KR')}`).join(', ');
+22 -2
View File
@@ -54,8 +54,8 @@ recovery dump.
### Gateway ### Gateway
| Legacy table | Target | Policy | | Legacy table | Target | Policy |
| --------------- | ----------------------------- | ------------------------------------------------------------------------------- | | --------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
| `member` | `app_user` plus `legacy_data` | Preserve identity, roles/ACL, sanctions, OAuth metadata, password hash and salt | | `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 | | `member_log` | `legacy_member_log` | Preserve complete JSON action history |
| `banned_member` | `legacy_banned_member` | Preserve hashed-email ban | | `banned_member` | `legacy_banned_member` | Preserve hashed-email ban |
| `storage` | `legacy_root_key_value` | Preserve raw namespace/key/JSON value | | `storage` | `legacy_root_key_value` | Preserve raw namespace/key/JSON value |
@@ -66,6 +66,26 @@ 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 that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump. 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 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
+3
View File
@@ -566,6 +566,9 @@ importers:
pg: pg:
specifier: ^8.16.3 specifier: ^8.16.3
version: 8.23.0 version: 8.23.0
sharp:
specifier: ^0.35.0
version: 0.35.3(@types/node@26.2.0)
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^26.2.0 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 file (recommended), an environment variable, or directly from the mode-0600
plan. Target PostgreSQL URLs remain in the named environment variables. 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 ```sh
mkdir -p tools/legacy-db-migration/secrets mkdir -p tools/legacy-db-migration/secrets
chmod 700 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 `check-plan` opens every source and target without writing. Its stage JSON lists
every included item as `inventory`, including source, target, strategy and the 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 season/file/byte counts. `run-plan` also
preflights every stage before the first import, is a dry-run without `--apply`, 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; 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`. The individual commands also accept `--mode incremental` and `--source-key`.
Use the ordered plan for production so every configured connection is checked 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 ### Isolated current-season comparison fixture
@@ -10,6 +10,12 @@
"passwordFile": "./secrets/mysql-root-password", "passwordFile": "./secrets/mysql-root-password",
"tls": true "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" "targetUrlEnv": "GATEWAY_DATABASE_URL"
}, },
"profiles": [ "profiles": [
+2 -1
View File
@@ -17,7 +17,8 @@
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"mariadb": "3.5.3", "mariadb": "3.5.3",
"pg": "^8.16.3" "pg": "^8.16.3",
"sharp": "^0.35.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@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 { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js'; import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js'; import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';
import type { LegacyUserIconTransferConfig } from './legacyUserIcons.js';
export interface ResolvedMigrationStage { export interface ResolvedMigrationStage {
kind: 'gateway' | 'game'; kind: 'gateway' | 'game';
@@ -15,6 +16,7 @@ export interface ResolvedMigrationStage {
targetUrl: string; targetUrl: string;
sourceIdentity: MigrationSourceIdentity; sourceIdentity: MigrationSourceIdentity;
battleResults?: BattleResultSourceConfig; battleResults?: BattleResultSourceConfig;
userIcons?: LegacyUserIconTransferConfig;
} }
export interface ResolvedMigrationPlan { 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 parseStage = (value: unknown, label: string): Record<string, unknown> => {
const record = asRecord(value, label); 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`); if (!('source' in record)) throw new Error(`${label}.source is required`);
return record; 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> => { export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
const configPath = path.resolve(configPathInput); const configPath = path.resolve(configPathInput);
const rawText = await readSecureText(configPath, 'Migration config'); const rawText = await readSecureText(configPath, 'Migration config');
@@ -164,6 +203,10 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
if (root.gateway !== undefined) { if (root.gateway !== undefined) {
const gateway = parseStage(root.gateway, 'gateway'); const gateway = parseStage(root.gateway, 'gateway');
const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source'); const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source');
const userIcons =
gateway.userIcons === undefined
? undefined
: await resolveUserIcons(gateway.userIcons, configDirectory, 'gateway.userIcons');
stages.push({ stages.push({
kind: 'gateway', kind: 'gateway',
name: 'gateway', name: 'gateway',
@@ -173,6 +216,7 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
key: `${sourceSet}:gateway`, key: `${sourceSet}:gateway`,
fingerprint: fingerprintMariaConnection(sourceUrl), fingerprint: fingerprintMariaConnection(sourceUrl),
}, },
...(userIcons ? { userIcons } : {}),
}); });
} }
+71 -12
View File
@@ -27,6 +27,14 @@ import {
type MigrationExecutionOptions, type MigrationExecutionOptions,
type MigrationProgress, type MigrationProgress,
} from './incremental.js'; } 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'; import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
export interface MigrationSummary { 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 memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`); const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
const acl = parseJson(row.acl, `member.${memberNo}.acl`); 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 oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
const oauthId = toNullableString(row.oauth_id)?.trim() || null; const oauthId = toNullableString(row.oauth_id)?.trim() || null;
const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`); 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 = { const legacyData: JsonValue = {
memberNo, memberNo,
grade, grade,
acl, acl,
penalty, penalty,
picture: rawPicture,
imageServer,
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
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`),
@@ -137,8 +157,8 @@ export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date |
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: toNullableString(row.PICTURE) ?? 'default.jpg', picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture),
image_server: toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`), image_server: importedIcon?.imageServer ?? 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,
@@ -175,19 +195,39 @@ const processMembers = async (
target: PoolClient | null, target: PoolClient | null,
apply: boolean, apply: boolean,
migratedAt: Date, migratedAt: Date,
counts: Record<string, number> counts: Record<string, number>,
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>
): 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');
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) { if (target) {
await preflightMemberConflicts(target, mapped); await preflightMemberConflicts(target, mapped);
} }
if (target && apply) { if (target && apply) {
await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS }); 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; counts.member = (counts.member ?? 0) + mapped.length;
} }
@@ -285,7 +325,8 @@ export const migrateGateway = async (
targetPool: PgPool | null, targetPool: PgPool | null,
apply: boolean, apply: boolean,
migratedAt: Date, migratedAt: Date,
execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root') execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root'),
userIconConfig?: LegacyUserIconTransferConfig
): Promise<MigrationSummary> => { ): Promise<MigrationSummary> => {
validateSourceIdentity(execution.source); validateSourceIdentity(execution.source);
if (execution.mode === 'incremental' && !targetPool) { if (execution.mode === 'incremental' && !targetPool) {
@@ -300,8 +341,20 @@ export const migrateGateway = async (
const client = targetPool ? await targetPool.connect() : null; const client = targetPool ? await targetPool.connect() : null;
let importRunId: string | null = null; let importRunId: string | null = null;
try { try {
const run = async (runId: string | null): Promise<void> => { const sourceIconRows = await querySource(
await processMembers(source, client, apply, migratedAt, counts); 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 = { progress.member = {
strategy: 'rescan', strategy: 'rescan',
startAfterId: null, startAfterId: null,
@@ -365,9 +418,13 @@ export const migrateGateway = async (
); );
importRunId = created.rows[0]?.id ?? null; importRunId = created.rows[0]?.id ?? null;
if (!importRunId) throw new Error('Failed to create legacy gateway import run'); if (!importRunId) throw new Error('Failed to create legacy gateway import run');
await client.query('BEGIN'); let transactionStarted = false;
try { 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( await client.query(
`UPDATE "legacy_import_run" `UPDATE "legacy_import_run"
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP, SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
@@ -376,8 +433,9 @@ export const migrateGateway = async (
[importRunId, JSON.stringify(counts), JSON.stringify(progress)] [importRunId, JSON.stringify(counts), JSON.stringify(progress)]
); );
await client.query('COMMIT'); await client.query('COMMIT');
transactionStarted = false;
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); if (transactionStarted) await client.query('ROLLBACK');
const message = const message =
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000); error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
await client.query( await client.query(
@@ -391,7 +449,8 @@ export const migrateGateway = async (
} }
}); });
} else { } else {
await run(null); const prepared = await prepareLegacyUserIcons(sourceIconRows, userIconConfig, false);
await run(null, prepared);
} }
} finally { } finally {
client?.release(); client?.release();
+2 -2
View File
@@ -10,9 +10,9 @@ export interface MigrationInventoryItem {
export const GATEWAY_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [ export const GATEWAY_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [
{ {
source: 'member', source: 'member',
target: 'app_user + legacy_data', target: 'app_user + user_icon + legacy_data',
strategy: 'rescan', strategy: 'rescan',
contents: '계정 식별자, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료', contents: '계정 식별자, 전용 아이콘 목록, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료',
}, },
{ {
source: 'member_log', 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 { MigrationMode } from './incremental.js';
import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js'; import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js';
import { migrationInventoryForStage } from './inventory.js'; import { migrationInventoryForStage } from './inventory.js';
import { prepareLegacyUserIcons } from './legacyUserIcons.js';
export interface PlanRunSummary { export interface PlanRunSummary {
command: 'run-plan'; command: 'run-plan';
@@ -23,6 +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 };
} }
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => { const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
@@ -76,7 +78,22 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
if (!targetReady.rows[0]?.table_name) { if (!targetReady.rows[0]?.table_name) {
throw new Error(`Target migrations are not current for ${stage.name}; missing ${targetDataTable}`); 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 }>( const battleResultReady = await target.query<{ table_name: string | null }>(
'SELECT to_regclass($1) AS table_name', 'SELECT to_regclass($1) AS table_name',
['legacy_archive.general_battle_result'] ['legacy_archive.general_battle_result']
@@ -113,6 +130,7 @@ export const checkMigrationPlan = async (plan: ResolvedMigrationPlan): Promise<R
status: 'READY', status: 'READY',
inventory: migrationInventoryForStage(stage), inventory: migrationInventoryForStage(stage),
...(preflight.battleResults ? { battleResults: preflight.battleResults } : {}), ...(preflight.battleResults ? { battleResults: preflight.battleResults } : {}),
...(preflight.userIcons ? { userIcons: preflight.userIcons } : {}),
}); });
} }
return { return {
@@ -140,7 +158,7 @@ export const runMigrationPlan = async (
const execution = { mode, source: stage.sourceIdentity } as const; const execution = { mode, source: stage.sourceIdentity } as const;
const summary = const summary =
stage.kind === 'gateway' 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); : await migrateGame(source, target, apply, stage.profile!, execution);
const battleResults = const battleResults =
stage.kind === 'game' && stage.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-')); const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-legacy-plan-'));
workDirectories.push(directory); workDirectories.push(directory);
await writeFile(path.join(directory, 'mysql-password'), 'secret-value\n', { mode: 0o600 }); 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'); const configPath = path.join(directory, 'migration-plan.json');
await writeFile( await writeFile(
configPath, configPath,
@@ -30,6 +32,12 @@ const writeFixture = async (mode = 0o600): Promise<string> => {
user: 'migration_reader', user: 'migration_reader',
passwordFile: './mysql-password', 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', targetUrlEnv: 'TEST_GATEWAY_DATABASE_URL',
}, },
}), }),
@@ -51,6 +59,11 @@ describe('legacy migration plan config', () => {
expect(source.username).toBe('migration_reader'); expect(source.username).toBe('migration_reader');
expect(source.password).toBe('secret-value'); expect(source.password).toBe('secret-value');
expect(plan.stages[0]!.sourceIdentity.key).toBe('fixture-cutover:gateway'); 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 () => { 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 { describe, expect, it, vi } from 'vitest';
import { upsertRows } from '../src/db.js'; 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> = {}) => ({ const memberRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7, 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 () => { 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 query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
const client = { query } as unknown as PoolClient; 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'));
});
});