diff --git a/README.md b/README.md index 710f7e57..ffa15535 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons GAME_IMAGE_UPLOAD_URL=https://sam-image.hided.net GAME_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret GAME_CONTENT_IMAGE_PUBLIC_URL=https://sam-image.hided.net/uploads/core2026 +IMAGE_SYNC_URL=https://sam-image.hided.net +IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core2026_secret ``` Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간 @@ -98,6 +100,12 @@ PUT을 수행합니다. game-api의 국방·외교·정찰 편집기 첨부 이 사용하되 `/uploads/core2026/` bind 경로에 저장합니다. 공유 비밀값은 `VITE_*`, 브라우저 응답 또는 Cloudflare로 전달하지 않습니다. +Gitea webhook을 놓친 경우에는 별도의 `image_sync_core2026_secret`을 mount한 +서버 컨테이너에서 `pnpm sync:image`를 실행합니다. 특정 이미지 저장소 commit을 +확인하면서 동기화하려면 `pnpm sync:image -- --commit `를 +사용합니다. 이 호출은 현재 활성 branch의 fast-forward만 요청하며 branch를 +변경할 권한은 없습니다. 업로드 secret과 sync secret은 서로 바꾸어 쓰지 않습니다. + ```sh cd ../docker_compose_files/development ./scripts/prepare-instance.sh main 15433 16379 ../../core2026 diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index 21cab89f..9581765d 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -222,7 +222,7 @@ export const accountRouter = router({ }); } const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format; - const filename = `${randomBytes(8).toString('hex')}.${extension}`; + const filename = `${randomBytes(16).toString('hex')}.${extension}`; if (!ctx.userIconUpload) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' }); } diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 0e3d3c97..19bbe980 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -720,7 +720,9 @@ describe('account self service', () => { }); const updated = await users.findById(user.id); - expect(result.iconUrl).toMatch(/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{16}\.png$/); + expect(result.iconUrl).toMatch( + /^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.png$/ + ); expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']); expect(updated?.imageServer).toBe(0); expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed'); diff --git a/package.json b/package.json index 1747476d..303c6af3 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas", "validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources", "manage:general-icons": "node tools/manage-general-icons.mjs", + "sync:image": "node tools/sync-image-repository.mjs", "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", "check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs", "check:architecture": "node tools/check-package-boundaries.mjs", "test:architecture": "node --test tools/check-package-boundaries.test.mjs", + "test:image-sync": "node --test tools/sync-image-repository.test.mjs", "test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json", "typecheck:e2e:frontend-legacy": "tsc -p tools/frontend-legacy-parity/tsconfig.json --noEmit", "test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs", diff --git a/tools/sync-image-repository.mjs b/tools/sync-image-repository.mjs new file mode 100644 index 00000000..26bd3918 --- /dev/null +++ b/tools/sync-image-repository.mjs @@ -0,0 +1,92 @@ +import { createHmac, randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const DEFAULT_BASE_URL = 'https://sam-image.hided.net'; +const DEFAULT_SECRET_FILE = '/run/secrets/image_sync_core2026_secret'; + +const parseArgs = (argv) => { + const result = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--commit' || argument === '--url' || argument === '--secret-file') { + const value = argv[index + 1]; + if (!value) { + throw new Error(`${argument} requires a value.`); + } + result[argument.slice(2)] = value; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + return result; +}; + +const normalizeEndpoint = (baseUrl) => { + const url = new URL(baseUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Image sync URL must use HTTP or HTTPS.'); + } + url.pathname = `${url.pathname.replace(/\/$/, '')}/v1/sync`; + url.search = ''; + url.hash = ''; + return url.toString(); +}; + +export const syncImageRepository = async ({ + baseUrl = DEFAULT_BASE_URL, + secretFile = DEFAULT_SECRET_FILE, + commit, + fetchImpl = fetch, + now = Date.now, + requestIdFactory = randomUUID, +} = {}) => { + if (commit !== undefined && !/^[0-9a-f]{40,64}$/i.test(commit)) { + throw new Error('Commit must be a full 40-64 character hexadecimal object ID.'); + } + const secret = (await readFile(secretFile, 'utf8')).trim(); + if (secret.length < 32) { + throw new Error('IMAGE_SYNC_SECRET_FILE must contain at least 32 characters.'); + } + const body = commit ? JSON.stringify({ commit }) : '{}'; + const timestamp = String(Math.floor(now() / 1000)); + const requestId = requestIdFactory(); + const signature = createHmac('sha256', secret).update(`${timestamp}.${requestId}.${body}`).digest('hex'); + const response = await fetchImpl(normalizeEndpoint(baseUrl), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-image-client': 'core2026', + 'x-image-timestamp': timestamp, + 'x-image-request-id': requestId, + 'x-image-signature': signature, + }, + body, + }); + if (!response.ok) { + throw new Error(`Image repository sync failed with HTTP ${response.status}.`); + } + const payload = await response.json(); + if (!payload || typeof payload !== 'object' || payload.ok !== true) { + throw new Error('Image repository returned an unexpected sync response.'); + } + return payload; +}; + +const run = async () => { + const args = parseArgs(process.argv.slice(2)); + const result = await syncImageRepository({ + baseUrl: args.url ?? process.env.IMAGE_SYNC_URL ?? DEFAULT_BASE_URL, + secretFile: args['secret-file'] ?? process.env.IMAGE_SYNC_SECRET_FILE ?? DEFAULT_SECRET_FILE, + commit: args.commit, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + run().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/tools/sync-image-repository.test.mjs b/tools/sync-image-repository.test.mjs new file mode 100644 index 00000000..033f16df --- /dev/null +++ b/tools/sync-image-repository.test.mjs @@ -0,0 +1,67 @@ +import { createHmac } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { syncImageRepository } from './sync-image-repository.mjs'; + +test('signs a scoped Core2026 fallback sync without exposing the secret', async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), 'sammo-image-sync-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const secretFile = path.join(directory, 'secret'); + const secret = 's'.repeat(32); + await writeFile(secretFile, `${secret}\n`, { mode: 0o600 }); + let captured; + const result = await syncImageRepository({ + baseUrl: 'https://sam-image.hided.net/', + secretFile, + commit: 'a'.repeat(40), + now: () => Date.parse('2026-08-08T00:00:00Z'), + requestIdFactory: () => 'request-1', + fetchImpl: async (url, init) => { + captured = { url, init }; + return new Response(JSON.stringify({ ok: true, changed: false }), { status: 200 }); + }, + }); + + assert.deepEqual(result, { ok: true, changed: false }); + assert.equal(captured.url, 'https://sam-image.hided.net/v1/sync'); + assert.equal(captured.init.body, JSON.stringify({ commit: 'a'.repeat(40) })); + assert.equal(captured.init.headers['x-image-client'], 'core2026'); + assert.equal( + captured.init.headers['x-image-signature'], + createHmac('sha256', secret) + .update(`${captured.init.headers['x-image-timestamp']}.request-1.${captured.init.body}`) + .digest('hex') + ); + assert.equal(Object.values(captured.init.headers).includes(secret), false); +}); + +test('rejects invalid commits before reading the secret or making a request', async () => { + await assert.rejects( + syncImageRepository({ + secretFile: '/does/not/exist', + commit: 'main', + fetchImpl: async () => { + throw new Error('must not fetch'); + }, + }), + /Commit must be a full/ + ); +}); + +test('reports authentication failures without returning a response body', async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), 'sammo-image-sync-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const secretFile = path.join(directory, 'secret'); + await writeFile(secretFile, 's'.repeat(32), { mode: 0o600 }); + await assert.rejects( + syncImageRepository({ + secretFile, + fetchImpl: async () => new Response('{"reason":"sensitive detail"}', { status: 401 }), + }), + /HTTP 401/ + ); +});