feat(game): upload editor images to image service

This commit is contained in:
2026-08-06 15:52:54 +00:00
parent 57800d8574
commit e198f70756
9 changed files with 160 additions and 25 deletions
+3
View File
@@ -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.
+6 -2
View File
@@ -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
+6
View File
@@ -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,
+4
View File
@@ -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,
+11 -23
View File
@@ -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<typeof getMyGeneral>[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,
+12
View File
@@ -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,
@@ -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<ContentImageUploadResult>;
}
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<ContentImageUploadResult> {
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}` };
}
}
+27
View File
@@ -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 }),
@@ -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<string, string>;
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);
});
});