feat(game): upload editor images to image service
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user