운영 보안을 보존하여 빌드 개선 배포본 통합
This commit is contained in:
commit
b3f7a419a9
97 files changed
+5522
-740
No files matched your search
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
buildSessionRevocationBaselineKey,
|
||||
buildUserSessionRevocationKey,
|
||||
parseSessionRevocationWatermark,
|
||||
SessionRevocationCommandBudget,
|
||||
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||
|
||||
export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
|
||||
}
|
||||
|
||||
export interface FlushStore {
|
||||
getFlushedAt(userId: string): Date | null;
|
||||
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
|
||||
applyFlush(event: GatewayUserFlushEvent): void;
|
||||
}
|
||||
|
||||
@@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore {
|
||||
}
|
||||
}
|
||||
|
||||
interface RevocationRedisClient {
|
||||
readonly isReady?: boolean;
|
||||
mGet(keys: string[]): Promise<(string | null)[]>;
|
||||
withAbortSignal?(signal: AbortSignal): RevocationRedisClient;
|
||||
}
|
||||
|
||||
/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */
|
||||
export class RedisFlushStore implements FlushStore {
|
||||
private readonly local = new Map<string, Date>();
|
||||
private readonly commands = new SessionRevocationCommandBudget(64);
|
||||
constructor(
|
||||
private readonly client: RevocationRedisClient,
|
||||
private readonly channel: string
|
||||
) {}
|
||||
|
||||
private commandClient(): RevocationRedisClient {
|
||||
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
|
||||
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
}
|
||||
|
||||
async assertInitialized(): Promise<void> {
|
||||
const values = await this.commands.run(() =>
|
||||
this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)])
|
||||
);
|
||||
if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!))
|
||||
throw new Error('Gateway session revocation must be initialized before game API startup.');
|
||||
}
|
||||
|
||||
async getFlushedAt(userId: string): Promise<Date | null> {
|
||||
const values = await this.commands.run(() =>
|
||||
this.commandClient().mGet([
|
||||
buildSessionRevocationBaselineKey(this.channel),
|
||||
buildUserSessionRevocationKey(this.channel, userId),
|
||||
])
|
||||
);
|
||||
if (values.length !== 2) throw new Error('Session revocation storage is unavailable.');
|
||||
const baseline = parseSessionRevocationWatermark(values[0]!);
|
||||
if (!baseline) throw new Error('Session revocation storage is unavailable.');
|
||||
const persisted = parseSessionRevocationWatermark(values[1]!);
|
||||
return new Date(
|
||||
Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
applyFlush(event: GatewayUserFlushEvent): void {
|
||||
const parsed = new Date(event.flushedAt);
|
||||
if (Number.isNaN(parsed.getTime())) return;
|
||||
const prior = this.local.get(event.userId);
|
||||
if (!prior || parsed > prior) {
|
||||
this.local.delete(event.userId);
|
||||
this.local.set(event.userId, parsed);
|
||||
}
|
||||
// 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다.
|
||||
if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushSubscriber {
|
||||
private readonly client: {
|
||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||
|
||||
const parseReconcileInterval = (value: string | undefined): number => {
|
||||
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
|
||||
|
||||
export interface GameApiConfig {
|
||||
host: string;
|
||||
corsAllowedOrigins: string[];
|
||||
port: number;
|
||||
trpcPath: string;
|
||||
eventsPath: string;
|
||||
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
|
||||
return {
|
||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
|
||||
env.GATEWAY_PUBLIC_URL,
|
||||
env.GAME_PUBLIC_URL,
|
||||
]),
|
||||
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
const MAX_PENDING_FRAMES = 32;
|
||||
const MAX_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
/** Revalidate before work and before writing its result; never retain unbounded frames. */
|
||||
export class AuthenticatedRealtimeConnection {
|
||||
private queue = Promise.resolve();
|
||||
private pending = 0;
|
||||
private ended = false;
|
||||
private readonly expiryTimer: ReturnType<typeof setTimeout>;
|
||||
private cleanup = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
readonly auth: GameSessionTokenPayload,
|
||||
private readonly response: ServerResponse,
|
||||
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
|
||||
) {
|
||||
// AccessTokenStore requires a full remaining TTL second on every lookup.
|
||||
this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999));
|
||||
this.expiryTimer.unref();
|
||||
response.once('close', this.close);
|
||||
response.once('error', this.close);
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.ended;
|
||||
}
|
||||
|
||||
whenIdle(): Promise<void> {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
onClose(cleanup: () => void): void {
|
||||
if (this.ended) cleanup();
|
||||
else this.cleanup.add(cleanup);
|
||||
}
|
||||
|
||||
private async currentAuth(): Promise<GameSessionTokenPayload | null> {
|
||||
if (this.ended) return null;
|
||||
const current = await this.resolveAuth();
|
||||
if (
|
||||
!current ||
|
||||
this.ended ||
|
||||
current.sessionId !== this.auth.sessionId ||
|
||||
current.user.id !== this.auth.user.id ||
|
||||
current.profile !== this.auth.profile
|
||||
) {
|
||||
this.close();
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
enqueue(work: (auth: GameSessionTokenPayload) => Promise<string | null>): void {
|
||||
if (this.ended) return;
|
||||
if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.pending += 1;
|
||||
this.queue = this.queue
|
||||
.then(async () => {
|
||||
const current = await this.currentAuth();
|
||||
if (!current) return;
|
||||
const frame = await work(current);
|
||||
if (!frame || !(await this.currentAuth())) return;
|
||||
if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close();
|
||||
})
|
||||
.catch(() => {
|
||||
// Redis/auth failure is fail closed. Reconnect must authenticate again.
|
||||
this.close();
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
close = (): void => {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
clearTimeout(this.expiryTimer);
|
||||
this.response.off('close', this.close);
|
||||
this.response.off('error', this.close);
|
||||
for (const cleanup of this.cleanup) cleanup();
|
||||
this.cleanup.clear();
|
||||
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const DEFAULT_LIMITS = { connections: 512, perUser: 8, opening: 32 } as const;
|
||||
const OPENING_DEADLINE_MS = 5000;
|
||||
|
||||
export interface RealtimeConnectionReservation {
|
||||
canContinue(): boolean;
|
||||
reserveUser(userId: string): boolean;
|
||||
startStreaming(): boolean;
|
||||
cancelOpening(): void;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** One game API process owns both pending setup and live streams; no waiting queue. */
|
||||
export class RealtimeConnectionAdmission {
|
||||
private readonly limits: { connections: number; perUser: number; opening: number };
|
||||
private total = 0;
|
||||
private opening = 0;
|
||||
private closed = false;
|
||||
private readonly byUser = new Map<string, number>();
|
||||
private readonly pending = new Set<() => void>();
|
||||
|
||||
constructor(limits: Partial<{ connections: number; perUser: number; opening: number }> = {}) {
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
if (Object.values(this.limits).some((value) => !Number.isSafeInteger(value) || value < 1))
|
||||
throw new Error('Invalid realtime connection limits.');
|
||||
}
|
||||
|
||||
begin(onTimeout: () => void): RealtimeConnectionReservation | null {
|
||||
if (this.closed || this.total >= this.limits.connections || this.opening >= this.limits.opening) return null;
|
||||
this.total += 1;
|
||||
this.opening += 1;
|
||||
let userId: string | null = null;
|
||||
let streaming = false;
|
||||
let cancelled = false;
|
||||
let released = false;
|
||||
const deadline = Date.now() + OPENING_DEADLINE_MS;
|
||||
const expire = () => {
|
||||
if (released || streaming || cancelled) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
onTimeout();
|
||||
};
|
||||
const timer = setTimeout(expire, OPENING_DEADLINE_MS);
|
||||
timer.unref();
|
||||
this.pending.add(expire);
|
||||
const canContinue = () => {
|
||||
if (!released && !streaming && !cancelled && Date.now() >= deadline) expire();
|
||||
return !released && !streaming && !cancelled;
|
||||
};
|
||||
return {
|
||||
canContinue,
|
||||
reserveUser: (id) => {
|
||||
if (!canContinue() || userId !== null) return false;
|
||||
const count = this.byUser.get(id) ?? 0;
|
||||
if (count >= this.limits.perUser) return false;
|
||||
this.byUser.set(id, count + 1);
|
||||
userId = id;
|
||||
return true;
|
||||
},
|
||||
startStreaming: () => {
|
||||
if (!canContinue() || userId === null) return false;
|
||||
streaming = true;
|
||||
this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
return true;
|
||||
},
|
||||
cancelOpening: () => {
|
||||
if (released || streaming) return;
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
// Disconnect/timeout does not prove the awaited Redis/SQL operation has settled.
|
||||
},
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.total -= 1;
|
||||
if (!streaming) this.opening -= 1;
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(expire);
|
||||
if (userId !== null) {
|
||||
const count = (this.byUser.get(userId) ?? 1) - 1;
|
||||
if (count === 0) this.byUser.delete(userId);
|
||||
else this.byUser.set(userId, count);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
for (const expire of this.pending) expire();
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,15 @@ export const authRouter = router({
|
||||
message: 'Game access is restricted for this account.',
|
||||
});
|
||||
}
|
||||
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id);
|
||||
let flushedAt: Date | null;
|
||||
try {
|
||||
flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Authentication protection is unavailable.',
|
||||
});
|
||||
}
|
||||
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
ImageWorkBudget,
|
||||
ImageWorkBusyError,
|
||||
ImageUploadError,
|
||||
isRasterImage,
|
||||
} from '@sammo-ts/common/images/imageUpload';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'crypto';
|
||||
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
const boardImageBudget = new ImageWorkBudget();
|
||||
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
|
||||
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
||||
const MAX_LONG_EDGE = 2048;
|
||||
const WEBP_QUALITY = 80;
|
||||
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
|
||||
buffer: Buffer,
|
||||
{ animated, resize }: { animated: boolean; resize: boolean }
|
||||
): Promise<Buffer> => {
|
||||
let pipeline = sharp(buffer, { animated: true });
|
||||
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||
if (resize) {
|
||||
pipeline = pipeline.resize({
|
||||
width: MAX_LONG_EDGE,
|
||||
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
|
||||
...(animated ? { animated: true } : {}),
|
||||
};
|
||||
|
||||
return pipeline.webp(webpOptions).toBuffer();
|
||||
return pipeline
|
||||
.webp(webpOptions)
|
||||
.toBuffer()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
|
||||
let pipeline = sharp(buffer, { animated: true });
|
||||
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||
if (resize) {
|
||||
pipeline = pipeline.resize({
|
||||
width: MAX_LONG_EDGE,
|
||||
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer();
|
||||
return pipeline
|
||||
.avif({ quality: 60, effort: 4 })
|
||||
.toBuffer()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
export const boardRouter = router({
|
||||
@@ -213,70 +232,116 @@ export const boardRouter = router({
|
||||
|
||||
return { id: comment.id };
|
||||
}),
|
||||
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, true);
|
||||
uploadImage: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dataUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, true);
|
||||
|
||||
const buffer = parseDataUrl(input.dataUrl);
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
|
||||
}
|
||||
try {
|
||||
return await boardImageBudget.run(async () => {
|
||||
const buffer = parseDataUrl(input.dataUrl);
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: 'PAYLOAD_TOO_LARGE',
|
||||
message: '이미지 용량 제한(1MB)을 초과했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
}
|
||||
if (!isRasterImage(buffer))
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
});
|
||||
if ((metadata.pages ?? 1) > 256)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
|
||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const format = metadata.format;
|
||||
const isAvif = metadata.mediaType === 'image/avif';
|
||||
const isAnimated = (metadata.pages ?? 1) > 1;
|
||||
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
|
||||
const format = metadata.format;
|
||||
const isAvif = metadata.mediaType === 'image/avif';
|
||||
const isAnimated = (metadata.pages ?? 1) > 1;
|
||||
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
|
||||
|
||||
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
|
||||
if (!allowed.has(format)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
}
|
||||
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
|
||||
if (!allowed.has(format)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
}
|
||||
|
||||
let outputBuffer = buffer;
|
||||
let outputFormat = isAvif ? 'avif' : 'webp';
|
||||
let outputBuffer = buffer;
|
||||
let outputFormat = isAvif ? 'avif' : 'webp';
|
||||
|
||||
if (isAvif) {
|
||||
if (needsResize) {
|
||||
outputBuffer = await buildAvifBuffer(buffer, true);
|
||||
if (isAvif) {
|
||||
if (needsResize) {
|
||||
outputBuffer = await buildAvifBuffer(buffer, true);
|
||||
}
|
||||
} else {
|
||||
const webpBuffer = await buildWebpBuffer(buffer, {
|
||||
animated: isAnimated || format === 'gif',
|
||||
resize: needsResize,
|
||||
});
|
||||
if (
|
||||
format === 'webp' &&
|
||||
!needsResize &&
|
||||
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
|
||||
) {
|
||||
outputBuffer = buffer;
|
||||
outputFormat = 'webp';
|
||||
} else {
|
||||
outputBuffer = webpBuffer;
|
||||
outputFormat = 'webp';
|
||||
}
|
||||
}
|
||||
|
||||
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, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata();
|
||||
|
||||
return {
|
||||
url: uploaded.publicUrl,
|
||||
width: outputMeta.width ?? metadata.width,
|
||||
height: outputMeta.height ?? metadata.height,
|
||||
format: outputFormat,
|
||||
animated: isAnimated,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageWorkBusyError)
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
if (error instanceof ImageUploadError)
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const webpBuffer = await buildWebpBuffer(buffer, {
|
||||
animated: isAnimated || format === 'gif',
|
||||
resize: needsResize,
|
||||
});
|
||||
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
|
||||
outputBuffer = buffer;
|
||||
outputFormat = 'webp';
|
||||
} else {
|
||||
outputBuffer = webpBuffer;
|
||||
outputFormat = 'webp';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return {
|
||||
url: uploaded.publicUrl,
|
||||
width: outputMeta.width ?? metadata.width,
|
||||
height: outputMeta.height ?? metadata.height,
|
||||
format: outputFormat,
|
||||
animated: isAnimated,
|
||||
size: outputBuffer.length,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
} from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
|
||||
if (result?.type === 'commandRejected') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
||||
}
|
||||
if (!result || result.type !== 'tournamentAdjustGeneral') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
|
||||
}
|
||||
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
|
||||
};
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
return true;
|
||||
@@ -197,6 +207,7 @@ const zMatch = z.object({
|
||||
|
||||
const zBetEntry = z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int().positive(),
|
||||
amount: z.number().int().positive(),
|
||||
});
|
||||
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
|
||||
});
|
||||
}),
|
||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, entries, matches] = await Promise.all([
|
||||
store.getState(),
|
||||
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
|
||||
}
|
||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||
totalAmount += entry.amount;
|
||||
if (entry.generalId === general.id) {
|
||||
if (entry.userId === ctx.auth!.user.id) {
|
||||
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||
myAmount += entry.amount;
|
||||
}
|
||||
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
|
||||
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -develCost,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
assertTournamentAdjustment(feeResult);
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: develCost,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||
}
|
||||
|
||||
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
|
||||
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
|
||||
if (entries.some((entry) => entry.userId === undefined)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
|
||||
}
|
||||
const previousBetAmount = entries
|
||||
.filter((entry) => entry.generalId === general.id)
|
||||
.filter((entry) => entry.userId === ctx.auth!.user.id)
|
||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (previousBetAmount + input.amount > 1_000) {
|
||||
throw new TRPCError({
|
||||
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
|
||||
}
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: -input.amount,
|
||||
betGoldDelta: input.amount,
|
||||
minGoldAfter: 500,
|
||||
});
|
||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: input.amount },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
|
||||
}
|
||||
assertTournamentAdjustment(adjustResult);
|
||||
|
||||
try {
|
||||
await store.appendBettingEntry({
|
||||
generalId: general.id,
|
||||
userId: ctx.auth!.user.id,
|
||||
targetId: input.targetId,
|
||||
amount: input.amount,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
assertTournamentAdjustment(
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: -input.amount },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
userId: ctx.auth!.user.id,
|
||||
generalId: general.id,
|
||||
goldDelta: input.amount,
|
||||
betGoldDelta: -input.amount,
|
||||
minGoldAfter: 0,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
+184
-108
@@ -1,8 +1,10 @@
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||
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 { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -21,7 +23,7 @@ import {
|
||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||
import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||
import {
|
||||
consumeRealtimeAccessGrantHeader,
|
||||
@@ -32,6 +34,9 @@ import { appRouter } from './router.js';
|
||||
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||
import { AuthenticatedRealtimeConnection } from './realtime/authenticatedConnection.js';
|
||||
import { RealtimeConnectionAdmission } from './realtime/connectionAdmission.js';
|
||||
import { formatSseFrame } from './realtime/sse.js';
|
||||
import {
|
||||
shouldForwardRealtimeEvent,
|
||||
@@ -71,16 +76,22 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
||||
const resolveAuthFromToken = async (
|
||||
token: string | null,
|
||||
accessTokenStore: RedisAccessTokenStore,
|
||||
flushStore: FlushStore
|
||||
flushStore: FlushStore,
|
||||
profileName: string
|
||||
): Promise<GameSessionTokenPayload | null> => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const stored = await accessTokenStore.get(token);
|
||||
if (!stored) {
|
||||
if (!stored || stored.profile !== profileName) {
|
||||
return null;
|
||||
}
|
||||
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
||||
let flushedAt: Date | null;
|
||||
try {
|
||||
flushedAt = await flushStore.getFlushedAt(stored.user.id);
|
||||
} catch {
|
||||
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' });
|
||||
}
|
||||
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
||||
return null;
|
||||
}
|
||||
@@ -99,11 +110,18 @@ export const createGameApiServer = async () => {
|
||||
imageUploadSecret
|
||||
);
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
logger: safeHttpLoggerOptions,
|
||||
routerOptions: {
|
||||
maxParamLength: 2048,
|
||||
},
|
||||
});
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
|
||||
const realtimeAdmission = new RealtimeConnectionAdmission();
|
||||
app.addHook('preClose', async () => {
|
||||
realtimeAdmission.close();
|
||||
for (const connection of realtimeConnections) connection.close();
|
||||
});
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
@@ -134,7 +152,18 @@ export const createGameApiServer = async () => {
|
||||
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||
});
|
||||
const flushStore = new InMemoryFlushStore();
|
||||
const flushStore = new RedisFlushStore(redis.client, config.flushChannel);
|
||||
try {
|
||||
await flushStore.assertInitialized();
|
||||
} catch {
|
||||
await Promise.allSettled([
|
||||
Promise.resolve().then(() => {
|
||||
if (redis.client.isOpen) redis.client.destroy();
|
||||
}),
|
||||
postgres.disconnect(),
|
||||
]);
|
||||
throw new Error('Gateway session revocation is unavailable; game API startup refused.');
|
||||
}
|
||||
const flushSubscriberClient = redis.client.duplicate();
|
||||
try {
|
||||
await flushSubscriberClient.connect();
|
||||
@@ -150,6 +179,12 @@ export const createGameApiServer = async () => {
|
||||
config.flushChannel,
|
||||
flushStore,
|
||||
async (event) => {
|
||||
const flushedAt = Date.parse(event.flushedAt);
|
||||
for (const connection of realtimeConnections) {
|
||||
if (connection.auth.user.id === event.userId && Date.parse(connection.auth.issuedAt) <= flushedAt) {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
|
||||
},
|
||||
(error, event) => {
|
||||
@@ -239,8 +274,9 @@ export const createGameApiServer = async () => {
|
||||
|
||||
app.addHook('onClose', closeResources);
|
||||
|
||||
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: config.corsAllowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -256,7 +292,7 @@ export const createGameApiServer = async () => {
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
||||
? req.headers['idempotency-key'][0]
|
||||
: req.headers['idempotency-key'];
|
||||
@@ -299,80 +335,124 @@ export const createGameApiServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||
if (
|
||||
!auth ||
|
||||
auth.profile !== config.profileName ||
|
||||
isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile])
|
||||
)
|
||||
return null;
|
||||
return auth;
|
||||
};
|
||||
|
||||
app.get(config.eventsPath, async (request, reply) => {
|
||||
const query = request.query as { token?: string; scope?: string };
|
||||
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
||||
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
||||
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
|
||||
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
|
||||
|
||||
if (!auth) {
|
||||
if (query.token !== undefined || !tokenFromHeader) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
where: { userId: auth.user.id, npcState: 0 },
|
||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||
});
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
let expired = false;
|
||||
let disconnected = false;
|
||||
const opening = realtimeAdmission.begin(() => {
|
||||
expired = true;
|
||||
if (!reply.sent && !reply.raw.destroyed) {
|
||||
reply.header('Retry-After', '15');
|
||||
void reply.status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||
}
|
||||
const nation =
|
||||
general.nationId > 0
|
||||
? await postgres.prisma.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
generalId: general.id,
|
||||
cityId: general.cityId,
|
||||
nationId: general.nationId,
|
||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
|
||||
reply.hijack();
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
|
||||
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader('Vary', 'Origin');
|
||||
});
|
||||
if (!opening) {
|
||||
await reply.header('Retry-After', '15').status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||
return;
|
||||
}
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
request.raw.setTimeout(0);
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const sendFrame = (payload: string) => {
|
||||
const cancelOpening = () => {
|
||||
disconnected = true;
|
||||
opening.cancelOpening();
|
||||
};
|
||||
request.raw.once('aborted', cancelOpening);
|
||||
reply.raw.once('close', cancelOpening);
|
||||
let transferred = false;
|
||||
try {
|
||||
let auth: GameSessionTokenPayload | null = null;
|
||||
try {
|
||||
reply.raw.write(payload);
|
||||
auth = await resolveRealtimeAuth(tokenFromHeader);
|
||||
} catch {
|
||||
if (opening.canContinue() && !reply.raw.destroyed)
|
||||
await reply.status(503).send({ ok: false, error: 'authentication_unavailable' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
if (!opening.canContinue() || reply.raw.destroyed) return;
|
||||
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ready',
|
||||
data: '{}',
|
||||
})
|
||||
);
|
||||
if (!auth || auth.profile !== config.profileName) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
if (!opening.reserveUser(auth.user.id)) {
|
||||
if (opening.canContinue())
|
||||
await reply
|
||||
.header('Retry-After', '15')
|
||||
.status(429)
|
||||
.send({ ok: false, error: 'realtime_connection_limit' });
|
||||
return;
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let eventQueue = Promise.resolve();
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
eventQueue = eventQueue
|
||||
.then(async () => {
|
||||
if (closed) return;
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
where: { userId: auth.user.id, npcState: 0 },
|
||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||
});
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
}
|
||||
const nation =
|
||||
general.nationId > 0
|
||||
? await postgres.prisma.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
generalId: general.id,
|
||||
cityId: general.cityId,
|
||||
nationId: general.nationId,
|
||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
|
||||
|
||||
reply.hijack();
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
|
||||
// Hijacked SSE responses bypass Fastify's normal CORS response hook.
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader('Vary', 'Origin');
|
||||
}
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
request.raw.setTimeout(0);
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
|
||||
resolveRealtimeAuth(tokenFromHeader)
|
||||
);
|
||||
realtimeConnections.add(connection);
|
||||
connection.onClose(() => {
|
||||
realtimeConnections.delete(connection);
|
||||
// A disconnected socket can still own an in-flight Redis/SQL operation.
|
||||
void connection.whenIdle().then(opening.release, opening.release);
|
||||
});
|
||||
transferred = true;
|
||||
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||
connection.enqueue(async (currentAuth) => {
|
||||
const identities = [viewerIdentity];
|
||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||
const nextIdentity = await loadViewerIdentity();
|
||||
@@ -381,58 +461,54 @@ export const createGameApiServer = async () => {
|
||||
}
|
||||
let refreshGrant: string | undefined;
|
||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
|
||||
refreshGrant ??= createRealtimeAccessGrant(
|
||||
currentAuth,
|
||||
config.profileName,
|
||||
config.gameTokenSecret
|
||||
);
|
||||
return refreshGrant;
|
||||
});
|
||||
if (!publicEvent || closed) return;
|
||||
if (!publicEvent || connection.closed) return null;
|
||||
if (refreshGrant) {
|
||||
try {
|
||||
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
|
||||
} catch {
|
||||
// Preserve the invalidation. An unregistered grant safely falls back
|
||||
// to the normal scored refresh path.
|
||||
// An unregistered grant falls back to the scored refresh path.
|
||||
}
|
||||
}
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: publicEvent.type,
|
||||
data: JSON.stringify(publicEvent),
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// A best-effort notification must not affect committed game state.
|
||||
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
|
||||
});
|
||||
});
|
||||
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending) return;
|
||||
heartbeatPending = true;
|
||||
void turnEngineStatus
|
||||
.get()
|
||||
.then((turnEngineRunning) => {
|
||||
if (closed) return;
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
});
|
||||
connection.onClose(unsubscribe);
|
||||
let heartbeatPending = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (heartbeatPending || connection.closed) return;
|
||||
heartbeatPending = true;
|
||||
connection.enqueue(async () => {
|
||||
try {
|
||||
return formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ turnEngineRunning }),
|
||||
})
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
heartbeatPending = false;
|
||||
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
|
||||
});
|
||||
} finally {
|
||||
heartbeatPending = false;
|
||||
}
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
request.raw.on('close', close);
|
||||
request.raw.on('aborted', close);
|
||||
}, 15_000);
|
||||
connection.onClose(() => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
request.raw.once('aborted', connection.close);
|
||||
connection.onClose(() => {
|
||||
request.raw.off('aborted', connection.close);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!expired && !disconnected) throw error;
|
||||
} finally {
|
||||
request.raw.off('aborted', cancelOpening);
|
||||
reply.raw.off('close', cancelOpening);
|
||||
if (!transferred) opening.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_request, reply) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||
|
||||
export interface ContentImageUploadResult {
|
||||
publicUrl: string;
|
||||
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
|
||||
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,
|
||||
await performSignedImageUpload(
|
||||
{
|
||||
...input,
|
||||
kind: 'content',
|
||||
baseUrl: this.baseUrl,
|
||||
secret: this.secret,
|
||||
},
|
||||
body: new Uint8Array(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.');
|
||||
}
|
||||
this.fetchImpl,
|
||||
this.now
|
||||
);
|
||||
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
@@ -286,7 +287,10 @@ export class TournamentStore {
|
||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||
const entries = await this.getBettingEntries();
|
||||
const existing = entries.find(
|
||||
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
|
||||
(candidate) =>
|
||||
candidate.generalId === entry.generalId &&
|
||||
candidate.targetId === entry.targetId &&
|
||||
candidate.userId === entry.userId
|
||||
);
|
||||
if (existing) {
|
||||
existing.amount += entry.amount;
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
|
||||
|
||||
export interface TournamentBetEntry {
|
||||
generalId: number;
|
||||
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
|
||||
userId?: string | null;
|
||||
targetId: number;
|
||||
amount: number;
|
||||
}
|
||||
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
|
||||
for (const npc of npcBetList) {
|
||||
const targetId = rng.choice(candidateIds);
|
||||
if (!existingBettors.has(npc.id as number)) {
|
||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
||||
entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
|
||||
}
|
||||
|
||||
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: `${requestPrefix}:resources`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
|
||||
});
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: `${requestPrefix}:meta`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
|
||||
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
|
||||
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
|
||||
const result = await daemonTransport.requestCommand({
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: `${requestPrefix}:atomic`,
|
||||
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
|
||||
});
|
||||
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
|
||||
throw new Error('NPC tournament betting did not commit.');
|
||||
}
|
||||
const processed = new Set(result.processedGeneralIds);
|
||||
const existing = await store.getBettingEntries();
|
||||
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
|
||||
await store.setBettingEntries(
|
||||
existing.concat(
|
||||
plan
|
||||
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
|
||||
.map((entry) => ({ ...entry, userId: null }))
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
|
||||
@@ -20,10 +21,10 @@ import {
|
||||
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
||||
import { recordGeneralActivity } from './services/generalActivity.js';
|
||||
|
||||
const t = initTRPC.context<GameApiContext>().create();
|
||||
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
|
||||
Reference in new issue
Block a user