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
+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}` };
}
}