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_URL=https://sam-image.hided.net
|
||||||
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
||||||
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
|
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_REGISTRATION_ENABLED=true
|
||||||
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7
|
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7
|
||||||
# Optional. Keep the PEM private key outside Git; if omitted, a per-process RSA key is generated.
|
# 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_URL=https://sam-image.hided.net
|
||||||
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
||||||
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
|
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 요청으로 서버 간
|
Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간
|
||||||
PUT을 수행합니다. 공유 비밀값은 `VITE_*`, 브라우저 응답 또는 Cloudflare로
|
PUT을 수행합니다. game-api의 국방·외교·정찰 편집기 첨부 이미지도 같은 계약을
|
||||||
전달하지 않습니다.
|
사용하되 `/uploads/core2026/` bind 경로에 저장합니다. 공유 비밀값은 `VITE_*`,
|
||||||
|
브라우저 응답 또는 Cloudflare로 전달하지 않습니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd ../docker_compose_files/development
|
cd ../docker_compose_files/development
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export interface GameApiConfig {
|
|||||||
uploadPath: string;
|
uploadPath: string;
|
||||||
uploadDir: string;
|
uploadDir: string;
|
||||||
uploadPublicUrl: string | null;
|
uploadPublicUrl: string | null;
|
||||||
|
imageUploadBaseUrl: string;
|
||||||
|
imageUploadSecretFile: string;
|
||||||
|
contentImagePublicUrl: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
scenario: string;
|
scenario: string;
|
||||||
profileName: string;
|
profileName: string;
|
||||||
@@ -50,6 +53,9 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
|||||||
uploadPath: env.GAME_UPLOAD_PATH ?? '/uploads',
|
uploadPath: env.GAME_UPLOAD_PATH ?? '/uploads',
|
||||||
uploadDir: env.GAME_UPLOAD_DIR ?? 'uploads',
|
uploadDir: env.GAME_UPLOAD_DIR ?? 'uploads',
|
||||||
uploadPublicUrl: env.GAME_UPLOAD_PUBLIC_URL ?? null,
|
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,
|
profile,
|
||||||
scenario,
|
scenario,
|
||||||
profileName,
|
profileName,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { BattleSimTransport } from './battleSim/transport.js';
|
|||||||
import type { FlushStore } from './auth/flushStore.js';
|
import type { FlushStore } from './auth/flushStore.js';
|
||||||
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||||
import type { AccountIconSource } from './auth/accountIconSource.js';
|
import type { AccountIconSource } from './auth/accountIconSource.js';
|
||||||
|
import type { ContentImageUploadStore } from './services/remoteContentImageStore.js';
|
||||||
|
|
||||||
export interface GameProfile {
|
export interface GameProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -91,6 +92,7 @@ export interface GameApiContext {
|
|||||||
uploadDir: string;
|
uploadDir: string;
|
||||||
uploadPath: string;
|
uploadPath: string;
|
||||||
uploadPublicUrl: string | null;
|
uploadPublicUrl: string | null;
|
||||||
|
contentImageUpload?: ContentImageUploadStore;
|
||||||
auth: GameSessionTokenPayload | null;
|
auth: GameSessionTokenPayload | null;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
accessTokenStore: RedisAccessTokenStore;
|
accessTokenStore: RedisAccessTokenStore;
|
||||||
@@ -109,6 +111,7 @@ export const createGameApiContext = (options: {
|
|||||||
uploadDir: string;
|
uploadDir: string;
|
||||||
uploadPath: string;
|
uploadPath: string;
|
||||||
uploadPublicUrl: string | null;
|
uploadPublicUrl: string | null;
|
||||||
|
contentImageUpload?: ContentImageUploadStore;
|
||||||
auth: GameSessionTokenPayload | null;
|
auth: GameSessionTokenPayload | null;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
accessTokenStore: RedisAccessTokenStore;
|
accessTokenStore: RedisAccessTokenStore;
|
||||||
@@ -127,6 +130,7 @@ export const createGameApiContext = (options: {
|
|||||||
uploadDir: options.uploadDir,
|
uploadDir: options.uploadDir,
|
||||||
uploadPath: options.uploadPath,
|
uploadPath: options.uploadPath,
|
||||||
uploadPublicUrl: options.uploadPublicUrl,
|
uploadPublicUrl: options.uploadPublicUrl,
|
||||||
|
...(options.contentImageUpload ? { contentImageUpload: options.contentImageUpload } : {}),
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
...(options.accessToken ? { accessToken: options.accessToken } : {}),
|
...(options.accessToken ? { accessToken: options.accessToken } : {}),
|
||||||
accessTokenStore: options.accessTokenStore,
|
accessTokenStore: options.accessTokenStore,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import path from 'path';
|
import { randomBytes } from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import sharp, { type WebpOptions } from 'sharp';
|
import sharp, { type WebpOptions } from 'sharp';
|
||||||
|
|
||||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
@@ -36,21 +34,6 @@ const getBoardActor = async (ctx: Parameters<typeof getMyGeneral>[0]) => {
|
|||||||
return { general, permission };
|
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 parseDataUrl = (dataUrl: string): Buffer => {
|
||||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -274,15 +257,20 @@ export const boardRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.mkdir(ctx.uploadDir, { recursive: true });
|
if (!ctx.contentImageUpload) {
|
||||||
const filename = `${randomUUID()}.${outputFormat}`;
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||||
await fs.writeFile(path.join(ctx.uploadDir, filename), outputBuffer);
|
}
|
||||||
|
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 outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
|
||||||
const url = buildPublicImageUrl(ctx.uploadPublicUrl, ctx.uploadPath, filename);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url: uploaded.publicUrl,
|
||||||
width: outputMeta.width ?? metadata.width,
|
width: outputMeta.width ?? metadata.width,
|
||||||
height: outputMeta.height ?? metadata.height,
|
height: outputMeta.height ?? metadata.height,
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fastify, { type FastifyRequest } from 'fastify';
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
import { buildGameEventChannel } from '@sammo-ts/common';
|
import { buildGameEventChannel } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
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 { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
|
||||||
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
|
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
|
||||||
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
|
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
|
||||||
|
import { RemoteContentImageStore } from './services/remoteContentImageStore.js';
|
||||||
|
|
||||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -63,6 +65,15 @@ const resolveAuthFromToken = async (
|
|||||||
|
|
||||||
export const createGameApiServer = async () => {
|
export const createGameApiServer = async () => {
|
||||||
const config = resolveGameApiConfigFromEnv();
|
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({
|
const app = fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
routerOptions: {
|
routerOptions: {
|
||||||
@@ -195,6 +206,7 @@ export const createGameApiServer = async () => {
|
|||||||
uploadDir: path.resolve(process.cwd(), config.uploadDir),
|
uploadDir: path.resolve(process.cwd(), config.uploadDir),
|
||||||
uploadPath: config.uploadPath,
|
uploadPath: config.uploadPath,
|
||||||
uploadPublicUrl: config.uploadPublicUrl,
|
uploadPublicUrl: config.uploadPublicUrl,
|
||||||
|
contentImageUpload,
|
||||||
auth,
|
auth,
|
||||||
...(auth && token ? { accessToken: token } : {}),
|
...(auth && token ? { accessToken: token } : {}),
|
||||||
accessTokenStore,
|
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 { describe, expect, it, vi } from 'vitest';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
@@ -94,6 +95,7 @@ const buildContext = (options: {
|
|||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
targetPost?: { id: number; isSecret: boolean } | null;
|
targetPost?: { id: number; isSecret: boolean } | null;
|
||||||
|
contentImageUpload?: GameApiContext['contentImageUpload'];
|
||||||
}) => {
|
}) => {
|
||||||
const me = options.me ?? buildGeneral();
|
const me = options.me ?? buildGeneral();
|
||||||
const boardPostFindMany = vi.fn(async () => options.posts ?? []);
|
const boardPostFindMany = vi.fn(async () => options.posts ?? []);
|
||||||
@@ -138,6 +140,7 @@ const buildContext = (options: {
|
|||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
|
...(options.contentImageUpload ? { contentImageUpload: options.contentImageUpload } : {}),
|
||||||
accessTokenStore,
|
accessTokenStore,
|
||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
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 () => {
|
it('does not reveal whether another nation owns a requested comment target', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
me: buildGeneral({ nationId: 3, officerLevel: 5 }),
|
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