From b84f31d0986a5957fecf696c2549915bc17fe6a2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:29:45 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(game-ui):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EB=8F=84=EC=8B=9C=20=ED=88=B4=ED=8C=81?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 남만, 교지, 남해, 대처럼 지도 하단에 있는 도시의 툴팁이 지도 경계 밖으로 잘리지 않도록 실제 높이를 기준으로 세로 배치를 전환한다. 실제 CHE 좌표를 사용하는 Chromium 회귀 검증을 추가한다. --- app/game-frontend/e2e/inGameInfo.spec.ts | 46 ++++++++++++++++--- .../src/components/main/MapViewer.vue | 12 ++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index e32182a9..c859f953 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -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('.city-base')).find( + (element) => element.getAttribute('aria-label') === expectedCityName + ); + const tooltip = mapArea.querySelector('.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('.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(); diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index 85c7ff03..d757caee 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -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>(); const decodedImageElements = new Map(); @@ -146,6 +148,7 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const mapArea = ref(null); const mapBody = ref(null); const mapControls = ref(null); +const tooltipElement = ref(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) => { > 현재 -
+
{{ hoveredCityTitle }}
{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}
From 0c5dee1af80865dcaae8952801c4b1ebad5a8f76 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:33:19 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(migration):=20Ref=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9D=84=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20API=EB=A1=9C=20=EC=9D=B4=EA=B4=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전용 아이콘 바이트를 모두 검증한 뒤 sam-image 서명 API에 결정적 경로로 등록한다. API 반환 경로를 계정과 아이콘 소유 목록에 연결하고 Core에서 바뀐 현재 선택은 보존한다. --- docs/legacy-db-migration.md | 36 +- pnpm-lock.yaml | 3 + tools/legacy-db-migration/README.md | 14 +- .../migration-plan.example.json | 6 + tools/legacy-db-migration/package.json | 3 +- tools/legacy-db-migration/src/config.ts | 46 +- tools/legacy-db-migration/src/gateway.ts | 83 +++- tools/legacy-db-migration/src/inventory.ts | 4 +- .../src/legacyUserIcons.ts | 403 ++++++++++++++++++ tools/legacy-db-migration/src/plan.ts | 22 +- tools/legacy-db-migration/test/config.test.ts | 13 + .../legacy-db-migration/test/gateway.test.ts | 27 +- .../test/legacyUserIcons.postgres.test.ts | 78 ++++ .../test/legacyUserIcons.test.ts | 182 ++++++++ 14 files changed, 891 insertions(+), 29 deletions(-) create mode 100644 tools/legacy-db-migration/src/legacyUserIcons.ts create mode 100644 tools/legacy-db-migration/test/legacyUserIcons.postgres.test.ts create mode 100644 tools/legacy-db-migration/test/legacyUserIcons.test.ts diff --git a/docs/legacy-db-migration.md b/docs/legacy-db-migration.md index 8bd59bd6..cec07c57 100644 --- a/docs/legacy-db-migration.md +++ b/docs/legacy-db-migration.md @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d40f66df..f7a345b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tools/legacy-db-migration/README.md b/tools/legacy-db-migration/README.md index a31a1fae..4dc02326 100644 --- a/tools/legacy-db-migration/README.md +++ b/tools/legacy-db-migration/README.md @@ -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 diff --git a/tools/legacy-db-migration/migration-plan.example.json b/tools/legacy-db-migration/migration-plan.example.json index c5bbd26f..565b79f1 100644 --- a/tools/legacy-db-migration/migration-plan.example.json +++ b/tools/legacy-db-migration/migration-plan.example.json @@ -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": [ diff --git a/tools/legacy-db-migration/package.json b/tools/legacy-db-migration/package.json index 428b3e85..c78409d4 100644 --- a/tools/legacy-db-migration/package.json +++ b/tools/legacy-db-migration/package.json @@ -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", diff --git a/tools/legacy-db-migration/src/config.ts b/tools/legacy-db-migration/src/config.ts index 4d68144a..6fb8398a 100644 --- a/tools/legacy-db-migration/src/config.ts +++ b/tools/legacy-db-migration/src/config.ts @@ -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, label: string): strin const parseStage = (value: unknown, label: string): Record => { 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 => { + 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 => { const configPath = path.resolve(configPathInput); const rawText = await readSecureText(configPath, 'Migration config'); @@ -164,6 +203,10 @@ export const loadMigrationPlan = async (configPathInput: string): Promise { +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 + counts: Record, + preparedIcons: ReadonlyMap ): Promise => { 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 => { 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 => { - 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 => { + 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(); diff --git a/tools/legacy-db-migration/src/inventory.ts b/tools/legacy-db-migration/src/inventory.ts index 4df8bb0f..8f7d9bf0 100644 --- a/tools/legacy-db-migration/src/inventory.ts +++ b/tools/legacy-db-migration/src/inventory.ts @@ -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', diff --git a/tools/legacy-db-migration/src/legacyUserIcons.ts b/tools/legacy-db-migration/src/legacyUserIcons.ts new file mode 100644 index 00000000..dcd866c6 --- /dev/null +++ b/tools/legacy-db-migration/src/legacyUserIcons.ts @@ -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 = { + 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; + 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 => { + 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 => { + 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 => { + 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 => { + 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 ( + values: readonly T[], + concurrency: number, + mapper: (value: T) => Promise +): Promise => { + const results = new Array(values.length); + let nextIndex = 0; + const worker = async (): Promise => { + 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 => { + 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(); + 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 => { + 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; +}; diff --git a/tools/legacy-db-migration/src/plan.ts b/tools/legacy-db-migration/src/plan.ts index 775f85b4..92973e6d 100644 --- a/tools/legacy-db-migration/src/plan.ts +++ b/tools/legacy-db-migration/src/plan.ts @@ -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 => { @@ -76,7 +78,22 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise '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 => { 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 => { 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 () => { diff --git a/tools/legacy-db-migration/test/gateway.test.ts b/tools/legacy-db-migration/test/gateway.test.ts index d37f2052..89a0f042 100644 --- a/tools/legacy-db-migration/test/gateway.test.ts +++ b/tools/legacy-db-migration/test/gateway.test.ts @@ -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 = {}) => ({ 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; diff --git a/tools/legacy-db-migration/test/legacyUserIcons.postgres.test.ts b/tools/legacy-db-migration/test/legacyUserIcons.postgres.test.ts new file mode 100644 index 00000000..5ff37e9d --- /dev/null +++ b/tools/legacy-db-migration/test/legacyUserIcons.postgres.test.ts @@ -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(); + } + }); +}); diff --git a/tools/legacy-db-migration/test/legacyUserIcons.test.ts b/tools/legacy-db-migration/test/legacyUserIcons.test.ts new file mode 100644 index 00000000..07df2d30 --- /dev/null +++ b/tools/legacy-db-migration/test/legacyUserIcons.test.ts @@ -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 = {}) => ({ + 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(); + + 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(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(); + + 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(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')); + }); +}); From c6459a64dda60088e1074ade95c692b4d451f8f4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:33:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=B6=94=EB=B0=A9=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=20=EB=8C=80=EC=83=81=EC=9D=84=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B0=A8=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증된 실행자와 현재 world의 대상을 기준으로 본인, 군주, 수뇌, 외교권자를 mutation 전에 거부한다. 인사부 후보와 엔진 무변경 회귀, 실제 Chromium 후보 검증을 함께 추가한다. --- .../src/turn/worldCommandHandler.ts | 15 +++++++-- .../test/nationPersonnelManagement.test.ts | 33 +++++++++++++++++++ app/game-frontend/e2e/nationOffices.spec.ts | 7 ++++ .../src/views/NationPersonnelView.vue | 4 ++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 70b12f13..45095881 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -1997,7 +1997,7 @@ async function handleKick( } const target = world.getGeneralById(command.destGeneralId); - if (!target || target.id === general.id || target.nationId !== general.nationId) { + if (!target || target.nationId !== general.nationId) { return { type: 'kick', ok: false, @@ -2005,7 +2005,18 @@ async function handleKick( 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 { type: 'kick', ok: false, diff --git a/app/game-engine/test/nationPersonnelManagement.test.ts b/app/game-engine/test/nationPersonnelManagement.test.ts index aa42b49c..11391432 100644 --- a/app/game-engine/test/nationPersonnelManagement.test.ts +++ b/app/game-engine/test/nationPersonnelManagement.test.ts @@ -312,6 +312,39 @@ describe('nation personnel world commands', () => { 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 () => { const early = buildWorld({ currentYear: 181, diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 1c626da9..8d098d47 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -412,6 +412,13 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie expect(rowGeometry.gradientCount).toBe(0); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); 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(); const picker = page.getByTestId('personnel-selection-dialog'); diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index 74d405c8..7a3af80d 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -99,7 +99,9 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => { return candidates; }; 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 => entries.map((entry) => `${entry.name}【${entry.value.toLocaleString('ko-KR')}】`).join(', ');