Commit 6fe7fe95 authored by nanahira's avatar nanahira

add dashboard

parent b7363c75
Pipeline #43299 passed with stages
in 2 minutes and 44 seconds
host: "::"
port: 7911
apiHost: ""
apiPort: 7922
dbHost: ""
dbPort: 5432
dbUser: postgres
......
......@@ -11,6 +11,11 @@ import { SqljsFactory, SqljsLoader } from './services/sqljs';
import { FeatsModule } from './feats/feats-module';
import { MiddlewareRx } from './services/middleware-rx';
import { TypeormFactory, TypeormLoader } from './services/typeorm';
import { SSLFinder } from './services/ssl-finder';
import { KoaService } from './services/koa-service';
import { FileResourceService } from './file-resource';
import { LegacyApiAuthService } from './services/legacy-api-auth-service';
import { LegacyApiModule } from './legacy-api/legacy-api-module';
const core = createAppContext()
.provide(ConfigService, {
......@@ -21,6 +26,16 @@ const core = createAppContext()
.provide(MiddlewareRx, { merge: ['event$'] })
.provide(HttpClient, { merge: ['http'] })
.provide(AragamiService, { merge: ['aragami'] })
.provide(SSLFinder)
.provide(FileResourceService, {
provide: 'fileResource',
})
.provide(LegacyApiAuthService, {
provide: 'legacyApiAuth',
})
.provide(KoaService, {
merge: ['router', 'koa'],
})
.provide(SqljsLoader, {
useFactory: SqljsFactory,
merge: ['SQL'],
......@@ -37,6 +52,7 @@ export type ContextState = AppContextState<Context>;
export const app = core
.use(TransportModule)
.use(FeatsModule)
.use(LegacyApiModule)
.use(RoomModule)
.use(JoinHandlerModule)
.define();
......@@ -6,10 +6,8 @@ import { ClientHandler } from './client-handler';
import { Chnroute } from './chnroute';
import { I18nService } from './i18n';
import { IpResolver } from './ip-resolver';
import { SSLFinder } from './ssl-finder';
export const TransportModule = createAppContext<ContextState>()
.provide(SSLFinder)
.provide(IpResolver)
.provide(Chnroute)
.provide(I18nService)
......
......@@ -39,7 +39,10 @@ export class TcpServer {
socket.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ECONNRESET') {
this.logger.debug(
{ remoteAddress: socket.remoteAddress, remotePort: socket.remotePort },
{
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
},
'TCP socket reset by peer',
);
return;
......
......@@ -3,7 +3,7 @@ import { createServer as createHttpsServer } from 'node:https';
import { Server as WebSocketServer } from 'ws';
import { Context } from '../../../app';
import { ClientHandler } from '../../client-handler';
import { SSLFinder } from '../../ssl-finder';
import { SSLFinder } from '../../../services/ssl-finder';
import { WsClient } from './client';
import { WebSocket } from 'ws';
import { IpResolver } from '../../ip-resolver';
......@@ -26,7 +26,8 @@ export class WsServer {
return;
}
const host = this.ctx.config.getString('HOST');
const host =
this.ctx.config.getString('WS_HOST') || this.ctx.config.getString('HOST');
// Try to get SSL configuration
const sslFinder = this.ctx.get(() => SSLFinder);
......
......@@ -12,6 +12,10 @@ export const defaultConfig = {
HOST: '::',
// Main server port for YGOPro clients. Format: integer string.
PORT: '7911',
// Legacy HTTP API bind address. Empty means fallback to HOST.
API_HOST: '',
// Legacy HTTP API port. Format: integer string. '0' means disabled.
API_PORT: '7922',
// PostgreSQL host. Empty means database disabled.
DB_HOST: '',
// PostgreSQL port. Format: integer string.
......@@ -29,8 +33,10 @@ export const defaultConfig = {
REDIS_URL: '',
// Log level. Format: lowercase string (e.g. info/debug/warn/error).
LOG_LEVEL: 'info',
// WebSocket server bind host. Empty means fallback to HOST.
WS_HOST: '',
// WebSocket port. Format: integer string. '0' means do not open a separate WS port.
WS_PORT: '0',
WS_PORT: '7912',
// Enable SSL for WebSocket server.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
ENABLE_SSL: '0',
......
......@@ -29,6 +29,8 @@ export const TRANSLATIONS = {
'Error occurs, please create a new game and enter /ai to summon an AI.',
create_room_failed: 'Game creation failed, please try again later.',
invalid_password_not_found: 'Password invalid (Not Found)',
banned_ip_login: 'You have been banned.',
banned_user_login: 'You have been banned.',
add_windbot_failed: 'AI addition failed, enter /ai again.',
pre_reconnecting_to_room:
'You will be reconnected to your previous game. Please pick your previous deck.',
......@@ -170,6 +172,10 @@ export const TRANSLATIONS = {
windbot_name_too_long: 'AI房间名过长,请在建立房间后输入 /ai 来添加AI',
create_room_failed: '建立房间失败,请重试',
invalid_password_not_found: '主机密码不正确 (Not Found)',
banned_ip_login:
'您的账号已被封禁。如果您没有进行违规操作且用的是流量网络,可能过几小时就好。是IP撞了。',
banned_user_login:
'您的账号已被封禁。如果您没有进行违规操作且用的是流量网络,可能过几小时就好。是IP撞了。',
add_windbot_failed: '添加AI失败,可尝试输入 /ai 重新添加',
pre_reconnecting_to_room:
'你有未完成的对局,即将重新连接,请选择你在本局决斗中使用的卡组并准备。',
......
......@@ -524,6 +524,18 @@ export class CloudReplayService {
});
}
buildReplayYrpPayload(replay: DuelRecordEntity) {
return this.createReplayPacket(replay).replay.toYrp();
}
async getReplayYrpPayloadById(replayId: number) {
const replay = await this.findReplayById(replayId);
if (!replay) {
return undefined;
}
return this.buildReplayYrpPayload(replay);
}
private restoreDuelRecord(replay: DuelRecordEntity) {
const wasSwapped = this.resolveReplaySwappedByIsFirst(replay);
const seatCount = this.resolveSeatCount(replay.hostInfo);
......
......@@ -7,8 +7,7 @@ import {
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { BaseTimeEntity } from '../../utility';
import { BigintTransformer } from './bigint-transformer';
import { BaseTimeEntity, BigintTransformer } from '../../utility';
import { DuelRecordEntity } from './duel-record.entity';
@Entity('duel_record_player')
......
......@@ -7,8 +7,7 @@ import {
OneToMany,
PrimaryColumn,
} from 'typeorm';
import { BaseTimeEntity } from '../../utility';
import { BigintTransformer } from './bigint-transformer';
import { BaseTimeEntity, BigintTransformer } from '../../utility';
import { DuelRecordPlayer } from './duel-record-player.entity';
@Entity('duel_record')
......
......@@ -144,7 +144,9 @@ function decodeLengthPrefixedResponses(payload: Buffer) {
}
export function decodeSeedBase64(seedBase64: string) {
const decoded = seedBase64 ? Buffer.from(seedBase64, 'base64') : Buffer.alloc(0);
const decoded = seedBase64
? Buffer.from(seedBase64, 'base64')
: Buffer.alloc(0);
const raw = Buffer.alloc(32, 0);
decoded.copy(raw, 0, 0, Math.min(decoded.length, raw.length));
const seed: number[] = [];
......
import { createAppContext } from 'nfkit';
import { ClientVersionCheck } from './client-version-check';
import { ContextState } from '../app';
import { Welcome } from './welcome';
import { PlayerStatusNotify } from './player-status-notify';
import { Reconnect, RefreshFieldService } from './reconnect';
......@@ -19,7 +18,7 @@ import { LpLowHintService } from './lp-low-hint-service';
import { LockDeckService } from './lock-deck';
import { BlockReplay } from './block-replay';
export const FeatsModule = createAppContext<ContextState>()
export const FeatsModule = createAppContext()
.provide(ClientKeyProvider)
.provide(HidePlayerNameProvider)
.provide(KoishiContextService)
......
import { YGOProLFListError, YGOProLFListErrorReason } from 'ygopro-lflist-encode';
import {
YGOProLFListError,
YGOProLFListErrorReason,
} from 'ygopro-lflist-encode';
import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../../app';
import { RoomCheckDeck } from '../../room';
......
import { ChatColor, YGOProMsgDamage, YGOProMsgPayLpCost } from 'ygopro-msg-encode';
import {
ChatColor,
YGOProMsgDamage,
YGOProMsgPayLpCost,
} from 'ygopro-msg-encode';
import { Context } from '../app';
import { Client } from '../client';
import { RoomManager } from '../room';
......
import { Context } from '../../app';
import { ValueContainer } from '../../utility/value-container';
import { FileResourceService } from './file-resource-service';
import { cloneJson } from './resource-util';
type AnyObject = Record<string, unknown>;
......@@ -12,7 +11,7 @@ type RemoteEntry<T extends object> = {
export abstract class BaseResourceProvider<T extends object> {
protected logger = this.ctx.createLogger(this.constructor.name);
protected fileResourceService = this.ctx.get(() => FileResourceService);
protected fileResourceService = this.ctx.fileResource;
protected data: ValueContainer<T>;
public resource: ValueContainer<T>;
......
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { Context } from '../../app';
import { cloneJson, isObjectRecord } from './resource-util';
export class FileResourceService {
private logger = this.ctx.createLogger(this.constructor.name);
private readonly dataDir = path.resolve(process.cwd(), 'data');
private readonly defaultDataPath = path.resolve(
process.cwd(),
'resource',
'default_data.json',
);
private initialized = false;
private initTask?: Promise<void>;
private dataByName = new Map<string, Record<string, unknown>>();
private dataPathByName = new Map<string, string>();
constructor(private ctx: Context) {}
async init() {
await this.ensureInitialized();
}
async ensureInitialized() {
if (this.initialized) {
return;
}
if (!this.initTask) {
this.initTask = this.doInit();
}
await this.initTask;
}
getDataOrEmpty<T extends object>(name: string, emptyData: T): T {
if (!this.initialized) {
return cloneJson(emptyData);
}
const data = this.dataByName.get(name);
if (!data) {
return cloneJson(emptyData);
}
return cloneJson(data as T);
}
async saveData(name: string, data: Record<string, unknown>) {
await this.ensureInitialized();
const dataPath = this.dataPathByName.get(name);
if (!dataPath) {
return false;
}
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), 'utf-8');
this.dataByName.set(name, cloneJson(data));
return true;
}
private async doInit() {
await fs.mkdir(this.dataDir, { recursive: true });
const defaultData = await this.readJsonFile(this.defaultDataPath);
if (!isObjectRecord(defaultData)) {
this.logger.warn(
{ defaultDataPath: this.defaultDataPath },
'Failed to load resource/default_data.json',
);
this.initialized = true;
return;
}
for (const [name, data] of Object.entries(defaultData)) {
if (!isObjectRecord(data)) {
continue;
}
const resolvedData = this.resolveDefaultData(name, data);
const dataPath = this.resolveDataPath(name, data.file);
this.dataPathByName.set(name, dataPath);
const localData = await this.readJsonFile(dataPath);
if (isObjectRecord(localData)) {
this.dataByName.set(name, localData);
continue;
}
await fs.writeFile(
dataPath,
JSON.stringify(resolvedData, null, 2),
'utf-8',
);
this.dataByName.set(name, resolvedData);
}
this.initialized = true;
this.logger.info(
{ count: this.dataByName.size, dataDir: this.dataDir },
'File resources initialized',
);
}
private resolveDefaultData(name: string, data: Record<string, unknown>) {
const nextData = cloneJson(data);
const fileName = this.resolveFileName(name, data.file);
nextData.file = `./data/${fileName}`;
return nextData;
}
private resolveDataPath(name: string, filePath: unknown) {
const fileName = this.resolveFileName(name, filePath);
return path.join(this.dataDir, fileName);
}
private resolveFileName(name: string, filePath: unknown) {
if (typeof filePath === 'string' && filePath.trim()) {
return path.basename(filePath);
}
return `${name}.json`;
}
private async readJsonFile(filePath: string): Promise<unknown> {
try {
const text = await fs.readFile(filePath, 'utf-8');
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
}
export { FileResourceService } from '../../file-resource/file-resource-service';
......@@ -2,12 +2,10 @@ import { createAppContext } from 'nfkit';
import { ContextState } from '../../app';
import { BadwordProvider } from './badword-provider';
import { DialoguesProvider } from './dialogues-provider';
import { FileResourceService } from './file-resource-service';
import { TipsProvider } from './tips-provider';
import { WordsProvider } from './words-provider';
export const ResourceModule = createAppContext<ContextState>()
.provide(FileResourceService)
.provide(TipsProvider)
.provide(WordsProvider)
.provide(DialoguesProvider)
......
export function isObjectRecord(
value: unknown,
): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function cloneJson<T>(value: T): T {
if (value == null) {
return value;
}
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
export { cloneJson, isObjectRecord } from '../../file-resource/resource-util';
......@@ -2,6 +2,7 @@ import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../app';
import { Client } from '../client';
import { OnRoomJoin } from '../room/room-event/on-room-join';
import { ValueContainer } from '../utility/value-container';
declare module '../room' {
interface Room {
......@@ -17,8 +18,6 @@ declare module '../client' {
}
export class Welcome {
private welcomeMessage = this.ctx.config.getString('WELCOME');
constructor(private ctx: Context) {
this.ctx.middleware(OnRoomJoin, async (event, client, next) => {
const room = event.room;
......@@ -34,10 +33,29 @@ export class Welcome {
}
async sendConfigWelcome(client: Client) {
if (!this.welcomeMessage || client.configWelcomeSent) {
const welcomeMessage = await this.getConfigWelcome(client);
if (!welcomeMessage || client.configWelcomeSent) {
return;
}
client.configWelcomeSent = true;
await client.sendChat(this.welcomeMessage, ChatColor.GREEN);
await client.sendChat(welcomeMessage, ChatColor.GREEN);
}
async getConfigWelcome(client: Client) {
const baseWelcome = this.ctx.config.getString('WELCOME');
const event = await this.ctx.dispatch(
new WelcomeConfigCheck(client, baseWelcome),
client,
);
return event?.value || '';
}
}
export class WelcomeConfigCheck extends ValueContainer<string> {
constructor(
public client: Client,
initialValue: string,
) {
super(initialValue);
}
}
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { cloneJson, isObjectRecord } from './resource-util';
import { AppContext } from 'nfkit';
import { Logger } from '../services/logger';
export class FileResourceService {
private logger = this.ctx
.get(() => Logger)
.createLogger(this.constructor.name);
private readonly dataDir = path.resolve(process.cwd(), 'data');
private readonly defaultDataPath = path.resolve(
process.cwd(),
'resource',
'default_data.json',
);
private initialized = false;
private initTask?: Promise<void>;
private dataByName = new Map<string, Record<string, unknown>>();
private dataPathByName = new Map<string, string>();
constructor(private ctx: AppContext) {}
async init() {
await this.ensureInitialized();
}
async ensureInitialized() {
if (this.initialized) {
return;
}
if (!this.initTask) {
this.initTask = this.doInit();
}
await this.initTask;
}
getDataOrEmpty<T extends object>(name: string, emptyData: T): T {
if (!this.initialized) {
return cloneJson(emptyData);
}
const data = this.dataByName.get(name);
if (!data) {
return cloneJson(emptyData);
}
return cloneJson(data as T);
}
async getDataOrEmptyAsync<T extends object>(
name: string,
emptyData: T,
options: {
forceRead?: boolean;
} = {},
): Promise<T> {
await this.ensureInitialized();
if (options.forceRead) {
const dataPath = this.dataPathByName.get(name);
if (dataPath) {
const localData = await this.readJsonFile(dataPath);
if (isObjectRecord(localData)) {
this.dataByName.set(name, localData);
return cloneJson(localData as T);
}
}
}
return this.getDataOrEmpty(name, emptyData);
}
async saveData(name: string, data: Record<string, unknown>) {
await this.ensureInitialized();
const dataPath = this.dataPathByName.get(name);
if (!dataPath) {
return false;
}
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), 'utf-8');
this.dataByName.set(name, cloneJson(data));
return true;
}
private async doInit() {
await fs.mkdir(this.dataDir, { recursive: true });
const defaultData = await this.readJsonFile(this.defaultDataPath);
if (!isObjectRecord(defaultData)) {
this.logger.warn(
{ defaultDataPath: this.defaultDataPath },
'Failed to load resource/default_data.json',
);
this.initialized = true;
return;
}
for (const [name, data] of Object.entries(defaultData)) {
if (!isObjectRecord(data)) {
continue;
}
const resolvedData = this.resolveDefaultData(name, data);
const dataPath = this.resolveDataPath(name, data.file);
this.dataPathByName.set(name, dataPath);
const localData = await this.readJsonFile(dataPath);
if (isObjectRecord(localData)) {
this.dataByName.set(name, localData);
continue;
}
await fs.writeFile(
dataPath,
JSON.stringify(resolvedData, null, 2),
'utf-8',
);
this.dataByName.set(name, resolvedData);
}
this.initialized = true;
this.logger.info(
{ count: this.dataByName.size, dataDir: this.dataDir },
'File resources initialized',
);
}
private resolveDefaultData(name: string, data: Record<string, unknown>) {
const nextData = cloneJson(data);
const fileName = this.resolveFileName(name, data.file);
nextData.file = `./data/${fileName}`;
return nextData;
}
private resolveDataPath(name: string, filePath: unknown) {
const fileName = this.resolveFileName(name, filePath);
return path.join(this.dataDir, fileName);
}
private resolveFileName(name: string, filePath: unknown) {
if (typeof filePath === 'string' && filePath.trim()) {
return path.basename(filePath);
}
return `${name}.json`;
}
private async readJsonFile(filePath: string): Promise<unknown> {
try {
const text = await fs.readFile(filePath, 'utf-8');
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
}
export * from './resource-util';
export * from './file-resource-service';
export function isObjectRecord(
value: unknown,
): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function cloneJson<T>(value: T): T {
if (value == null) {
return value;
}
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
......@@ -29,7 +29,7 @@ export class JoinRoomlist {
await this.menuManager.launchMenu(client, async () => {
const roomNames = this.roomManager
.allRooms()
.filter((room) => room.native)
.filter((room) => room.native && !room.name.includes('$'))
.map((room) => room.name);
const menu: MenuEntry[] = roomNames.map((roomName) => ({
......
export * from './legacy-api-module';
export * from './legacy-api-service';
export * from './legacy-api-replay-service';
export * from './legacy-api-deck-service';
export * from './legacy-api-record.entity';
export * from './legacy-ban.entity';
export * from './legacy-deck.entity';
export * from './legacy-stop-service';
export * from './legacy-ban-service';
export * from './legacy-welcome-service';
export * from './legacy-room-id-service';
This diff is collapsed.
import { createAppContext } from 'nfkit';
import { LegacyApiService } from './legacy-api-service';
import { LegacyApiReplayService } from './legacy-api-replay-service';
import { LegacyApiDeckService } from './legacy-api-deck-service';
import { LegacyStopService } from './legacy-stop-service';
import { LegacyBanService } from './legacy-ban-service';
import { LegacyWelcomeService } from './legacy-welcome-service';
import { LegacyRoomIdService } from './legacy-room-id-service';
export const LegacyApiModule = createAppContext()
.provide(LegacyRoomIdService)
.provide(LegacyStopService)
.provide(LegacyBanService)
.provide(LegacyWelcomeService)
.provide(LegacyApiService)
.provide(LegacyApiReplayService)
.provide(LegacyApiDeckService)
.define();
import { BaseTimeEntity, BigintTransformer } from '../utility';
import { Column, Entity, Generated, Index, PrimaryColumn } from 'typeorm';
@Entity('legacy_api_record')
export class LegacyApiRecordEntity extends BaseTimeEntity {
@PrimaryColumn({
type: 'bigint',
unsigned: true,
transformer: new BigintTransformer(),
})
@Generated('increment')
id!: number;
@Index({ unique: true })
@Column({
type: 'varchar',
length: 64,
})
key!: string;
@Column({
type: 'text',
nullable: true,
})
value!: string | null;
}
import JSZip from 'jszip';
import { Context } from '../app';
import { DuelRecordEntity, DuelRecordPlayer } from '../feats/cloud-replay';
import { LegacyRoomIdService } from './legacy-room-id-service';
import { CloudReplayService } from '../feats';
type DuelLogQuery = {
roomName?: string;
duelCount?: number;
playerName?: string;
playerScore?: number;
};
export class LegacyApiReplayService {
private logger = this.ctx.createLogger('LegacyApiReplayService');
private roomIdService = this.ctx.get(() => LegacyRoomIdService);
private cloudReplayService = this.ctx.get(() => CloudReplayService);
constructor(private ctx: Context) {
this.registerRoutes();
}
private registerRoutes() {
const router = this.ctx.router;
router.get('/api/duellog', async (koaCtx) => {
const ok = await this.ctx.legacyApiAuth.auth(
String(koaCtx.query.username || ''),
String(koaCtx.query.pass || koaCtx.query.password || ''),
'duel_log',
'duel_log',
);
if (!ok) {
koaCtx.body = [{ name: '密码错误' }];
return;
}
const repo = this.getReplayRepo();
if (!repo) {
koaCtx.body = [];
return;
}
const query = this.parseQuery(koaCtx.query as Record<string, unknown>);
const replays = await this.buildReplayQuery(query).getMany();
koaCtx.body = replays.map((replay) => this.toDuelLogViewJson(replay));
});
router.get('/api/archive.zip', async (koaCtx) => {
const ok = await this.ctx.legacyApiAuth.auth(
String(koaCtx.query.username || ''),
String(koaCtx.query.pass || koaCtx.query.password || ''),
'download_replay',
'download_replay_archive',
);
if (!ok) {
koaCtx.status = 403;
koaCtx.body = 'Invalid password.';
return;
}
const repo = this.getReplayRepo();
if (!repo) {
koaCtx.status = 404;
koaCtx.body = 'Replay not found.';
return;
}
const query = this.parseQuery(koaCtx.query as Record<string, unknown>);
const replays = await this.buildReplayQuery(query).getMany();
if (!replays.length) {
koaCtx.status = 404;
koaCtx.body = 'Replay not found.';
return;
}
const zip = new JSZip();
for (const replay of replays) {
const payload = this.cloudReplayService.buildReplayYrpPayload(replay);
zip.file(`${replay.id}.yrp`, payload);
}
koaCtx.state.disableJsonp = true;
koaCtx.set('Content-Type', 'application/octet-stream');
koaCtx.set('Content-Disposition', 'attachment; filename="archive.zip"');
koaCtx.body = zip.generateNodeStream({
compression: 'DEFLATE',
compressionOptions: { level: 9 },
});
});
router.get('/api/clearlog', async (koaCtx) => {
const ok = await this.ctx.legacyApiAuth.auth(
String(koaCtx.query.username || ''),
String(koaCtx.query.pass || koaCtx.query.password || ''),
'clear_duel_log',
'clear_duel_log',
);
if (!ok) {
koaCtx.body = [{ name: '密码错误' }];
return;
}
const repo = this.getReplayRepo();
if (!repo) {
koaCtx.body = [{ name: 'Success' }];
return;
}
const query = this.parseQuery(koaCtx.query as Record<string, unknown>);
const ids = (
await this.buildReplayQuery(query)
.select('replay.id', 'id')
.getRawMany<{ id: string }>()
)
.map((row) => Number(row.id))
.filter((id) => Number.isFinite(id));
if (!ids.length) {
koaCtx.body = [{ name: 'Success' }];
return;
}
await repo.softDelete(ids);
koaCtx.body = [{ name: 'Success' }];
});
router.get('/api/replay/:filename', async (koaCtx) => {
const ok = await this.ctx.legacyApiAuth.auth(
String(koaCtx.query.username || ''),
String(koaCtx.query.pass || koaCtx.query.password || ''),
'download_replay',
'download_replay',
);
if (!ok) {
koaCtx.status = 403;
koaCtx.body = '密码错误';
return;
}
const filename = String(koaCtx.params.filename || '');
const matched = filename.match(/^(\d+)\.yrp$/);
if (!matched) {
koaCtx.status = 404;
koaCtx.body = `未找到文件 ${filename}`;
return;
}
const replayId = Number(matched[1]);
const payload =
await this.cloudReplayService.getReplayYrpPayloadById(replayId);
if (!payload) {
koaCtx.status = 404;
koaCtx.body = `未找到文件 ${filename}`;
return;
}
koaCtx.state.disableJsonp = true;
koaCtx.set('Content-Type', 'application/octet-stream');
koaCtx.set(
'Content-Disposition',
`attachment; filename="${replayId}.yrp"`,
);
koaCtx.body = Buffer.from(payload);
});
}
private parseQuery(query: Record<string, unknown>): DuelLogQuery {
const roomName = String(query.roomname || '').trim();
const playerName = String(query.playername || '').trim();
const duelCount = this.parseOptionalNumber(query.duelcount);
const playerScore = this.parseOptionalNumber(query.score);
return {
roomName: roomName || undefined,
duelCount,
playerName: playerName || undefined,
playerScore,
};
}
private parseOptionalNumber(value: unknown) {
const text = String(value ?? '').trim();
if (!text.length) {
return undefined;
}
const parsed = Number(text);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed;
}
private toDuelLogViewJson(replay: DuelRecordEntity) {
const mode = replay.hostInfo?.mode || 0;
const players = [...replay.players].sort((a, b) => a.pos - b.pos);
return {
id: replay.id,
time: this.formatDate(replay.endTime),
originalName: replay.name,
name: `${replay.name} (Duel:${replay.duelCount})`,
roomid: this.roomIdService.getRoomIdString(replay.roomIdentifier),
cloud_replay_id: `R#${replay.id}`,
replay_filename: `${replay.id}.yrp`,
roommode: mode,
players: players.map((player) => ({
pos: player.pos,
is_first: player.isFirst,
originalName: player.name,
name: player.name + ` (Score: ${player.score})`,
winner: player.winner,
score: player.score,
})),
};
}
private formatDate(date: Date) {
const normalized = new Date(date);
const year = normalized.getFullYear();
const month = `${normalized.getMonth() + 1}`.padStart(2, '0');
const day = `${normalized.getDate()}`.padStart(2, '0');
const hour = `${normalized.getHours()}`.padStart(2, '0');
const minute = `${normalized.getMinutes()}`.padStart(2, '0');
const second = `${normalized.getSeconds()}`.padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
private getReplayRepo() {
const database = this.ctx.database;
if (!database) {
return undefined;
}
return database.getRepository(DuelRecordEntity);
}
private buildReplayQuery(query: DuelLogQuery) {
const repo = this.getReplayRepo();
if (!repo) {
throw new Error('Database disabled');
}
const qb = repo
.createQueryBuilder('replay')
.leftJoinAndSelect('replay.players', 'player');
if (query.roomName) {
qb.andWhere(`replay.name LIKE :roomName || '%'`, {
roomName: query.roomName,
});
}
if (query.duelCount != null && !Number.isNaN(query.duelCount)) {
qb.andWhere('replay.duelCount = :duelCount', {
duelCount: query.duelCount,
});
}
if (query.playerName || query.playerScore != null) {
const subQb = qb
.subQuery()
.select('splayer.id')
.from(DuelRecordPlayer, 'splayer')
.where('splayer.duelRecordId = replay.id');
if (query.playerName) {
subQb.andWhere(`splayer.realName LIKE :playerName || '%'`);
}
if (query.playerScore != null && !Number.isNaN(query.playerScore)) {
subQb.andWhere('splayer.score = :playerScore');
}
const params: Record<string, unknown> = {};
if (query.playerName) {
params.playerName = query.playerName;
}
if (query.playerScore != null && !Number.isNaN(query.playerScore)) {
params.playerScore = query.playerScore;
}
qb.andWhere(`exists ${subQb.getQuery()}`, params);
}
qb.orderBy('replay.id', 'DESC');
return qb;
}
}
import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../app';
import { DialoguesProvider, TipsProvider } from '../feats/resource';
import { DuelStage, RoomManager } from '../room';
import { LegacyRoomIdService } from './legacy-room-id-service';
type ApiMessageHandler = {
permission: string;
callback: (
value: string,
query: Record<string, string>,
) => Promise<unknown[]>;
};
const API_MESSAGE_META_FIELDS = new Set([
'username',
'pass',
'password',
'callback',
]);
export class LegacyApiService {
private logger = this.ctx.createLogger('LegacyApiService');
private roomIdService = this.ctx.get(() => LegacyRoomIdService);
private handlers = new Map<string, ApiMessageHandler>();
constructor(private ctx: Context) {
this.registerDefaultHandlers();
this.registerRoutes();
}
addApiMessageHandler(
name: string,
permission: string,
callback: ApiMessageHandler['callback'],
) {
this.handlers.set(name, {
permission,
callback,
});
return this;
}
private registerDefaultHandlers() {
this.addApiMessageHandler('shout', 'shout', async (value) => {
const text = String(value || '');
const roomManager = this.ctx.get(() => RoomManager);
for (const room of roomManager.allRooms()) {
await room.sendChat(text, ChatColor.YELLOW);
}
return ['shout ok', text];
});
this.addApiMessageHandler('loadtips', 'change_settings', async () => {
const success = await this.ctx.get(() => TipsProvider).refreshResources();
return [
success ? 'tip ok' : 'tip fail',
this.ctx.config.getString('TIPS_GET'),
];
});
this.addApiMessageHandler('loaddialogues', 'change_settings', async () => {
const provider = this.ctx.get(() => DialoguesProvider);
const success = await provider.refreshResources();
return [
success ? 'dialogue ok' : 'dialogue fail',
this.ctx.config.getString('DIALOGUES_GET'),
];
});
this.addApiMessageHandler('kick', 'kick_user', async (value) => {
const found = await this.kickByTarget(value);
if (!found) {
return ['room not found', value];
}
return ['kick ok', value];
});
this.addApiMessageHandler('reboot', 'stop', async (value) => {
await this.kickByTarget('all');
setTimeout(() => process.exit(0), 100);
return ['reboot ok', value];
});
}
private registerRoutes() {
const router = this.ctx.router;
router.get('/api/getrooms', async (koaCtx) => {
const username = String(koaCtx.query.username || '');
const pass = String(koaCtx.query.pass || koaCtx.query.password || '');
const passValidated = await this.ctx.legacyApiAuth.auth(
username,
pass,
'get_rooms',
'get_rooms',
);
if (!passValidated) {
koaCtx.body = {
rooms: [
{
roomid: '0',
roomname: '密码错误',
needpass: 'true',
},
],
};
return;
}
const roomManager = this.ctx.get(() => RoomManager);
const rooms = roomManager.allRooms();
const roomInfos = await Promise.all(
rooms.map(async (room) => {
const info = await room.getInfo();
const users = [...info.players]
.sort((a, b) => a.pos - b.pos)
.map((player) => ({
id: '-1',
name: player.name,
ip: player.ip || null,
status:
info.duelStage !== DuelStage.Begin
? {
score: player.score ?? 0,
lp: player.lp ?? info.hostinfo.start_lp,
cards: player.cardCount ?? info.hostinfo.start_hand,
}
: null,
pos: player.pos,
}));
return {
roomid: this.roomIdService.getRoomIdString(info.identifier),
roomname: info.name,
roommode: info.hostinfo.mode,
needpass: (info.name.includes('$') ? true : false).toString(),
users,
istart: this.buildRoomIstart(info),
};
}),
);
koaCtx.body = {
rooms: roomInfos,
};
});
router.get('/api/message', async (koaCtx) => {
const rawQuery = koaCtx.query as Record<
string,
string | string[] | undefined
>;
const query: Record<string, string> = {};
for (const [key, value] of Object.entries(rawQuery)) {
query[key] = Array.isArray(value)
? String(value[0] || '')
: String(value || '');
}
const username = String(query.username || '');
const pass = String(query.pass || query.password || '');
const matchedName = Object.keys(query).find(
(key) => !API_MESSAGE_META_FIELDS.has(key) && this.handlers.has(key),
);
if (!matchedName) {
koaCtx.status = 400;
koaCtx.body = '400';
return;
}
const handler = this.handlers.get(matchedName)!;
const value = String(query[matchedName] || '');
const passValidated = await this.ctx.legacyApiAuth.auth(
username,
pass,
handler.permission,
matchedName,
);
if (!passValidated) {
koaCtx.body = ['密码错误', 0];
return;
}
koaCtx.body = await handler.callback(value, query);
});
}
private async kickByTarget(target: string) {
const value = (target || '').trim();
if (!value) {
return false;
}
const roomManager = this.ctx.get(() => RoomManager);
const foundRooms =
value === 'all' ? roomManager.allRooms() : this.findRoomByTarget(value);
if (!foundRooms.length) {
return false;
}
await Promise.all(foundRooms.map((room) => room.finalize(true)));
return true;
}
findRoomByTarget(target: string) {
const roomManager = this.ctx.get(() => RoomManager);
const roomByName = roomManager.findByName(target);
if (roomByName) {
return [roomByName];
}
const roomName = this.roomIdService.findRoomNameByRoomId(target);
if (!roomName) {
return [];
}
const roomById = roomManager.findByName(roomName);
return roomById ? [roomById] : [];
}
private buildRoomIstart(info: {
duelStage: DuelStage;
duels: unknown[];
turnCount?: number;
}) {
if (info.duelStage === DuelStage.Begin) {
return 'wait';
}
const duelText = `Duel:${info.duels.length}`;
if (info.duelStage === DuelStage.Siding) {
return `${duelText} Siding`;
}
if (info.duelStage === DuelStage.Finger) {
return `${duelText} Finger`;
}
if (info.duelStage === DuelStage.FirstGo) {
return `${duelText} FirstGo`;
}
if (info.duelStage === DuelStage.Dueling) {
const turn = Number.isFinite(info.turnCount) ? Number(info.turnCount) : 0;
return `${duelText} Turn:${turn}`;
}
return 'start';
}
}
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { LegacyBanEntity } from './legacy-ban.entity';
import { RoomManager } from '../room';
import { LegacyApiService } from './legacy-api-service';
export class LegacyBanService {
private logger = this.ctx.createLogger('LegacyBanService');
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
if (client.isLocal || client.isInternal) {
return next();
}
const nameBan = client.name
? await this.findBanRecord({ name: client.name })
: null;
if (nameBan) {
this.logger.info(
{ name: client.name, ip: client.ip },
'Blocked banned user from joining',
);
return client.die('#{banned_user_login}', ChatColor.RED);
}
const ipBan = client.ip ? await this.findBanRecord({ ip: client.ip }) : null;
if (ipBan) {
this.logger.info(
{ name: client.name, ip: client.ip },
'Blocked banned IP from joining',
);
return client.die('#{banned_ip_login}', ChatColor.RED);
}
return next();
});
this.ctx
.get(() => LegacyApiService)
.addApiMessageHandler('ban', 'ban_user', async (value) => {
const result = await this.banUser(value);
return [result ? 'ban ok' : 'ban fail', value];
});
}
async banUser(name: string) {
const targetName = (name || '').trim();
if (!targetName) {
return false;
}
await this.addBanRecord(targetName, null);
const pendingIps = new Set<string>();
const roomManager = this.ctx.get(() => RoomManager);
const rooms = roomManager.allRooms();
for (const room of rooms) {
const players = room.allPlayers;
for (const player of players) {
if (!player) {
continue;
}
const hitByName = player.name === targetName;
const hitByIp = !!(player.ip && pendingIps.has(player.ip));
if (!hitByName && !hitByIp) {
continue;
}
if (player.ip) {
pendingIps.add(player.ip);
await this.addBanRecord(targetName, player.ip);
}
await room.sendChat(
`${player.name} #{kicked_by_system}`,
ChatColor.RED,
);
await room.kick(player);
}
}
this.logger.info({ name: targetName }, 'Legacy ban applied');
return true;
}
private async findBanRecord(criteria: { name?: string; ip?: string }) {
const database = this.ctx.database;
if (!database) {
return null;
}
const repo = database.getRepository(LegacyBanEntity);
return repo.findOne({
where: criteria,
});
}
private async addBanRecord(name: string | null, ip: string | null) {
const database = this.ctx.database;
if (!database) {
return;
}
const repo = database.getRepository(LegacyBanEntity);
const existing = await repo.findOne({
where: {
name: name || null,
ip: ip || null,
},
withDeleted: true,
});
if (existing) {
if (existing.deleteTime) {
await repo.recover(existing);
}
return;
}
const row = repo.create({
name: name || null,
ip: ip || null,
});
await repo.save(row);
}
}
import { BaseTimeEntity, BigintTransformer } from '../utility';
import {
Column,
Entity,
Generated,
Index,
PrimaryColumn,
Unique,
} from 'typeorm';
@Entity('legacy_ban')
@Unique(['ip', 'name'])
export class LegacyBanEntity extends BaseTimeEntity {
@PrimaryColumn({
type: 'bigint',
unsigned: true,
transformer: new BigintTransformer(),
})
@Generated('increment')
id!: number;
@Index()
@Column({
type: 'varchar',
length: 64,
nullable: true,
})
ip!: string | null;
@Index()
@Column({
type: 'varchar',
length: 20,
nullable: true,
})
name!: string | null;
}
import { BaseTimeEntity, BigintTransformer } from '../utility';
import { Column, Entity, Generated, Index, PrimaryColumn } from 'typeorm';
@Entity('legacy_deck')
export class LegacyDeckEntity extends BaseTimeEntity {
@PrimaryColumn({
type: 'bigint',
unsigned: true,
transformer: new BigintTransformer(),
})
@Generated('increment')
id!: number;
@Index()
@Column({
type: 'varchar',
length: 128,
})
name!: string;
@Column({
type: 'text',
})
payload!: string;
@Column('smallint')
mainc!: number;
@Index()
@Column({
type: 'timestamp',
})
uploadTime!: Date;
}
import { Context } from '../app';
import { OnRoomCreate, OnRoomFinalize } from '../room';
const ROOM_ID_PREFIX_LENGTH = 10;
const ROOM_ID_BASE = 1_000_000;
const ROOM_ID_MOD = 9_000_000;
export class LegacyRoomIdService {
private logger = this.ctx.createLogger('LegacyRoomIdService');
private roomNameToRoomId = new Map<string, string>();
private roomIdToRoomName = new Map<string, string>();
constructor(private ctx: Context) {
this.ctx.middleware(OnRoomCreate, async (event, client, next) => {
this.bindRoom(event.room.name, event.room.identifier);
return next();
});
this.ctx.middleware(OnRoomFinalize, async (event, client, next) => {
this.releaseRoom(event.room.name);
return next();
});
}
getRoomIdString(identifier: string) {
return String(this.getRoomIdNumber(identifier));
}
findRoomNameByRoomId(roomIdText: string) {
const normalizedRoomId = this.normalizeRoomId(roomIdText);
if (!normalizedRoomId) {
return undefined;
}
return this.roomIdToRoomName.get(normalizedRoomId);
}
getRoomIdNumber(identifier: string) {
const prefix = String(identifier || '').slice(0, ROOM_ID_PREFIX_LENGTH);
let value = 0n;
for (const ch of prefix) {
value = value * 62n + BigInt(this.toBase62Digit(ch));
}
return Number(value % BigInt(ROOM_ID_MOD)) + ROOM_ID_BASE;
}
private toBase62Digit(ch: string) {
if (ch >= '0' && ch <= '9') {
return ch.charCodeAt(0) - 48;
}
if (ch >= 'A' && ch <= 'Z') {
return ch.charCodeAt(0) - 55;
}
if (ch >= 'a' && ch <= 'z') {
return ch.charCodeAt(0) - 61;
}
return 0;
}
private bindRoom(roomName: string, identifier: string) {
const roomId = this.getRoomIdString(identifier);
const occupiedRoomName = this.roomIdToRoomName.get(roomId);
if (occupiedRoomName && occupiedRoomName !== roomName) {
this.logger.warn(
{
roomId,
currentRoomName: occupiedRoomName,
nextRoomName: roomName,
},
'Legacy room id collision detected',
);
}
const previousRoomId = this.roomNameToRoomId.get(roomName);
if (previousRoomId && previousRoomId !== roomId) {
const linkedRoomName = this.roomIdToRoomName.get(previousRoomId);
if (linkedRoomName === roomName) {
this.roomIdToRoomName.delete(previousRoomId);
}
}
this.roomNameToRoomId.set(roomName, roomId);
this.roomIdToRoomName.set(roomId, roomName);
}
private releaseRoom(roomName: string) {
const roomId = this.roomNameToRoomId.get(roomName);
if (!roomId) {
return;
}
this.roomNameToRoomId.delete(roomName);
const linkedRoomName = this.roomIdToRoomName.get(roomId);
if (linkedRoomName === roomName) {
this.roomIdToRoomName.delete(roomId);
}
}
private normalizeRoomId(roomIdText: string) {
const text = String(roomIdText || '').trim();
if (!/^\d+$/.test(text)) {
return undefined;
}
const roomId = Number(text);
if (!Number.isSafeInteger(roomId)) {
return undefined;
}
if (roomId < ROOM_ID_BASE || roomId >= ROOM_ID_BASE + ROOM_ID_MOD) {
return undefined;
}
return String(roomId);
}
}
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { LegacyApiRecordEntity } from './legacy-api-record.entity';
import { LegacyApiService } from './legacy-api-service';
const STOP_RECORD_KEY = 'stop';
export class LegacyStopService {
private logger = this.ctx.createLogger('LegacyStopService');
private stopText?: string;
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
if (!this.stopText) {
return next();
}
return client.die(this.stopText, ChatColor.RED);
});
this.ctx
.get(() => LegacyApiService)
.addApiMessageHandler('stop', 'stop', async (value) => {
const stop = await this.setStopText(value);
return ['stop ok', stop || false];
});
}
async init() {
const text = await this.loadStopTextFromDatabase();
this.stopText = text;
if (text) {
this.logger.warn(
{ stop: text },
'Server stop mode restored from database',
);
}
}
getStopText() {
return this.stopText;
}
async setStopText(rawValue: string | boolean | null | undefined) {
const nextText = this.normalizeStopText(rawValue);
this.stopText = nextText || undefined;
const database = this.ctx.database;
if (!database) {
return this.stopText;
}
const repo = database.getRepository(LegacyApiRecordEntity);
await repo.delete({
key: STOP_RECORD_KEY,
});
if (!nextText) {
this.logger.info('Cleared stop mode');
return undefined;
}
const record = repo.create({
key: STOP_RECORD_KEY,
value: nextText,
});
await repo.save(record);
this.logger.info({ stop: nextText }, 'Set stop mode');
return nextText;
}
private async loadStopTextFromDatabase() {
const database = this.ctx.database;
if (!database) {
return undefined;
}
const repo = database.getRepository(LegacyApiRecordEntity);
const record = await repo.findOne({
where: {
key: STOP_RECORD_KEY,
},
});
const value = (record?.value || '').trim();
return value || undefined;
}
private normalizeStopText(rawValue: string | boolean | null | undefined) {
if (rawValue === false || rawValue == null) {
return '';
}
const text = String(rawValue).trim();
if (!text || text.toLowerCase() === 'false') {
return '';
}
return text;
}
}
import { Context } from '../app';
import { WelcomeConfigCheck } from '../feats';
import { LegacyApiRecordEntity } from './legacy-api-record.entity';
import { LegacyApiService } from './legacy-api-service';
const WELCOME_RECORD_KEY = 'welcome';
export class LegacyWelcomeService {
private logger = this.ctx.createLogger('LegacyWelcomeService');
constructor(private ctx: Context) {
this.ctx.middleware(WelcomeConfigCheck, async (event, client, next) => {
const dbWelcome = await this.getWelcomeFromDatabase();
if (dbWelcome) {
event.use(dbWelcome);
}
return next();
});
this.ctx
.get(() => LegacyApiService)
.addApiMessageHandler('getwelcome', 'change_settings', async () => {
const welcome = await this.getWelcomeText();
return ['get ok', welcome];
})
.addApiMessageHandler('welcome', 'change_settings', async (value) => {
const welcome = await this.setWelcomeText(value);
return ['welcome ok', welcome || ''];
});
}
async getWelcomeText() {
const dbWelcome = await this.getWelcomeFromDatabase();
if (dbWelcome) {
return dbWelcome;
}
return this.ctx.config.getString('WELCOME');
}
async setWelcomeText(rawValue: string | boolean | null | undefined) {
const valueText = this.normalizeWelcome(rawValue);
const database = this.ctx.database;
if (!database) {
return this.ctx.config.getString('WELCOME');
}
const repo = database.getRepository(LegacyApiRecordEntity);
await repo.delete({
key: WELCOME_RECORD_KEY,
});
if (!valueText) {
this.logger.info('Cleared legacy welcome override');
return this.ctx.config.getString('WELCOME');
}
const record = repo.create({
key: WELCOME_RECORD_KEY,
value: valueText,
});
await repo.save(record);
this.logger.info({ welcome: valueText }, 'Updated legacy welcome override');
return valueText;
}
private async getWelcomeFromDatabase() {
const database = this.ctx.database;
if (!database) {
return undefined;
}
const repo = database.getRepository(LegacyApiRecordEntity);
const record = await repo.findOne({
where: {
key: WELCOME_RECORD_KEY,
},
});
const text = (record?.value || '').trim();
return text || undefined;
}
private normalizeWelcome(rawValue: string | boolean | null | undefined) {
if (rawValue === false || rawValue == null) {
return '';
}
const valueText = String(rawValue).trim();
if (!valueText || valueText.toLowerCase() === 'false') {
return '';
}
return valueText;
}
}
export function deckNameMatch(deckName: string, playerName: string) {
if (
deckName === playerName ||
deckName === `${playerName}.ydk` ||
deckName === `${playerName}.ydk.ydk`
) {
return true;
}
const parsedDeckName = deckName.match(
/^([^\+ \uff0b]+)[\+ \uff0b](.+?)(\.ydk){0,2}$/,
);
return !!(
parsedDeckName &&
(playerName === parsedDeckName[1] || playerName === parsedDeckName[2])
);
}
const DELIMITER_CLASS = '[+ \uFF0B]';
const NO_DELIMITER_CLASS = '[^+ \uFF0B]';
function escapeRegex(value: string) {
return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
}
export function getDeckNameExactCandidates(playerName: string) {
return [playerName, `${playerName}.ydk`, `${playerName}.ydk.ydk`];
}
export function getDeckNameRegexCandidates(playerName: string) {
const escapedPlayerName = escapeRegex(playerName);
return {
firstPlayerRegex: `^${escapedPlayerName}${DELIMITER_CLASS}.+(\\.ydk){0,2}$`,
secondPlayerRegex: `^${NO_DELIMITER_CLASS}+${DELIMITER_CLASS}${escapedPlayerName}(\\.ydk){0,2}$`,
};
}
......@@ -24,7 +24,7 @@ export class DuelRecord {
messages: YGOProMsgBase[] = [];
toSwappedPlayers() {
if (!this.isSwapped) {
if (!this.isSwapped) {
return [...this.players];
}
const swappedPlayers = [...this.players];
......
......@@ -4,7 +4,7 @@ import BetterLock from 'better-lock';
import { HostInfo } from 'ygopro-msg-encode';
declare module './room' {
export interface Room {
export interface Room {
native?: boolean;
}
}
......
......@@ -1872,12 +1872,17 @@ export class Room {
'name',
'hostinfo',
'duelStage',
'turnCount',
'createTime',
]),
watcherCount: this.watchers.size,
players: this.playingPlayers.map((p) => ({
...pick(p, ['name', 'pos', 'ip']),
deck: p.deck?.toYdkeURL(),
score:
this.getDuelPos(p) >= 0 && this.getDuelPos(p) <= 1
? this.score[this.getDuelPos(p) as 0 | 1]
: undefined,
lp: fieldInfo?.[this.getIngameDuelPos(p)]?.lp,
cardCount: fieldInfo?.[this.getIngameDuelPos(p)]?.cardCount,
})),
......
import Koa from 'koa';
import Router from '@koa/router';
import * as ipaddr from 'ipaddr.js';
import { IncomingMessage, Server as HttpServer, createServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { SSLFinder } from './ssl-finder';
import { AppContext } from 'nfkit';
import { ConfigService } from './config';
import { Logger } from './logger';
type ProxyRange = [ipaddr.IPv4 | ipaddr.IPv6, number];
export class KoaService {
koa = new Koa();
router = new Router();
private config = this.ctx.get(() => ConfigService).config;
private logger = this.ctx.get(() => Logger).createLogger('KoaService');
private server?: HttpServer;
private trustedProxies: ProxyRange[] = [];
constructor(private ctx: AppContext) {
this.initTrustedProxies();
this.koa.use(async (ctx, next) => {
ctx.set('Access-Control-Allow-Origin', '*');
ctx.set('Access-Control-Allow-Private-Network', 'true');
ctx.set(
'Vary',
'Origin, Access-Control-Request-Headers, Access-Control-Request-Method',
);
if ((ctx.method || '').toUpperCase() === 'OPTIONS') {
const requestHeaders =
ctx.request.headers['access-control-request-headers'];
const allowHeaders = Array.isArray(requestHeaders)
? requestHeaders.join(', ')
: requestHeaders || '*';
ctx.status = 204;
ctx.set('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
ctx.set('Access-Control-Allow-Headers', allowHeaders);
ctx.set('Access-Control-Max-Age', '86400');
return;
}
return next();
});
this.koa.use(async (ctx, next) => {
const req = ctx.req as IncomingMessage;
const physicalIp = req.socket.remoteAddress || '';
const xffRaw = ctx.request.headers['x-forwarded-for'];
const xff = Array.isArray(xffRaw) ? xffRaw[0] : xffRaw;
ctx.state.realIp = this.getRealIp(physicalIp, xff);
return next();
});
this.koa.use(async (ctx, next) => {
await next();
if (ctx.state.disableJsonp) {
return;
}
const callback = String(ctx.query.callback || '').trim();
if (!callback || ctx.body == null) {
return;
}
if (
Buffer.isBuffer(ctx.body) ||
ctx.body instanceof Uint8Array ||
typeof (ctx.body as any).pipe === 'function'
) {
return;
}
const payload = JSON.stringify(ctx.body);
ctx.type = 'application/javascript; charset=utf-8';
// Keep srvpro-dash compatibility: some old callbacks read global `data`.
ctx.body = `window.data=${payload};${callback}(window.data);`;
});
this.koa.use(this.router.routes());
this.koa.use(this.router.allowedMethods());
}
async init() {
const port = this.config.getInt('API_PORT');
if (!port) {
this.logger.info(
'API_PORT not configured, Legacy API server not started',
);
return;
}
const host =
this.config.getString('API_HOST') || this.config.getString('HOST');
const sslOptions = this.ctx.get(() => SSLFinder).findSSL();
if (sslOptions) {
this.server = createHttpsServer(sslOptions, this.koa.callback());
this.logger.info('SSL configuration found, starting HTTPS Legacy API');
} else {
this.server = createServer(this.koa.callback());
this.logger.info('No SSL configuration, starting HTTP Legacy API');
}
await new Promise<void>((resolve, reject) => {
this.server!.listen(port, host, () => {
this.logger.info(
{
host,
port,
secure: !!sslOptions,
trustedProxyCount: this.trustedProxies.length,
},
'Legacy API server listening',
);
resolve();
});
this.server!.on('error', reject);
});
}
async stop() {
if (!this.server) {
return;
}
await new Promise<void>((resolve) => {
this.server!.close(() => {
this.logger.info('Legacy API server closed');
resolve();
});
});
}
private initTrustedProxies() {
const proxies = this.config.getStringArray('TRUSTED_PROXIES');
for (const trusted of proxies) {
try {
this.trustedProxies.push(ipaddr.parseCIDR(trusted));
} catch (e: any) {
this.logger.warn(
{ trusted, err: e.message },
'Failed to parse trusted proxy for KoaService',
);
}
}
}
private isTrustedProxy(ip: string): boolean {
try {
const normalized = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
const addr = ipaddr.parse(normalized);
return this.trustedProxies.some(([range, mask]) =>
addr.match(range, mask),
);
} catch {
return false;
}
}
private toIpv6(ip: string): string {
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
return `::ffff:${ip}`;
}
return ip;
}
private getRealIp(physicalIp: string, xffIp?: string): string {
if (!xffIp || xffIp === physicalIp) {
return this.toIpv6(physicalIp);
}
if (this.isTrustedProxy(physicalIp)) {
return this.toIpv6(xffIp.split(',')[0].trim());
}
return this.toIpv6(physicalIp);
}
}
import { AppContext } from 'nfkit';
import { FileResourceService } from '../file-resource';
import { Logger } from './logger';
type PermissionSet = Record<string, boolean>;
type UserPermissions = string | PermissionSet;
type UserEntry = {
password: string;
enabled: boolean;
permissions: UserPermissions;
[key: string]: unknown;
};
type UsersFile = {
file?: string;
permission_examples: Record<string, PermissionSet>;
users: Record<string, UserEntry>;
};
const EMPTY_USERS_FILE: UsersFile = {
permission_examples: {},
users: {},
};
export class LegacyApiAuthService {
private logger = this.ctx
.get(() => Logger)
.createLogger('LegacyApiAuthService');
private fileResource = this.ctx.get(() => FileResourceService);
constructor(private ctx: AppContext) {}
async auth(
name: string,
pass: string,
permissionRequired: string,
action = 'unknown',
) {
const usersData = await this.fileResource.getDataOrEmptyAsync(
'users',
EMPTY_USERS_FILE,
{
forceRead: true,
},
);
const user = usersData.users[name];
if (!user) {
this.logger.info(
{
user: name,
permissionRequired,
action,
result: 'unknown_user',
},
'Legacy API auth',
);
return false;
}
if (user.password !== pass) {
this.logger.info(
{
user: name,
permissionRequired,
action,
result: 'bad_password',
},
'Legacy API auth',
);
return false;
}
if (!user.enabled) {
this.logger.info(
{
user: name,
permissionRequired,
action,
result: 'disabled_user',
},
'Legacy API auth',
);
return false;
}
const permission = this.resolvePermissionSet(usersData, user.permissions);
const allowed = !!permission?.[permissionRequired];
this.logger.info(
{
user: name,
permissionRequired,
action,
result: allowed ? 'ok' : 'permission_denied',
},
'Legacy API auth',
);
return allowed;
}
private resolvePermissionSet(
usersData: UsersFile,
permissions: UserPermissions,
): PermissionSet | undefined {
if (typeof permissions === 'string') {
return usersData.permission_examples[permissions];
}
if (permissions && typeof permissions === 'object') {
return permissions;
}
return undefined;
}
}
import { Context } from '../app';
import { TlsOptions } from 'node:tls';
import fs from 'node:fs';
import path from 'node:path';
......@@ -9,6 +8,9 @@ import {
timingSafeEqual,
KeyObject,
} from 'node:crypto';
import { AppContext } from 'nfkit';
import { ConfigService } from './config';
import { Logger } from './logger';
type LoadedCandidate = {
certPath: string;
......@@ -19,13 +21,14 @@ type LoadedCandidate = {
};
export class SSLFinder {
constructor(private ctx: Context) {}
private enableSSL = this.ctx.config.getBoolean('ENABLE_SSL');
private sslPath = this.ctx.config.getString('SSL_PATH');
private sslKey = this.ctx.config.getString('SSL_KEY');
private sslCert = this.ctx.config.getString('SSL_CERT');
constructor(private ctx: AppContext) {}
private config = this.ctx.get(() => ConfigService).config;
private enableSSL = this.config.getBoolean('ENABLE_SSL');
private sslPath = this.config.getString('SSL_PATH');
private sslKey = this.config.getString('SSL_KEY');
private sslCert = this.config.getString('SSL_CERT');
private logger = this.ctx.createLogger('SSLFinder');
private logger = this.ctx.get(() => Logger).createLogger('SSLFinder');
private noSSL() {
if (this.sslPath || this.sslKey || this.sslCert) {
......@@ -41,11 +44,9 @@ export class SSLFinder {
return undefined;
}
// 1) 优先 SSL_CERT + SSL_KEY
const explicit = this.tryExplicit(this.sslCert, this.sslKey);
if (explicit) return { cert: explicit.certBuf, key: explicit.keyBuf };
// 2) 其次 sslPath:递归找 fullchain.pem + 同目录 privkey.pem,排除过期/不匹配;选有效期最长
const best = this.findBestFromPath(this.sslPath);
if (!best) return this.noSSL();
......@@ -116,7 +117,6 @@ export class SSLFinder {
const now = Date.now();
for (const fullchainPath of this.walkFindByName(baseDir, 'fullchain.pem')) {
// 先读 cert(一次),不合格就别读 key
const certBuf = this.readFileBuffer(fullchainPath);
if (!certBuf) continue;
......@@ -171,7 +171,6 @@ export class SSLFinder {
private parseLeafCertFromBuffer(
certBuf: Buffer,
): { x509: X509Certificate; validToMs: number } | undefined {
// fullchain.pem / cert.pem 里通常第一个 CERT block 是 leaf
const pem = certBuf.toString('utf8');
const firstCertPem = this.extractFirstPemCertificate(pem);
if (!firstCertPem) return undefined;
......@@ -199,16 +198,11 @@ export class SSLFinder {
keyPathForLog: string,
): boolean {
try {
// cert 公钥
const certPub = x509.publicKey;
// private key -> derive public key
const priv = createPrivateKey(keyBuf);
const derivedPub = createPublicKey(priv);
return this.publicKeysEqual(certPub, derivedPub);
} catch (err: any) {
// 这里常见是:私钥被 passphrase 加密 / 格式不对
this.logger.warn(
{ keyPath: keyPathForLog, err: err?.message ?? String(err) },
'Failed to parse private key for match check; treating as mismatch',
......@@ -218,7 +212,6 @@ export class SSLFinder {
}
private publicKeysEqual(a: KeyObject, b: KeyObject): boolean {
// 统一导出成 spki der 来对比
const aDer = a.export({ type: 'spki', format: 'der' }) as Buffer;
const bDer = b.export({ type: 'spki', format: 'der' }) as Buffer;
......
......@@ -4,6 +4,9 @@ import { ConfigService } from './config';
import { Logger } from './logger';
import { RandomDuelScore } from '../feats/random-duel';
import { DuelRecordEntity, DuelRecordPlayer } from '../feats/cloud-replay';
import { LegacyApiRecordEntity } from '../legacy-api/legacy-api-record.entity';
import { LegacyBanEntity } from '../legacy-api/legacy-ban.entity';
import { LegacyDeckEntity } from '../legacy-api/legacy-deck.entity';
export class TypeormLoader {
constructor(private ctx: AppContext) {}
......@@ -32,6 +35,7 @@ export const TypeormFactory = async (ctx: AppContext) => {
const password = config.getString('DB_PASS');
const database = config.getString('DB_NAME');
const synchronize = !config.getBoolean('DB_NO_INIT');
const dataSource = new DataSource({
type: 'postgres',
......@@ -41,7 +45,14 @@ export const TypeormFactory = async (ctx: AppContext) => {
password,
database,
synchronize,
entities: [RandomDuelScore, DuelRecordEntity, DuelRecordPlayer],
entities: [
RandomDuelScore,
DuelRecordEntity,
DuelRecordPlayer,
LegacyApiRecordEntity,
LegacyBanEntity,
LegacyDeckEntity,
],
});
try {
......
......@@ -5,7 +5,11 @@ export class BigintTransformer implements ValueTransformer {
if (dbValue == null) {
return dbValue;
}
return Number.parseInt(String(dbValue), 10);
const numberValue = Number.parseInt(String(dbValue), 10);
if (!Number.isFinite(numberValue)) {
return null;
}
return numberValue;
}
to(entityValue: unknown) {
......
export * from './panel-pagination';
export * from './base-time.entity';
export * from './bigint-transformer';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment