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