diff --git a/.env.example b/.env.example index 4a5a655..5f07253 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,9 @@ GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret 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 GATEWAY_LOCAL_REGISTRATION_ENABLED=true GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 # Optional. Keep the PEM private key outside Git; if omitted, a per-process RSA key is generated. diff --git a/README.md b/README.md index 7d7072f..6ad51d8 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,15 @@ mount하고 다음 서버 전용 변수를 설정합니다. GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret 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 ``` Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간 -PUT을 수행합니다. 공유 비밀값은 `VITE_*`, 브라우저 응답 또는 Cloudflare로 -전달하지 않습니다. +PUT을 수행합니다. game-api의 국방·외교·정찰 편집기 첨부 이미지도 같은 계약을 +사용하되 `/uploads/core2026/` bind 경로에 저장합니다. 공유 비밀값은 `VITE_*`, +브라우저 응답 또는 Cloudflare로 전달하지 않습니다. ```sh cd ../docker_compose_files/development diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index 45e51fd..df90e26 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -16,6 +16,9 @@ export interface GameApiConfig { uploadPath: string; uploadDir: string; uploadPublicUrl: string | null; + imageUploadBaseUrl: string; + imageUploadSecretFile: string; + contentImagePublicUrl: string; profile: string; scenario: string; profileName: string; @@ -50,6 +53,9 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env uploadPath: env.GAME_UPLOAD_PATH ?? '/uploads', uploadDir: env.GAME_UPLOAD_DIR ?? 'uploads', uploadPublicUrl: env.GAME_UPLOAD_PUBLIC_URL ?? null, + imageUploadBaseUrl: env.GAME_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net', + imageUploadSecretFile: env.GAME_IMAGE_UPLOAD_SECRET_FILE ?? '/run/secrets/image_upload_core2026_secret', + contentImagePublicUrl: env.GAME_CONTENT_IMAGE_PUBLIC_URL ?? 'https://sam-image.hided.net/uploads/core2026', profile, scenario, profileName, diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 7b32945..8824f96 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -8,6 +8,7 @@ import type { BattleSimTransport } from './battleSim/transport.js'; import type { FlushStore } from './auth/flushStore.js'; import type { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import type { AccountIconSource } from './auth/accountIconSource.js'; +import type { ContentImageUploadStore } from './services/remoteContentImageStore.js'; export interface GameProfile { id: string; @@ -91,6 +92,7 @@ export interface GameApiContext { uploadDir: string; uploadPath: string; uploadPublicUrl: string | null; + contentImageUpload?: ContentImageUploadStore; auth: GameSessionTokenPayload | null; accessToken?: string; accessTokenStore: RedisAccessTokenStore; @@ -109,6 +111,7 @@ export const createGameApiContext = (options: { uploadDir: string; uploadPath: string; uploadPublicUrl: string | null; + contentImageUpload?: ContentImageUploadStore; auth: GameSessionTokenPayload | null; accessToken?: string; accessTokenStore: RedisAccessTokenStore; @@ -127,6 +130,7 @@ export const createGameApiContext = (options: { uploadDir: options.uploadDir, uploadPath: options.uploadPath, uploadPublicUrl: options.uploadPublicUrl, + ...(options.contentImageUpload ? { contentImageUpload: options.contentImageUpload } : {}), auth: options.auth, ...(options.accessToken ? { accessToken: options.accessToken } : {}), accessTokenStore: options.accessTokenStore, diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index d11c16c..c7b7480 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -1,8 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import path from 'path'; -import { promises as fs } from 'fs'; -import { randomUUID } from 'crypto'; +import { randomBytes } from 'crypto'; import sharp, { type WebpOptions } from 'sharp'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; @@ -36,21 +34,6 @@ const getBoardActor = async (ctx: Parameters[0]) => { return { general, permission }; }; -const normalizeUploadPath = (value: string) => { - if (!value.startsWith('/')) { - return `/${value}`; - } - return value.replace(/\/$/, ''); -}; - -const buildPublicImageUrl = (uploadPublicUrl: string | null, uploadPath: string, filename: string) => { - if (uploadPublicUrl) { - return `${uploadPublicUrl.replace(/\/$/, '')}/${filename}`; - } - const normalizedPath = normalizeUploadPath(uploadPath); - return `${normalizedPath}/${filename}`; -}; - const parseDataUrl = (dataUrl: string): Buffer => { const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (match) { @@ -274,15 +257,20 @@ export const boardRouter = router({ } } - await fs.mkdir(ctx.uploadDir, { recursive: true }); - const filename = `${randomUUID()}.${outputFormat}`; - await fs.writeFile(path.join(ctx.uploadDir, filename), outputBuffer); + if (!ctx.contentImageUpload) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' }); + } + const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`; + const uploaded = await ctx.contentImageUpload.upload({ + filename, + contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp', + body: outputBuffer, + }); const outputMeta = await sharp(outputBuffer, { animated: true }).metadata(); - const url = buildPublicImageUrl(ctx.uploadPublicUrl, ctx.uploadPath, filename); return { - url, + url: uploaded.publicUrl, width: outputMeta.width ?? metadata.width, height: outputMeta.height ?? metadata.height, format: outputFormat, diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index b536168..acd274b 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -2,6 +2,7 @@ import fastify, { type FastifyRequest } from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import path from 'path'; +import fs from 'node:fs/promises'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { buildGameEventChannel } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; @@ -26,6 +27,7 @@ import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; +import { RemoteContentImageStore } from './services/remoteContentImageStore.js'; const extractBearerToken = (value: string | string[] | undefined): string | null => { if (!value) { @@ -63,6 +65,15 @@ const resolveAuthFromToken = async ( export const createGameApiServer = async () => { const config = resolveGameApiConfigFromEnv(); + const imageUploadSecret = (await fs.readFile(config.imageUploadSecretFile, 'utf8')).trim(); + if (imageUploadSecret.length < 32) { + throw new Error('GAME_IMAGE_UPLOAD_SECRET_FILE must contain at least 32 characters.'); + } + const contentImageUpload = new RemoteContentImageStore( + config.imageUploadBaseUrl, + config.contentImagePublicUrl, + imageUploadSecret + ); const app = fastify({ logger: true, routerOptions: { @@ -195,6 +206,7 @@ export const createGameApiServer = async () => { uploadDir: path.resolve(process.cwd(), config.uploadDir), uploadPath: config.uploadPath, uploadPublicUrl: config.uploadPublicUrl, + contentImageUpload, auth, ...(auth && token ? { accessToken: token } : {}), accessTokenStore, diff --git a/app/game-api/src/services/remoteContentImageStore.ts b/app/game-api/src/services/remoteContentImageStore.ts new file mode 100644 index 0000000..949de08 --- /dev/null +++ b/app/game-api/src/services/remoteContentImageStore.ts @@ -0,0 +1,52 @@ +import { createHash, createHmac, randomUUID } from 'node:crypto'; + +export interface ContentImageUploadResult { + publicUrl: string; +} + +export interface ContentImageUploadStore { + upload(input: { filename: string; contentType: string; body: Buffer }): Promise; +} + +export class RemoteContentImageStore implements ContentImageUploadStore { + constructor( + private readonly baseUrl: string, + private readonly publicBaseUrl: string, + private readonly secret: string, + private readonly fetchImpl: typeof fetch = fetch, + private readonly now: () => number = Date.now + ) {} + + async upload(input: { filename: string; contentType: string; body: Buffer }): Promise { + if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) { + throw new Error('Invalid content image filename.'); + } + const pathname = `/v1/uploads/content/core2026/${input.filename}`; + const expires = String(Math.floor(this.now() / 1000) + 60); + const requestId = randomUUID(); + const digest = createHash('sha256').update(input.body).digest('hex'); + const signature = createHmac('sha256', this.secret) + .update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`) + .digest('hex'); + const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, { + method: 'PUT', + headers: { + 'content-type': input.contentType, + 'x-image-client': 'core2026', + 'x-image-expires': expires, + 'x-image-request-id': requestId, + 'x-image-signature': signature, + }, + body: input.body, + }); + if (!response.ok) { + throw new Error(`Image repository upload failed with HTTP ${response.status}.`); + } + const expectedPath = `uploads/core2026/${input.filename}`; + const payload: unknown = await response.json(); + if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) { + throw new Error('Image repository returned an unexpected content path.'); + } + return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` }; + } +} diff --git a/app/game-api/test/boardRouter.test.ts b/app/game-api/test/boardRouter.test.ts index 135b0ec..c97edb7 100644 --- a/app/game-api/test/boardRouter.test.ts +++ b/app/game-api/test/boardRouter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import sharp from 'sharp'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; @@ -94,6 +95,7 @@ const buildContext = (options: { }>; }>; targetPost?: { id: number; isSecret: boolean } | null; + contentImageUpload?: GameApiContext['contentImageUpload']; }) => { const me = options.me ?? buildGeneral(); const boardPostFindMany = vi.fn(async () => options.posts ?? []); @@ -138,6 +140,7 @@ const buildContext = (options: { uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, + ...(options.contentImageUpload ? { contentImageUpload: options.contentImageUpload } : {}), accessTokenStore, flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', @@ -260,6 +263,30 @@ describe('board router actor, nation, and secret permissions', () => { }); }); + it('uploads normalized editor images through the remote bind store', async () => { + const upload = vi.fn(async ({ filename }: { filename: string }) => ({ + publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`, + })); + const fixture = buildContext({ contentImageUpload: { upload } }); + const png = await sharp({ + create: { width: 64, height: 48, channels: 4, background: '#224466' }, + }) + .png() + .toBuffer(); + + const result = await appRouter.createCaller(fixture.context).board.uploadImage({ + dataUrl: `data:image/png;base64,${png.toString('base64')}`, + }); + + expect(result.url).toMatch( + /^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/ + ); + expect(upload).toHaveBeenCalledWith( + expect.objectContaining({ contentType: 'image/webp', body: expect.any(Buffer) }) + ); + expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false }); + }); + it('does not reveal whether another nation owns a requested comment target', async () => { const fixture = buildContext({ me: buildGeneral({ nationId: 3, officerLevel: 5 }), diff --git a/app/game-api/test/remoteContentImageStore.test.ts b/app/game-api/test/remoteContentImageStore.test.ts new file mode 100644 index 0000000..ce2e63e --- /dev/null +++ b/app/game-api/test/remoteContentImageStore.test.ts @@ -0,0 +1,39 @@ +import { createHash, createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { RemoteContentImageStore } from '../src/services/remoteContentImageStore.js'; + +describe('remote content image store', () => { + it('signs a 60-second body-bound content upload and validates its returned path', async () => { + const filename = `${'c'.repeat(32)}.webp`; + const body = Buffer.from('content-body'); + const secret = 'u'.repeat(32); + let captured: { input: string | URL | Request; init?: RequestInit } | undefined; + const fetchImpl: typeof fetch = async (input, init) => { + captured = { input, init }; + return new Response(JSON.stringify({ path: `uploads/core2026/${filename}` }), { status: 201 }); + }; + const store = new RemoteContentImageStore( + 'https://sam-image.hided.net', + 'https://sam-image.hided.net/uploads/core2026', + secret, + fetchImpl, + () => Date.parse('2026-08-06T00:00:00.000Z') + ); + + await expect(store.upload({ filename, contentType: 'image/webp', body })).resolves.toEqual({ + publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`, + }); + const headers = captured?.init?.headers as Record; + const pathname = `/v1/uploads/content/core2026/${filename}`; + const digest = createHash('sha256').update(body).digest('hex'); + expect(headers['x-image-signature']).toBe( + createHmac('sha256', secret) + .update( + `${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/webp.${digest}` + ) + .digest('hex') + ); + expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`); + expect(Object.values(headers)).not.toContain(secret); + }); +});