diff --git a/packages/common/index.ts b/packages/common/index.ts index 632b41f8..7a753e64 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -16,3 +16,5 @@ export * from './enums/tosu'; export * from './enums/country'; export * from './utils/config.types'; +export * from './utils/config.schema'; +export * from './overlay.types'; diff --git a/packages/common/overlay.types.ts b/packages/common/overlay.types.ts new file mode 100644 index 00000000..a884f26d --- /dev/null +++ b/packages/common/overlay.types.ts @@ -0,0 +1,47 @@ +export type OverlayStatus = 'installed' | 'upgradable' | 'installable'; + +export interface OverlayAsset { + type: 'image' | 'video' | string; + url: string; +} + +export interface RawRepositoryOverlay { + id: string; + name: string; + version: string; + author: string; + authorlinks?: string[]; + compatiblewith?: string[]; + usecase?: string[]; + resolution?: string[]; + notes?: string; + _settings?: boolean; + assets?: OverlayAsset[]; + downloadLink?: string; +} + +export interface OverlayAuthor { + name: string; + links: string[]; +} + +export interface OverlayResolution { + width: number | null; + height: number | null; +} + +export interface Overlay { + id: string; + name: string; + version: string; + status: OverlayStatus; + author: OverlayAuthor; + url: string; + downloadUrl?: string; + resolution: OverlayResolution; + usecase: string[]; + compatible: string[]; + hasSettings: boolean; + notes: string; + assets: OverlayAsset[]; +} diff --git a/packages/common/utils/config.schema.ts b/packages/common/utils/config.schema.ts new file mode 100644 index 00000000..c0ea939e --- /dev/null +++ b/packages/common/utils/config.schema.ts @@ -0,0 +1,104 @@ +import type { ConfigBinding, ConfigSchema } from './config.types'; + +export const defaultSchema: ConfigSchema = { + enableAutoUpdate: { + binding: 'ENABLE_AUTOUPDATE', + default: true + }, + openDashboardOnStartup: { + binding: 'OPEN_DASHBOARD_ON_STARTUP', + default: true + }, + debugLog: { + binding: 'DEBUG_LOG', + default: false + }, + calculatePP: { + binding: 'CALCULATE_PP', + default: true + }, + enableKeyOverlay: { + binding: 'ENABLE_KEY_OVERLAY', + default: true + }, + pollRate: { + binding: 'POLL_RATE', + default: 150, + min: 100 + }, + preciseDataPollRate: { + binding: 'PRECISE_DATA_POLL_RATE', + default: 10, + min: 1 + }, + showMpCommands: { + binding: 'SHOW_MP_COMMANDS', + default: false + }, + readManiaScrollSpeed: { + binding: 'READ_MANIA_SCROLL_SPEED', + default: true + }, + serverIP: { + binding: 'SERVER_IP', + default: '127.0.0.1' + }, + serverPort: { + binding: 'SERVER_PORT', + default: 24050 + }, + staticFolderPath: { + binding: 'STATIC_FOLDER_PATH', + default: './static' + }, + enableIngameOverlay: { + binding: 'ENABLE_INGAME_OVERLAY', + default: false + }, + ingameOverlayKeybind: { + binding: 'INGAME_OVERLAY_KEYBIND', + default: 'Control + Shift + Space' + }, + ingameOverlayMaxFps: { + binding: 'INGAME_OVERLAY_MAX_FPS', + default: 60 + }, + allowedIPs: { + binding: 'ALLOWED_IPS', + default: '127.0.0.1,localhost,absolute' + } +}; + +export const newlineInsertions: ConfigBinding[] = [ + 'OPEN_DASHBOARD_ON_STARTUP', + 'READ_MANIA_SCROLL_SPEED', + 'ENABLE_INGAME_OVERLAY', + 'PRECISE_DATA_POLL_RATE', + 'INGAME_OVERLAY_MAX_FPS', + 'ALLOWED_IPS' +]; + +export const bindingOrder: ConfigBinding[] = [ + 'DEBUG_LOG', + 'ENABLE_AUTOUPDATE', + 'OPEN_DASHBOARD_ON_STARTUP', + + 'SHOW_MP_COMMANDS', + 'CALCULATE_PP', + 'READ_MANIA_SCROLL_SPEED', + + 'ENABLE_KEY_OVERLAY', + 'ENABLE_INGAME_OVERLAY', + + 'POLL_RATE', + 'PRECISE_DATA_POLL_RATE', + + 'INGAME_OVERLAY_KEYBIND', + 'INGAME_OVERLAY_MAX_FPS', + + 'SERVER_IP', + 'SERVER_PORT', + 'ALLOWED_IPS', + + 'STATIC_FOLDER_PATH' +]; diff --git a/packages/common/utils/config.ts b/packages/common/utils/config.ts index e9839e22..720c8227 100644 --- a/packages/common/utils/config.ts +++ b/packages/common/utils/config.ts @@ -16,108 +16,11 @@ import { getConfigPath } from './directories'; import { wLogger } from './logger'; import { isRealNumber } from './manipulation'; -const defaultSchema: ConfigSchema = { - enableAutoUpdate: { - binding: 'ENABLE_AUTOUPDATE', - default: true - }, - openDashboardOnStartup: { - binding: 'OPEN_DASHBOARD_ON_STARTUP', - default: true - }, - debugLog: { - binding: 'DEBUG_LOG', - default: false - }, - calculatePP: { - binding: 'CALCULATE_PP', - default: true - }, - enableKeyOverlay: { - binding: 'ENABLE_KEY_OVERLAY', - default: true - }, - pollRate: { - binding: 'POLL_RATE', - default: 150, - min: 100 - }, - preciseDataPollRate: { - binding: 'PRECISE_DATA_POLL_RATE', - default: 10, - min: 1 - }, - showMpCommands: { - binding: 'SHOW_MP_COMMANDS', - default: false - }, - readManiaScrollSpeed: { - binding: 'READ_MANIA_SCROLL_SPEED', - default: true - }, - serverIP: { - binding: 'SERVER_IP', - default: '127.0.0.1' - }, - serverPort: { - binding: 'SERVER_PORT', - default: 24050 - }, - staticFolderPath: { - binding: 'STATIC_FOLDER_PATH', - default: './static' - }, - enableIngameOverlay: { - binding: 'ENABLE_INGAME_OVERLAY', - default: false - }, - ingameOverlayKeybind: { - binding: 'INGAME_OVERLAY_KEYBIND', - default: 'Control + Shift + Space' - }, - ingameOverlayMaxFps: { - binding: 'INGAME_OVERLAY_MAX_FPS', - default: 60 - }, - allowedIPs: { - binding: 'ALLOWED_IPS', - default: '127.0.0.1,localhost,absolute' - } -}; - -const newlineInsertions: ConfigBinding[] = [ - 'OPEN_DASHBOARD_ON_STARTUP', - 'READ_MANIA_SCROLL_SPEED', - 'ENABLE_INGAME_OVERLAY', - 'PRECISE_DATA_POLL_RATE', - 'INGAME_OVERLAY_MAX_FPS', - 'ALLOWED_IPS' -]; - -const bindingOrder: ConfigBinding[] = [ - 'DEBUG_LOG', - 'ENABLE_AUTOUPDATE', - 'OPEN_DASHBOARD_ON_STARTUP', - - 'SHOW_MP_COMMANDS', - 'CALCULATE_PP', - 'READ_MANIA_SCROLL_SPEED', - - 'ENABLE_KEY_OVERLAY', - 'ENABLE_INGAME_OVERLAY', - - 'POLL_RATE', - 'PRECISE_DATA_POLL_RATE', - - 'INGAME_OVERLAY_KEYBIND', - 'INGAME_OVERLAY_MAX_FPS', - - 'SERVER_IP', - 'SERVER_PORT', - 'ALLOWED_IPS', - - 'STATIC_FOLDER_PATH' -]; +import { + bindingOrder, + defaultSchema, + newlineInsertions +} from './config.schema'; export const configEvents = new EventEmitter(); diff --git a/packages/common/utils/directories.ts b/packages/common/utils/directories.ts index 6e5f7852..43a939d5 100644 --- a/packages/common/utils/directories.ts +++ b/packages/common/utils/directories.ts @@ -3,6 +3,7 @@ import { homedir } from 'os'; import path from 'path'; import { config } from './config'; +import { wLogger } from './logger'; export function ensureDirectoryExists(dir: string) { if (!fs.existsSync(dir)) { @@ -113,3 +114,84 @@ export function getConfigPath() { } return getProgramPath(); } + +export interface LocalOverlayFolder { + folderName: string; + folderPath: string; + entryPath: string; + metadataPath?: string; + settingsPath?: string; +} + +/** + * Scans the static directory for valid 1-level overlay folders. + * Prefers `index.html` as the entry file; fallbacks to any `*.html` file. + * + * @param staticPath The absolute path to the static overlays directory. + * @param target Optional specific overlay folder name to scan. + * @returns An array of discovered local overlay folder details. + */ +export function scanLocalOverlays( + staticPath: string, + target?: string +): LocalOverlayFolder[] { + if (!fs.existsSync(staticPath) || (target && target.startsWith('.'))) + return []; + + const entries = target + ? [{ name: target, isDirectory: () => true }] + : fs.readdirSync(staticPath, { withFileTypes: true }); + + const overlays: LocalOverlayFolder[] = []; + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + + const folderPath = path.join(staticPath, entry.name); + if (!fs.existsSync(folderPath)) continue; + + try { + const files = fs.readdirSync(folderPath); + + let entryName = files.find((f) => f.toLowerCase() === 'index.html'); + if (!entryName) { + entryName = files.find((f) => + f.toLowerCase().endsWith('.html') + ); + } + + if (!entryName) { + wLogger.debug( + `Skipping overlay folder %${entry.name}%: No HTML entry file found.` + ); + continue; + } + + const hasMetadata = files.some( + (f) => f.toLowerCase() === 'metadata.txt' + ); + const hasSettings = files.some( + (f) => f.toLowerCase() === 'settings.json' + ); + + overlays.push({ + folderName: entry.name, + folderPath, + entryPath: path.join(folderPath, entryName), + metadataPath: hasMetadata + ? path.join(folderPath, 'metadata.txt') + : undefined, + settingsPath: hasSettings + ? path.join(folderPath, 'settings.json') + : undefined + }); + } catch (error) { + wLogger.debug( + `Failed to scan overlay folder %${entry.name}%:`, + (error as Error).message + ); + } + } + + return overlays; +} diff --git a/packages/common/utils/downloader.ts b/packages/common/utils/downloader.ts index 324c9f40..63ecc1f4 100644 --- a/packages/common/utils/downloader.ts +++ b/packages/common/utils/downloader.ts @@ -1,111 +1,108 @@ import crypto from 'crypto'; import fs from 'fs'; -import https from 'https'; +import { Readable, Transform } from 'stream'; +import { pipeline } from 'stream/promises'; +import { context } from './context'; import { wLogger } from './logger'; import { progressManager } from './progress'; /** - * A cyperdark's downloadFile implmentation based on pure node api - * @param url {string} - * @param destination {string} - * @returns {Promise} + * Downloads a file from a URL to a local destination on disk. + * + * @param url The target download URL. + * @param destination The absolute destination file path on disk. + * @returns Promise resolving to the destination path. */ -export const downloadFile = ( +export async function downloadFile( url: string, destination: string -): Promise => - new Promise((resolve, reject) => { - let token: symbol | undefined; - - const options = { - headers: { - Accept: 'application/octet-stream', - 'User-Agent': '@tosuapp/tosu' - }, - agent: new https.Agent({ - secureOptions: crypto.constants.SSL_OP_ALL - }) - }; - - // find url - https - .get(url, options, (response) => { - if (response.headers.location) { - downloadFile(response.headers.location, destination) - .then(resolve) - .catch(reject); - return; - } - - const file = fs.createWriteStream(destination); - token = progressManager.start('Downloading File'); - - file.on('error', async (err) => { - try { - if (fs.existsSync(destination)) - fs.unlinkSync(destination); - } catch { - // Ignore cleanup errors to avoid masking the original download failure - } - if (token) - await progressManager.end(token, 'Download failed'); - reject(err); - }); - - file.on('finish', async () => { - file.close(); - if (token) - await progressManager.end(token, 'Download completed'); - resolve(destination); - }); - - const totalSize = parseInt( - response.headers['content-length']!, - 10 - ); - let downloadedSize = 0; - - response.on('data', (data) => { - downloadedSize += data.length; - const progress = downloadedSize / totalSize; - - const downloadedMB = (downloadedSize / 1024 / 1024).toFixed( - 2 - ); - const totalMB = (totalSize / 1024 / 1024).toFixed(2); - - if (token) { - progressManager.update( - token, - progress, - `| ${downloadedMB} / ${totalMB} MB` - ); - } - }); - - response.pipe(file); - }) - .on('error', async (err) => { - if (token) await progressManager.end(token, 'Download failed'); - reject(err); - }); +): Promise { + const response = await fetch(url, { + redirect: 'follow', + headers: { + 'User-Agent': `tosu/${context.currentVersion} (https://tosu.app; i@kotrik.ru)` + } }); + if (!response.ok) { + throw new Error( + `Download failed with status ${response.status} (${response.statusText})` + ); + } + + if (!response.body) { + throw new Error('Download failed: response body is null'); + } + + const totalSize = parseInt( + response.headers.get('content-length') || '0', + 10 + ); + let downloadedSize = 0; + + const token = progressManager.start('Downloading File'); + + const progressStream = new Transform({ + transform(chunk, _encoding, callback) { + downloadedSize += chunk.length; + const progress = totalSize > 0 ? downloadedSize / totalSize : 0; + const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2); + const totalMB = + totalSize > 0 ? (totalSize / 1024 / 1024).toFixed(2) : '???'; + + progressManager.update( + token, + progress, + `| ${downloadedMB} / ${totalMB} MB` + ); + + callback(null, chunk); + } + }); + + const fileStream = fs.createWriteStream(destination); + const nodeStream = Readable.fromWeb(response.body as any); + + try { + await pipeline(nodeStream, progressStream, fileStream); + await progressManager.end(token, 'Download completed'); + return destination; + } catch (err) { + await progressManager.end(token, 'Download failed'); + if (fs.existsSync(destination)) { + await fs.promises.unlink(destination).catch(() => {}); + } + throw err; + } +} + +/** + * Verifies a downloaded file's checksum against an expected 'algorithm:hash' digest. + * + * @param expectedDigest The digest string in format 'algorithm:checksum' (e.g. 'sha256:abcd...'). + * @param filePath The absolute path of the local file to verify. + * @returns Promise resolving to true if checksum matches, false otherwise. + */ export async function verifyDownload( - githubDigest: `${string}:${string}`, + expectedDigest: `${string}:${string}` | string, filePath: string ): Promise { try { - const [hashAlgorithm, apiChecksum] = githubDigest.split(':'); - const checksum = crypto - .createHash(hashAlgorithm) - .update(await fs.promises.readFile(filePath)) - .digest('hex'); + if (!expectedDigest || !expectedDigest.includes(':')) return false; + + const [hashAlgorithm, apiChecksum] = expectedDigest.split(':'); + if (!hashAlgorithm || !apiChecksum) return false; + + const hash = crypto.createHash(hashAlgorithm); + const fileStream = fs.createReadStream(filePath); + + await pipeline(fileStream, hash); - if (apiChecksum !== checksum) { + const checksum = hash.digest('hex'); + if (apiChecksum.toLowerCase() !== checksum.toLowerCase()) { wLogger.error( - `Download verification: file checksum doesn't match - ${apiChecksum} ${checksum} ` + `Download verification failed: checksum mismatch (expected ${apiChecksum}, got ${checksum})` ); return false; } @@ -113,7 +110,7 @@ export async function verifyDownload( return true; } catch (exc) { wLogger.error(`Download verification failed:`, (exc as Error).message); - wLogger.debug('Auto-update error details:', exc); + wLogger.debug('Checksum error details:', exc); return false; } diff --git a/packages/server/index.ts b/packages/server/index.ts index 08a3717e..ad529251 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -3,88 +3,164 @@ import type { InstanceManager } from 'tosu/instances/manager'; import buildAssetsApi from './router/assets'; import buildBaseApi from './router/index'; +import buildOverlaysApi from './router/overlays'; import buildSCApi from './router/scApi'; import buildSocket from './router/socket'; import buildV1Api from './router/v1'; import buildV2Api from './router/v2'; +import { isRequestAllowed } from './utils'; import { handleSocketCommands } from './utils/commands'; import { HttpServer } from './utils/http'; -import { isRequestAllowed } from './utils/index'; -import { Websocket } from './utils/socket'; +import { WebSocketChannel } from './utils/socket'; +import { Task, type TaskHandle } from './utils/task'; + +type WebSocketChannelName = 'v1' | 'v2' | 'v2Precise' | 'sc' | 'commands'; + +export function registerMiddlewares( + app: HttpServer, + instanceManager: InstanceManager +) { + app.use((req, _, next) => { + req.instanceManager = instanceManager; + next(); + }); + + app.use((_, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept' + ); + res.setHeader( + 'Access-Control-Allow-Methods', + 'POST, GET, PUT, DELETE, OPTIONS' + ); + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + next(); + }); + + app.use((req, res, next) => { + const allowed = isRequestAllowed(req); + if (allowed) { + return next(); + } + + wLogger.warn(`Blocked unauthorized request to %${req.url}%`, { + origin: req.headers.origin, + referer: req.headers.referer + }); + + res.statusCode = 403; + res.end('Not Found'); + }); +} export class Server { instanceManager: InstanceManager; app = new HttpServer(); - WS_V1: Websocket; - WS_SC: Websocket; - WS_V2: Websocket; - WS_V2_PRECISE: Websocket; - WS_COMMANDS: Websocket; + sockets: Record; + private tasks: TaskHandle[] = []; constructor({ instanceManager }: { instanceManager: InstanceManager }) { this.instanceManager = instanceManager; - this.middlewares(); + registerMiddlewares(this.app, this.instanceManager); } start() { - this.WS_V1 = new Websocket({ - instanceManager: this.instanceManager, - pollRateFieldName: 'pollRate', - stateFunctionName: 'getState', - onMessageCallback: handleSocketCommands - }); - this.WS_SC = new Websocket({ - instanceManager: this.instanceManager, - pollRateFieldName: 'pollRate', - stateFunctionName: 'getStateSC', - onMessageCallback: handleSocketCommands - }); - - this.WS_V2 = new Websocket({ - instanceManager: this.instanceManager, - pollRateFieldName: 'pollRate', - stateFunctionName: 'getStateV2', - onMessageCallback: handleSocketCommands - }); - this.WS_V2_PRECISE = new Websocket({ - instanceManager: this.instanceManager, - pollRateFieldName: 'preciseDataPollRate', - stateFunctionName: 'getPreciseData', - onMessageCallback: handleSocketCommands - }); - this.WS_COMMANDS = new Websocket({ - instanceManager: this.instanceManager, - pollRateFieldName: '', - stateFunctionName: '', - onMessageCallback: handleSocketCommands - }); + this.sockets = { + v1: new WebSocketChannel({ onMessage: handleSocketCommands }), + sc: new WebSocketChannel({ onMessage: handleSocketCommands }), + v2: new WebSocketChannel({ onMessage: handleSocketCommands }), + v2Precise: new WebSocketChannel({ + onMessage: handleSocketCommands + }), + commands: new WebSocketChannel({ onMessage: handleSocketCommands }) + }; buildAssetsApi(this); buildV1Api(this.app); buildSCApi(this.app); - buildV2Api(this.app); + buildOverlaysApi(this.app); - buildSocket({ - app: this.app, - - WS_V1: this.WS_V1, - WS_SC: this.WS_SC, - WS_V2: this.WS_V2, - WS_V2_PRECISE: this.WS_V2_PRECISE, - WS_COMMANDS: this.WS_COMMANDS - }); - + buildSocket(this); buildBaseApi(this); this.app.listen(config.serverPort, config.serverIP); + this.startTasks(); + } + + private startTasks() { + this.stopTasks(); + + const v1 = Task.recur( + () => config.pollRate, + () => { + const socket = this.sockets.v1; + + if (socket.connections.size > 0) { + const state = this.instanceManager.getState(); + socket.broadcast(state); + } + } + ); + + const sc = Task.recur( + () => config.pollRate, + () => { + const socket = this.sockets.sc; + + if (socket.connections.size > 0) { + const state = this.instanceManager.getStateSC(); + socket.broadcast(state); + } + } + ); + + const v2 = Task.recur( + () => config.pollRate, + () => { + const socket = this.sockets.v2; + + if (socket.connections.size > 0) { + const state = this.instanceManager.getStateV2(); + socket.broadcast(state); + } + } + ); + + const v2p = Task.recur( + () => config.preciseDataPollRate, + () => { + const socket = this.sockets.v2Precise; + + if (socket.connections.size > 0) { + const state = this.instanceManager.getPreciseData(); + socket.broadcast(state); + } + } + ); + + this.tasks = [v1, sc, v2, v2p]; } - restart() { - this.app.server.close(); + private stopTasks() { + for (const task of this.tasks) { + task.stop(); + } + this.tasks = []; + } + + async restart() { + this.stopTasks(); + await new Promise((resolve, reject) => { + this.app.server.close((err) => (err ? reject(err) : resolve())); + }); + this.app.listen(config.serverPort, config.serverIP); + this.startTasks(); } handleConfigUpdate(oldConfig: GlobalConfig) { @@ -93,56 +169,24 @@ export class Server { const portChanged = oldConfig.serverPort !== config.serverPort; if (ipChanged || portChanged) { - this.restart(); + this.restart().catch((exc) => { + wLogger.error( + 'Failed to restart server after config update:', + (exc as Error).message + ); + }); } } catch (exc) { wLogger.error( 'Failed to handle server config update:', - (exc as any).message + (exc as Error).message ); wLogger.debug('Server config update error details:', exc); } } - - middlewares() { - const instanceManager = this.instanceManager; - - this.app.use((_, res, next) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept' - ); - res.setHeader( - 'Access-Control-Allow-Methods', - 'POST, GET, PUT, DELETE, OPTIONS' - ); - res.setHeader('Access-Control-Allow-Private-Network', 'true'); - next(); - }); - - this.app.use((req, res, next) => { - const allowed = isRequestAllowed(req); - if (allowed) { - return next(); - } - - wLogger.warn(`Blocked unauthorized request to %${req.url}%`, { - origin: req.headers.origin, - referer: req.headers.referer - }); - - res.statusCode = 403; - res.end('Not Found'); - }); - - this.app.use((req, _, next) => { - req.instanceManager = instanceManager; - next(); - }); - } } export * from './utils/http'; export * from './utils/socket'; +export * from './utils/task'; export * from './utils/index'; diff --git a/packages/server/router/counters.ts b/packages/server/router/counters.ts new file mode 100644 index 00000000..decbc04f --- /dev/null +++ b/packages/server/router/counters.ts @@ -0,0 +1,349 @@ +/** + * @deprecated Legacy /api/counters endpoints. + * These endpoints are preserved for backward compatibility and will be removed in a future major release. + * Please migrate to /api/overlays endpoints instead. + */ +import { + JsonSafeParse, + downloadFile, + getCachePath, + getProgramPath, + getStaticPath, + platformResolver, + unzip, + wLogger +} from '@tosu/common'; +import { exec } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { Server, sendJson } from '../index'; +import { + buildExternalCounters, + buildLocalCounters, + saveSettings +} from '../utils/counters'; +import { type ISettings } from '../utils/counters.types'; +import type { ExtendedIncomingMessage } from '../utils/http'; +import { parseCounterSettings } from '../utils/parseSettings'; + +function logDeprecation(req: ExtendedIncomingMessage, endpoint: string) { + const caller = + req.headers.referer || + req.headers.origin || + req.headers['user-agent'] || + req.socket.remoteAddress || + 'unknown'; + + wLogger.warn( + `Deprecated endpoint %${endpoint}% called by %${caller}%. Please migrate to /api/overlays.` + ); +} + +export default function buildLegacyCountersApi(server: Server) { + server.app.route( + /^\/api\/counters\/search\/(?.*)/, + 'GET', + (req, res) => { + logDeprecation(req, 'GET /api/counters/search'); + + const query = decodeURI(req.params.query) + .replace(/[^a-z0-9A-Z]/, '') + .toLowerCase(); + + const parseAddress = new URL( + req.headers.host + ? `http://${req.headers.host}/` + : req.headers.referer || + `http://${req.socket.remoteAddress}/` + ); + + const parseReferer = new URL( + req.headers.referer || `http://${req.socket.remoteAddress}/` + ); + + if (parseReferer.pathname === `/available`) { + return buildExternalCounters(res, parseAddress.hostname, query); + } + + return buildLocalCounters(res, parseAddress.hostname, query); + } + ); + + server.app.route( + /^\/api\/counters\/download\/(?.*)/, + 'GET', + (req, res) => { + logDeprecation(req, 'GET /api/counters/download'); + + const folderName = req.query.name; + if (!folderName) { + return sendJson(res, { + error: 'no folder name' + }); + } + + const cacheFolder = getCachePath(); + const staticPath = getStaticPath(); + const folderPath = path.join(staticPath, decodeURI(folderName)); + const tempPath = path.join(cacheFolder, `${Date.now()}.zip`); + + if (fs.existsSync(folderPath) && req.query.update !== 'true') { + return sendJson(res, { + error: 'Folder already exist' + }); + } + + if (!fs.existsSync(cacheFolder)) fs.mkdirSync(cacheFolder); + + const startUnzip = (result: string) => { + unzip(result, folderPath) + .then(() => { + wLogger.info( + `PP Counter %${folderName}% downloaded successfully (%${req.headers.referer}%)` + ); + fs.unlinkSync(tempPath); + + server.sockets.commands?.dispatchCommand( + '', + 'unzip', + 'getOverlays', + `__ingame__` + ); + + sendJson(res, { + status: 'Finished', + path: result + }); + }) + .catch((reason) => { + fs.unlinkSync(tempPath); + + wLogger.error( + `Failed to unzip counter %${folderName}%:`, + (reason as Error).message + ); + wLogger.debug('Counter unzip error details:', reason); + + sendJson(res, { + error: (reason as Error).message + }); + }); + }; + + downloadFile(req.params.url, tempPath) + .then(startUnzip) + .catch((reason) => { + wLogger.error( + `Failed to download counter %${folderName}%:`, + (reason as Error).message + ); + wLogger.debug(`Counter download error details:`, reason); + + sendJson(res, { + error: (reason as Error).message + }); + }); + } + ); + + server.app.route( + /^\/api\/counters\/open\/(?.*)/, + 'GET', + (req, res) => { + logDeprecation(req, 'GET /api/counters/open'); + + const folderName = req.params.name; + if (!folderName) { + return sendJson(res, { + error: 'no folder name' + }); + } + + const staticPath = getStaticPath(); + let folderPath = path.join(staticPath, decodeURI(folderName)); + if (folderName === 'tosu.exe') folderPath = getProgramPath(); + else if (folderName === 'static.exe') folderPath = getStaticPath(); + + if (!fs.existsSync(folderPath)) { + return sendJson(res, { + error: "Folder doesn't exists" + }); + } + + wLogger.info( + `Opening PP Counter folder: %${folderName}% (%${req.headers.referer}%)` + ); + + const platform = platformResolver(process.platform); + exec(`${platform.command} "${folderPath}"`, (err) => { + if (err) { + wLogger.error( + `Failed to open folder %${folderName}%:`, + err.message + ); + wLogger.debug('Folder open error details:', err); + + return sendJson(res, { + error: `Error opening folder: ${err.message}` + }); + } + + return sendJson(res, { + status: 'opened' + }); + }); + } + ); + + server.app.route( + /^\/api\/counters\/delete\/(?.*)/, + 'GET', + (req, res) => { + logDeprecation(req, 'GET /api/counters/delete'); + + const folderName = req.params.name; + if (!folderName) { + return sendJson(res, { + error: 'no folder name' + }); + } + + const staticPath = getStaticPath(); + const folderPath = path.join(staticPath, decodeURI(folderName)); + + if (!fs.existsSync(folderPath)) { + return sendJson(res, { + error: "Folder doesn't exists" + }); + } + + wLogger.info( + `PP Counter removed: %${folderName}% (%${req.headers.referer}%)` + ); + + fs.rmSync(folderPath, { recursive: true, force: true }); + + server.sockets.commands?.dispatchCommand( + '', + 'remove', + 'getOverlays', + `__ingame__` + ); + + return sendJson(res, { + status: 'deleted' + }); + } + ); + + server.app.route( + /^\/api\/counters\/settings\/(?.*)/, + 'GET', + (req, res) => { + logDeprecation(req, 'GET /api/counters/settings'); + + const folderName = req.params.name; + if (!folderName) { + return sendJson(res, { + error: 'No folder name' + }); + } + + const settings = parseCounterSettings(folderName, 'parse'); + if (settings instanceof Error) { + wLogger.debug( + `Failed to parse settings for %${folderName}%:`, + settings + ); + + return sendJson(res, { + error: settings.message + }); + } + + wLogger.info( + `Settings accessed for %${folderName}% (%${req.headers.referer}%)` + ); + + return sendJson(res, settings); + } + ); + + server.app.route( + /^\/api\/counters\/settings\/(?.*)/, + 'POST', + (req, res) => { + logDeprecation(req, 'POST /api/counters/settings'); + + const body: ISettings[] | Error = JsonSafeParse({ + isFile: false, + payload: req.body || '', + defaultValue: new Error('Failed to parse body') + }); + if (body instanceof Error) throw body; + + const folderName = req.params.name; + if (!folderName) { + return sendJson(res, { + error: 'no folder name' + }); + } + + if (req.query.update === 'yes') { + const result = parseCounterSettings( + folderName, + 'dev/save', + body as any + ); + if (result instanceof Error) { + wLogger.debug( + `Failed to update settings for %${folderName}%:`, + result + ); + + return sendJson(res, { + error: result.message + }); + } + + wLogger.info( + `Settings re-created for %${folderName}% (%${req.headers.referer}%)` + ); + + fs.writeFileSync( + result.settingsPath!, + JSON.stringify(result.settings), + 'utf8' + ); + + return sendJson(res, { result: 'success' }); + } + + wLogger.info( + `Settings saved for %${folderName}% (%${req.headers.referer}%)` + ); + + const html = saveSettings(folderName, body as any); + if (html instanceof Error) { + wLogger.debug( + `Failed to save settings for %${folderName}%:`, + html + ); + + return sendJson(res, { + error: html.message + }); + } + + server.sockets.commands?.dispatchCommand( + '', + 'save settings', + 'getSettings', + folderName + ); + + return sendJson(res, { result: 'success' }); + } + ); +} diff --git a/packages/server/router/index.ts b/packages/server/router/index.ts index c57b0d4b..aa5bacb2 100644 --- a/packages/server/router/index.ts +++ b/packages/server/router/index.ts @@ -2,44 +2,23 @@ import { type ConfigBinding, ConfigManager, JsonSafeParse, - downloadFile, - getCachePath, - getProgramPath, getStaticPath, - platformResolver, - unzip, wLogger } from '@tosu/common'; import { autoUpdater } from '@tosu/updater'; -import { exec } from 'child_process'; import fs from 'fs'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; import path from 'path'; import rosu from 'rosu-pp-js'; import { Server, sendJson } from '../index'; -import { - buildEmptyPage, - buildExternalCounters, - buildInstructionLocal, - buildLocalCounters, - buildSettings, - getLocalCounters, - saveSettings -} from '../utils/counters'; -import { type ISettings } from '../utils/counters.types'; -import { directoryWalker } from '../utils/directories'; -import { parseCounterSettings } from '../utils/parseSettings'; -import { - type Report, - generateReport, - generateReportHTML -} from '../utils/report'; +import { serveStaticFile } from '../utils/directories'; +import buildLegacyCountersApi from './counters'; const pkgAssetsPath = path.join(import.meta.dirname, 'assets'); export default function buildBaseApi(server: Server) { + buildLegacyCountersApi(server); + server.app.route('/json', 'GET', (req, res) => { const osuInstance = req.instanceManager.getInstance( req.instanceManager.focusedClient @@ -52,300 +31,6 @@ export default function buildBaseApi(server: Server) { return sendJson(res, json); }); - server.app.route( - /^\/api\/counters\/search\/(?.*)/, - 'GET', - (req, res) => { - const query = decodeURI(req.params.query) - .replace(/[^a-z0-9A-Z]/, '') - .toLowerCase(); - - const parseAddress = new URL( - req.headers.host - ? `http://${req.headers.host}/` - : req.headers.referer || - `http://${req.socket.remoteAddress}/` - ); - - const parseReferer = new URL( - req.headers.referer || `http://${req.socket.remoteAddress}/` - ); - if (parseReferer.pathname === `/available`) { - return buildExternalCounters(res, parseAddress.hostname, query); - } - - return buildLocalCounters(res, parseAddress.hostname, query); - } - ); - - server.app.route( - /^\/api\/counters\/download\/(?.*)/, - 'GET', - (req, res) => { - const folderName = req.query.name; - if (!folderName) { - return sendJson(res, { - error: 'no folder name' - }); - } - - const cacheFolder = getCachePath(); - const staticPath = getStaticPath(); - const folderPath = path.join(staticPath, decodeURI(folderName)); - - const tempPath = path.join(cacheFolder, `${Date.now()}.zip`); - - if (fs.existsSync(folderPath) && req.query.update !== 'true') { - return sendJson(res, { - error: 'Folder already exist' - }); - } - - if (!fs.existsSync(cacheFolder)) fs.mkdirSync(cacheFolder); - - const startUnzip = (result: string) => { - unzip(result, folderPath) - .then(() => { - wLogger.info( - `PP Counter %${folderName}% downloaded successfully (%${req.headers.referer}%)` - ); - fs.unlinkSync(tempPath); - - server.WS_COMMANDS.socket.emit( - 'message', - 'unzip', - 'getOverlays', - `__ingame__` - ); - - sendJson(res, { - status: 'Finished', - path: result - }); - }) - .catch((reason) => { - fs.unlinkSync(tempPath); - - wLogger.error( - `Failed to unzip counter %${folderName}%:`, - (reason as Error).message - ); - wLogger.debug('Counter unzip error details:', reason); - - sendJson(res, { - error: (reason as Error).message - }); - }); - }; - - downloadFile(req.params.url, tempPath) - .then(startUnzip) - .catch((reason) => { - wLogger.error( - `Failed to download counter %${folderName}%:`, - (reason as Error).message - ); - wLogger.debug(`Counter download error details:`, reason); - - sendJson(res, { - error: (reason as Error).message - }); - }); - } - ); - - server.app.route( - /^\/api\/counters\/open\/(?.*)/, - 'GET', - (req, res) => { - const folderName = req.params.name; - if (!folderName) { - return sendJson(res, { - error: 'no folder name' - }); - } - - const staticPath = getStaticPath(); - let folderPath = path.join(staticPath, decodeURI(folderName)); - if (folderName === 'tosu.exe') folderPath = getProgramPath(); - else if (folderName === 'static.exe') folderPath = getStaticPath(); - - if (!fs.existsSync(folderPath)) { - return sendJson(res, { - error: "Folder doesn't exists" - }); - } - - wLogger.info( - `Opening PP Counter folder: %${folderName}% (%${req.headers.referer}%)` - ); - - const platform = platformResolver(process.platform); - exec(`${platform.command} "${folderPath}"`, (err) => { - if (err) { - wLogger.error( - `Failed to open folder %${folderName}%:`, - err.message - ); - wLogger.debug('Folder open error details:', err); - - return sendJson(res, { - error: `Error opening folder: ${err.message}` - }); - } - - return sendJson(res, { - status: 'opened' - }); - }); - } - ); - - server.app.route( - /^\/api\/counters\/delete\/(?.*)/, - 'GET', - (req, res) => { - const folderName = req.params.name; - if (!folderName) { - return sendJson(res, { - error: 'no folder name' - }); - } - - const staticPath = getStaticPath(); - const folderPath = path.join(staticPath, decodeURI(folderName)); - - if (!fs.existsSync(folderPath)) { - return sendJson(res, { - error: "Folder doesn't exists" - }); - } - - wLogger.info( - `PP Counter removed: %${folderName}% (%${req.headers.referer}%)` - ); - - fs.rmSync(folderPath, { recursive: true, force: true }); - - server.WS_COMMANDS.socket.emit( - 'message', - 'remove', - 'getOverlays', - `__ingame__` - ); - - return sendJson(res, { - status: 'deleted' - }); - } - ); - - server.app.route( - /^\/api\/counters\/settings\/(?.*)/, - 'GET', - (req, res) => { - const folderName = req.params.name; - if (!folderName) { - return sendJson(res, { - error: 'No folder name' - }); - } - - const settings = parseCounterSettings(folderName, 'parse'); - if (settings instanceof Error) { - wLogger.debug( - `Failed to parse settings for %${folderName}%:`, - settings - ); - - return sendJson(res, { - error: settings.message - }); - } - - wLogger.info( - `Settings accessed for %${folderName}% (%${req.headers.referer}%)` - ); - - return sendJson(res, settings); - } - ); - - server.app.route( - /^\/api\/counters\/settings\/(?.*)/, - 'POST', - (req, res) => { - const body: ISettings[] | Error = JsonSafeParse({ - isFile: false, - payload: req.body, - defaultValue: new Error('Failed to parse body') - }); - if (body instanceof Error) throw body; - - const folderName = req.params.name; - if (!folderName) { - return sendJson(res, { - error: 'no folder name' - }); - } - - if (req.query.update === 'yes') { - const result = parseCounterSettings( - folderName, - 'dev/save', - body as any - ); - if (result instanceof Error) { - wLogger.debug( - `Failed to update settings for %${folderName}%:`, - result - ); - - return sendJson(res, { - error: result.message - }); - } - - wLogger.info( - `Settings re-created for %${folderName}% (%${req.headers.referer}%)` - ); - - fs.writeFileSync( - result.settingsPath!, - JSON.stringify(result.settings), - 'utf8' - ); - - return sendJson(res, { result: 'success' }); - } - - wLogger.info( - `Settings saved for %${folderName}% (%${req.headers.referer}%)` - ); - - const html = saveSettings(folderName, body as any); - if (html instanceof Error) { - wLogger.debug( - `Failed to save settings for %${folderName}%:`, - html - ); - - return sendJson(res, { - error: html.message - }); - } - - server.WS_COMMANDS.socket.emit( - 'message', - 'save settings', - 'getSettings', - folderName - ); - - return sendJson(res, { result: 'success' }); - } - ); - server.app.route('/api/runUpdates', 'GET', (req, res) => autoUpdater('server', res) ); @@ -353,7 +38,7 @@ export default function buildBaseApi(server: Server) { server.app.route('/api/settingsSave', 'POST', async (req, res) => { const body: Record | Error = JsonSafeParse({ isFile: false, - payload: req.body, + payload: req.body || '', defaultValue: new Error('Failed to parse body') }); if (body instanceof Error) throw body; @@ -439,53 +124,63 @@ export default function buildBaseApi(server: Server) { }); server.app.route('/api/generateReport', 'GET', async (req, res) => { - let report: Report; - try { - report = await generateReport(req.instanceManager); - res.writeHead(200, { - 'Content-Type': 'text/html; charset=utf-8', - 'Content-Disposition': `attachment; filename="${encodeURIComponent(`tosu-report-${report.date.getTime()}.html`)}"` - }); - } catch (err) { - res.writeHead(500, { - 'Content-Type': 'text/plain; charset=utf-8' - }); - res.end( - `Server Error: ${(err as Error).message || 'Unknown error'}` - ); - return; - } - - try { - await pipeline(Readable.from(generateReportHTML(report)), res); - } catch (err) { - // Headers are already sent; log and abort the response. - wLogger.warn('Failed to stream report:', (err as Error).message); - wLogger.debug('Report streaming error details:', err); - res.destroy(); - } + // TODO: Temporary disabled for dev purposes. + // Should be re-enabled before merging. + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + return res.end('This page is no longer available.'); + + // let report: Report; + // try { + // report = await generateReport(req.instanceManager); + // res.writeHead(200, { + // 'Content-Type': 'text/html; charset=utf-8', + // 'Content-Disposition': `attachment; filename="${encodeURIComponent(`tosu-report-${report.date.getTime()}.html`)}"` + // }); + // } catch (err) { + // res.writeHead(500, { + // 'Content-Type': 'text/plain; charset=utf-8' + // }); + // res.end( + // `Server Error: ${(err as Error).message || 'Unknown error'}` + // ); + // return; + // } + // + // try { + // await pipeline(Readable.from(generateReportHTML(report)), res); + // } catch (err) { + // // Headers are already sent; log and abort the response. + // wLogger.warn('Failed to stream report:', (err as Error).message); + // wLogger.debug('Report streaming error details:', err); + // res.destroy(); + // } }); server.app.route(/\/api\/ingame/, 'GET', (req, res) => { - fs.readFile( - path.join(pkgAssetsPath, 'ingame.html'), - 'utf8', - (err, content) => { - if (err) { - wLogger.debug(`Failed to read ingame.html:`, err); - res.writeHead(500); - return res.end(`Server Error: ${err.code}`); - } - - const counters = getLocalCounters(); - content += `\n\n\n\n`; - - res.writeHead(200, { - 'Content-Type': 'text/html; charset=utf-8' - }); - res.end(content, 'utf-8'); - } - ); + // TODO: Temporary disabled for dev purposes. + // Should be re-enabled before merging. + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + return res.end('This page is no longer available.'); + + // fs.readFile( + // path.join(pkgAssetsPath, 'ingame.html'), + // 'utf8', + // (err, content) => { + // if (err) { + // wLogger.debug(`Failed to read ingame.html:`, err); + // res.writeHead(500); + // return res.end(`Server Error: ${err.code}`); + // } + // + // const counters = getLocalCounters(); + // content += `\n\n\n\n`; + // + // res.writeHead(200, { + // 'Content-Type': 'text/html; charset=utf-8' + // }); + // res.end(content, 'utf-8'); + // } + // ); }); server.app.route('/favicon.ico', 'GET', (req, res) => { @@ -509,58 +204,34 @@ export default function buildBaseApi(server: Server) { server.app.route(/.*/, 'GET', async (req, res) => { const url = req.pathname || '/'; try { - if (url.startsWith(`/.well-know`)) { + /* + * Browsers and extensions automatically probe RFC 8615 /.well-known URIs + * to discover site manifests, passkeys, or local network policies. + * - + * tosu does not host any well-known services. + */ + if (url.startsWith(`/.well-known`)) { res.statusCode = 404; res.statusMessage = 'Not Found'; - return res.end(); - } - - if (url === '/') { - const parseAddress = new URL( - req.headers.host - ? `http://${req.headers.host}/` - : req.headers.referer || - `http://${req.socket.remoteAddress}/` - ); - - return buildLocalCounters(res, parseAddress.hostname); - } - if (url === '/settings') { - if (req.query.overlay) return buildEmptyPage(res); - return buildSettings(res); - } - if (url === '/local-overlays') return buildInstructionLocal(res); - if (url === '/available') { - const parseAddress = new URL( - req.headers.host - ? `http://${req.headers.host}/` - : req.headers.referer || - `http://${req.socket.remoteAddress}/` - ); - return buildExternalCounters(res, parseAddress.hostname); + return res.end(); } - const staticPath = getStaticPath(); - - const extension = path.extname(url); + const normalizedUrl = + url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url; - // ignore empty and one letter extension (extension returned with .) - if (extension.length < 3 && !url.endsWith('/')) { - res.writeHead(301, { Location: url + '/' }); - return res.end(); + if (['/', '/settings', '/available'].includes(normalizedUrl)) { + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8' + }); + return res.end('This page is no longer available.'); } - const selectIndexHTML = url.endsWith('/') - ? url + 'index.html' - : url; - directoryWalker({ - _htmlRedirect: true, - req, - res, - baseUrl: url, - pathname: selectIndexHTML, - folderPath: staticPath + return serveStaticFile({ + ctx: { req, res }, + root: getStaticPath(), + pathname: url, + isOverlay: true }); } catch (error) { wLogger.warn( diff --git a/packages/server/router/overlays.ts b/packages/server/router/overlays.ts new file mode 100644 index 00000000..38173198 --- /dev/null +++ b/packages/server/router/overlays.ts @@ -0,0 +1,138 @@ +import { wLogger } from '@tosu/common'; +import type { ServerResponse } from 'http'; + +import type { OverlayStatusFilter } from '../services/overlays'; +import { OverlaysService, overlaysService } from '../services/overlays'; +import { sendJson } from '../utils'; +import type { ExtendedIncomingMessage, HttpServer } from '../utils/http'; + +function safeDecodeURIComponent(str: string): string { + try { + return decodeURIComponent(str || ''); + } catch { + return str || ''; + } +} + +export default function buildOverlaysApi( + app: HttpServer, + service: OverlaysService = overlaysService +) { + service.initialize().catch((err) => { + wLogger.error( + 'Failed to initialize overlays service:', + (err as Error).message + ); + }); + + app.get('/api/overlays/status', (_req, res) => { + return sendJson(res, service.getStatus()); + }); + + app.get( + '/api/overlays', + (req: ExtendedIncomingMessage, res: ServerResponse) => { + const rawFilter = (req.query.status || req.query.state) as + | OverlayStatusFilter + | undefined; + return sendJson(res, service.getOverlays(rawFilter)); + } + ); + + app.post( + '/api/overlays/download', + async (req: ExtendedIncomingMessage, res: ServerResponse) => { + let body: { id?: string; downloadUrl?: string } = {}; + try { + body = JSON.parse(req.body || '{}'); + } catch { + return sendJson(res, { error: 'Invalid JSON body' }, 400); + } + + const { id, downloadUrl } = body; + if (!id) { + return sendJson( + res, + { error: 'Missing required field: id' }, + 400 + ); + } + + try { + const result = await service.downloadOverlay(id, downloadUrl); + return sendJson(res, { + status: 'success', + path: result.path + }); + } catch (err) { + wLogger.error('Overlay download endpoint error:', err); + return sendJson(res, { error: (err as Error).message }, 500); + } + } + ); + + app.post( + '/api/overlays/:id/download', + async (req: ExtendedIncomingMessage, res: ServerResponse) => { + const id = safeDecodeURIComponent(req.params.id); + let body: { downloadUrl?: string } = {}; + try { + if (req.body) { + body = JSON.parse(req.body); + } + } catch {} + + try { + const result = await service.downloadOverlay( + id, + body.downloadUrl + ); + return sendJson(res, { + status: 'success', + path: result.path + }); + } catch (err) { + wLogger.error('Overlay download endpoint error:', err); + return sendJson(res, { error: (err as Error).message }, 500); + } + } + ); + + app.post( + '/api/overlays/:id/open', + async (req: ExtendedIncomingMessage, res: ServerResponse) => { + const id = safeDecodeURIComponent(req.params.id); + try { + await service.openOverlay(id); + return sendJson(res, { status: 'opened' }); + } catch (err) { + return sendJson(res, { error: (err as Error).message }, 500); + } + } + ); + + app.delete( + '/api/overlays/:id', + async (req: ExtendedIncomingMessage, res: ServerResponse) => { + const id = safeDecodeURIComponent(req.params.id); + try { + await service.deleteOverlay(id); + return sendJson(res, { status: 'deleted' }); + } catch (err) { + return sendJson(res, { error: (err as Error).message }, 500); + } + } + ); + + app.get( + '/api/overlays/:id', + (req: ExtendedIncomingMessage, res: ServerResponse) => { + const id = safeDecodeURIComponent(req.params.id); + const overlay = service.getOverlayById(id); + if (!overlay) { + return sendJson(res, { error: 'Overlay not found' }, 404); + } + return sendJson(res, overlay); + } + ); +} diff --git a/packages/server/router/socket.ts b/packages/server/router/socket.ts index e525bb6a..a99e8a5e 100644 --- a/packages/server/router/socket.ts +++ b/packages/server/router/socket.ts @@ -1,24 +1,17 @@ import { wLogger } from '@tosu/common'; -import { HttpServer, Websocket, isRequestAllowed } from '../index'; - -export default function buildSocket({ - app, - - WS_V1, - WS_SC, - WS_V2, - WS_V2_PRECISE, - WS_COMMANDS -}: { - app: HttpServer; - WS_V1: Websocket; - WS_SC: Websocket; - WS_V2: Websocket; - WS_V2_PRECISE: Websocket; - WS_COMMANDS: Websocket; -}) { - app.server.on('upgrade', function (request, socket, head) { +import { Server, WebSocketChannel, isRequestAllowed } from '../index'; + +export default function buildSocket(server: Server) { + const routeMap: Record = { + '/ws': server.sockets.v1, + '/tokens': server.sockets.sc, + '/websocket/v2': server.sockets.v2, + '/websocket/v2/precise': server.sockets.v2Precise, + '/websocket/commands': server.sockets.commands + }; + + server.app.server.on('upgrade', (request, socket, head) => { const allowed = isRequestAllowed(request); if (!allowed) { wLogger.warn( @@ -44,57 +37,14 @@ export default function buildSocket({ (value, key) => ((request as any).query[key] = value) ); - if (parsedURL.pathname === '/ws') { - WS_V1.socket.handleUpgrade( - request, - socket, - head, - function (ws) { - WS_V1.socket.emit('connection', ws, request); - } - ); - } - - if (parsedURL.pathname === '/tokens') { - WS_SC.socket.handleUpgrade( - request, - socket, - head, - function (ws) { - WS_SC.socket.emit('connection', ws, request); - } - ); - } - - if (parsedURL.pathname === '/websocket/v2') { - WS_V2.socket.handleUpgrade( - request, - socket, - head, - function (ws) { - WS_V2.socket.emit('connection', ws, request); - } - ); - } - - if (parsedURL.pathname === '/websocket/v2/precise') { - WS_V2_PRECISE.socket.handleUpgrade( - request, - socket, - head, - function (ws) { - WS_V2_PRECISE.socket.emit('connection', ws, request); - } - ); - } - - if (parsedURL.pathname === '/websocket/commands') { - WS_COMMANDS.socket.handleUpgrade( + const targetSocket = routeMap[parsedURL.pathname]; + if (targetSocket) { + targetSocket.server.handleUpgrade( request, socket, head, - function (ws) { - WS_COMMANDS.socket.emit('connection', ws, request); + (ws) => { + targetSocket.server.emit('connection', ws, request); } ); } diff --git a/packages/server/services/overlays.ts b/packages/server/services/overlays.ts new file mode 100644 index 00000000..b15ab328 --- /dev/null +++ b/packages/server/services/overlays.ts @@ -0,0 +1,437 @@ +import type { + Overlay, + OverlayResolution, + OverlayStatus, + RawRepositoryOverlay +} from '@tosu/common'; +import { + downloadFile, + getCachePath, + getStaticPath, + platformResolver, + scanLocalOverlays, + unzip, + wLogger +} from '@tosu/common'; +import { exec } from 'child_process'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +const OVERLAYS_API_URL = 'https://tosu.app/api.json'; +const SANITIZE_FILENAME_REGEX = /[/\\?%*:|"<>]/g; + +export type OverlayStatusFilter = 'installed' | 'upgradable' | 'installable'; + +export interface OverlaysStatusInfo { + totalCount: number; + installedCount: number; + updatesAvailable: number; + hash: string; +} + +function safeReadMetadata(filePath?: string): Map { + const metadata: Map = new Map(); + + if (!filePath || !fs.existsSync(filePath)) { + return metadata; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + const isCommentLine = trimmed.startsWith('##'); + + if (trimmed.length === 0 || isCommentLine) { + continue; + } + + const cleanLine = trimmed.split('##')[0]; + const idx = cleanLine.indexOf(':'); + + if (idx !== -1) { + const key = cleanLine.slice(0, idx).trim().toLowerCase(); + const value = cleanLine.slice(idx + 1).trim(); + + if (key && !metadata.has(key)) { + metadata.set(key, value); + } + } + } + + return metadata; + } catch (err) { + wLogger.debug( + `Failed to read overlay metadata file %${filePath}%:`, + (err as Error).message + ); + + return new Map(); + } +} + +function parseResolution( + input?: string | (string | number)[] +): OverlayResolution { + if (!input) return { width: null, height: null }; + + const [w, h] = Array.isArray(input) ? input : String(input).split(/[x, ]+/); + const width = parseInt(String(w), 10); + const height = parseInt(String(h), 10); + + return { + width: Number.isNaN(width) ? null : width, + height: Number.isNaN(height) ? null : height + }; +} + +function splitByComma(val?: string): string[] { + if (!val) return []; + return val + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +function generateHash(data: unknown): string { + return crypto.createHash('md5').update(JSON.stringify(data)).digest('hex'); +} + +export class OverlaysService { + private cache: Overlay[] = []; + private hash = ''; + private updatesAvailable = 0; + + private watchDebounceTimer: NodeJS.Timeout | null = null; + private syncPromise: Promise | null = null; + + public async initialize(): Promise { + await this.syncAndRefresh(true); + + const staticPath = getStaticPath(); + try { + fs.watch(staticPath, { recursive: false }, () => { + if (this.watchDebounceTimer) { + clearTimeout(this.watchDebounceTimer); + } + + this.watchDebounceTimer = setTimeout(() => { + this.syncAndRefresh(false).catch((err) => { + wLogger.error( + 'Failed background overlay watch sync:', + (err as Error).message + ); + }); + }, 500); + }); + } catch (err) { + wLogger.debug( + 'Failed to watch static folder:', + (err as Error).message + ); + } + } + + private readDiskOverlays(): Overlay[] { + const staticPath = getStaticPath(); + const folders = scanLocalOverlays(staticPath); + + return folders.map((folder) => { + const parsed = safeReadMetadata(folder.metadataPath); + const folderName = folder.folderName; + + return { + id: folderName, + name: parsed.get('name') || folderName, + version: parsed.get('version') || '1.0.0', + status: 'installed' as OverlayStatus, + author: { + name: parsed.get('author') || '???', + links: splitByComma(parsed.get('authorlinks')) + }, + url: `/${encodeURIComponent(folderName)}/`, + resolution: parseResolution(parsed.get('resolution')), + usecase: splitByComma(parsed.get('usecase')), + compatible: splitByComma(parsed.get('compatiblewith')), + hasSettings: Boolean(folder.settingsPath), + notes: parsed.get('notes') || '', + assets: [] + }; + }); + } + + private async fetchRepoOverlays(): Promise { + try { + const res = await fetch(OVERLAYS_API_URL); + if (!res.ok) { + wLogger.error( + `Failed to fetch repository overlays: HTTP ${res.status}` + ); + return []; + } + + const items = (await res.json()) as RawRepositoryOverlay[]; + return items.map((item) => ({ + id: item.id, + name: item.name, + version: item.version, + status: 'installable' as OverlayStatus, + author: { + name: item.author || '???', + links: item.authorlinks || [] + }, + url: item.downloadLink || '', + downloadUrl: item.downloadLink, + resolution: parseResolution(item.resolution), + usecase: item.usecase || [], + compatible: item.compatiblewith || [], + hasSettings: item._settings || false, + notes: item.notes || '', + assets: item.assets || [] + })); + } catch (err) { + wLogger.error( + 'Failed to fetch repository overlays:', + (err as Error).message + ); + return []; + } + } + + public async syncAndRefresh(forceRepoRefresh = false): Promise { + if (this.syncPromise) { + return this.syncPromise; + } + + this.syncPromise = this.performSync(forceRepoRefresh).finally(() => { + this.syncPromise = null; + }); + + return this.syncPromise; + } + + private async performSync(forceRepoRefresh: boolean): Promise { + try { + const local = this.readDiskOverlays(); + const currentRepoOnly = this.cache.filter( + (o) => o.status === 'installable' + ); + + const repo = + currentRepoOnly.length > 0 && !forceRepoRefresh + ? currentRepoOnly + : await this.fetchRepoOverlays(); + + const localMap = new Map(); + for (const l of local) { + const key = `${l.name.toLowerCase()} by ${l.author.name.toLowerCase()}`; + localMap.set(key, l); + localMap.set(l.id.toLowerCase(), l); + } + + let updates = 0; + const unifiedList: Overlay[] = [...local]; + + for (const r of repo) { + const key = `${r.name.toLowerCase()} by ${r.author.name.toLowerCase()}`; + const matchedLocal = + localMap.get(key) || localMap.get(r.id.toLowerCase()); + + if (matchedLocal) { + matchedLocal.downloadUrl = r.downloadUrl; + if (r.version !== matchedLocal.version) { + matchedLocal.status = 'upgradable'; + updates++; + } else { + matchedLocal.status = 'installed'; + } + + if (matchedLocal.assets.length === 0) { + matchedLocal.assets = r.assets; + } + } else { + r.status = 'installable'; + unifiedList.push(r); + } + } + + this.cache = unifiedList; + this.updatesAvailable = updates; + + const stableHashItems = unifiedList + .map((o) => ({ + id: o.id, + version: o.version, + status: o.status + })) + .sort((a, b) => a.id.localeCompare(b.id)); + + this.hash = generateHash(stableHashItems); + } catch (err) { + wLogger.error( + 'Failed overlay syncAndRefresh:', + (err as Error).message + ); + } + } + + public getOverlays(filter?: string): Overlay[] { + if (!filter) { + return this.cache; + } + + return this.cache.filter((o) => + filter === 'installed' + ? o.status === 'installed' || o.status === 'upgradable' + : o.status === filter + ); + } + + public getOverlayById(id: string): Overlay | undefined { + const target = id.toLowerCase(); + return this.cache.find((o) => { + const formattedName = `${o.name} by ${o.author.name}`.toLowerCase(); + return ( + o.id.toLowerCase() === target || + o.name.toLowerCase() === target || + formattedName === target + ); + }); + } + + public getStatus(): OverlaysStatusInfo { + const installedCount = this.cache.filter( + (o) => o.status === 'installed' || o.status === 'upgradable' + ).length; + + return { + totalCount: this.cache.length, + installedCount, + updatesAvailable: this.updatesAvailable, + hash: this.hash + }; + } + + private resolveFolderPath(id: string): string | null { + const overlay = this.getOverlayById(id); + const folderName = overlay + ? `${overlay.name} by ${overlay.author.name}` + : path.basename(id); + const staticPath = getStaticPath(); + const folderPath = path.resolve(staticPath, folderName); + + const relative = path.relative(staticPath, folderPath); + if ( + !relative || + relative.startsWith('..') || + path.isAbsolute(relative) + ) { + return null; + } + + return folderPath; + } + + public async downloadOverlay( + id: string, + customDownloadUrl?: string + ): Promise<{ path: string }> { + const entry = this.getOverlayById(id); + const rawUrl = customDownloadUrl || entry?.downloadUrl; + + if (!rawUrl) { + throw new Error('No download URL provided or found for this ID'); + } + + const folderName = entry?.author.name + ? `${entry.name} by ${entry.author.name}` + : entry?.name || id; + + const sanitizedFolder = folderName.replace( + SANITIZE_FILENAME_REGEX, + '_' + ); + const sanitizedId = id.replace(SANITIZE_FILENAME_REGEX, '_'); + + const folderPath = path.join(getStaticPath(), sanitizedFolder); + const cacheDir = getCachePath(); + const tempZipPath = path.join(cacheDir, `${sanitizedId}.zip`); + const tempExtractPath = path.join(cacheDir, sanitizedId); + + try { + fs.mkdirSync(cacheDir, { recursive: true }); + + // Strip osuck.link redirect to prevent throttling + const directUrl = rawUrl.replace( + /^https?:\/\/osuck\.link\/redirect\//i, + '' + ); + let safeUrl: string; + try { + safeUrl = encodeURI(decodeURI(directUrl)); + } catch { + safeUrl = encodeURI(directUrl); + } + + wLogger.info(`Downloading overlay from %${safeUrl}%`); + + await downloadFile(safeUrl, tempZipPath); + await unzip(tempZipPath, tempExtractPath); + + fs.rmSync(folderPath, { recursive: true, force: true }); + fs.renameSync(tempExtractPath, folderPath); + + wLogger.info( + `Overlay %${sanitizedFolder}% downloaded and installed.` + ); + await this.syncAndRefresh(false); + return { path: folderPath }; + } catch (error) { + wLogger.error( + `Failed to download overlay %${sanitizedFolder}%:`, + (error as Error).message + ); + throw error; + } finally { + fs.rmSync(tempZipPath, { force: true }); + fs.rmSync(tempExtractPath, { recursive: true, force: true }); + } + } + + public async openOverlay(id: string): Promise { + const folderPath = this.resolveFolderPath(id); + if (!folderPath || !fs.existsSync(folderPath)) { + throw new Error("Folder doesn't exist"); + } + + const platform = platformResolver(process.platform); + return new Promise((resolve, reject) => { + exec(`${platform.command} "${folderPath}"`, (err) => { + if (err) { + wLogger.error( + `Failed to open folder %${folderPath}%:`, + err.message + ); + reject(err); + } else { + resolve(); + } + }); + }); + } + + public async deleteOverlay(id: string): Promise { + const folderPath = this.resolveFolderPath(id); + if (!folderPath || !fs.existsSync(folderPath)) { + throw new Error("Folder doesn't exist"); + } + + fs.rmSync(folderPath, { recursive: true, force: true }); + wLogger.info(`Overlay at %${folderPath}% deleted.`); + await this.syncAndRefresh(false); + } +} + +export const overlaysService = new OverlaysService(); diff --git a/packages/server/utils/commands.ts b/packages/server/utils/commands.ts index e402d811..10bc9aaf 100644 --- a/packages/server/utils/commands.ts +++ b/packages/server/utils/commands.ts @@ -3,7 +3,11 @@ import { JsonSafeParse, debounce, wLogger } from '@tosu/common'; import { getLocalCounters, saveSettings } from './counters'; import type { bodyPayload } from './counters.types'; import { parseCounterSettings } from './parseSettings'; -import { type ModifiedWebsocket, Websocket } from './socket'; +import { + type WebSocketChannel, + type WsConnection, + getConnName +} from './socket'; const saveDelay = debounce((overlayFrom: string, json: bodyPayload[]) => { const html = saveSettings(overlayFrom, json); @@ -20,53 +24,53 @@ const saveDelay = debounce((overlayFrom: string, json: bodyPayload[]) => { export function handleSocketCommands( data: string, - socket: ModifiedWebsocket, - ws: Websocket + conn: WsConnection, + channel: WebSocketChannel ) { - wLogger.debug(`Received WebSocket command: %${data}%`); + wLogger.debug( + `Received WebSocket command: %${data}% from %${getConnName(conn)}%` + ); if (!data.includes(':')) { return; } const firstIndex = data.indexOf(':'); - const SecondIndex = data.indexOf(':', firstIndex + 1); + const secondIndex = data.indexOf(':', firstIndex + 1); const command = data.substring(0, firstIndex); const overlayName = - SecondIndex === -1 + secondIndex === -1 ? decodeURIComponent(data.substring(firstIndex + 1)) - : decodeURIComponent(data.substring(firstIndex + 1, SecondIndex)); + : decodeURIComponent(data.substring(firstIndex + 1, secondIndex)); const legacyPayload = data.substring(firstIndex + 1); - const payload = data.substring(SecondIndex + 1); + const payload = secondIndex === -1 ? '' : data.substring(secondIndex + 1); let message: unknown; - const overlayFrom = decodeURI(socket.query?.l || ''); switch (command) { case 'getOverlays': case 'getCounters': { - if (overlayFrom !== '__ingame__' || overlayName !== overlayFrom) { - message = { - error: 'Wrong overlay' - }; - break; - } - message = getLocalCounters(); break; } case 'getSettings': { - if (overlayName !== overlayFrom) { - message = { - error: 'Wrong overlay' - }; - break; - } - try { - const result = parseCounterSettings(overlayName, 'counter/get'); + const targetOverlay = + overlayName && overlayName !== 'undefined' + ? overlayName + : conn.overlayName || ''; + + if (!targetOverlay) { + message = { error: 'No overlay specified or resolved' }; + break; + } + + const result = parseCounterSettings( + targetOverlay, + 'counter/get' + ); if (result instanceof Error) { message = { error: result.message @@ -77,7 +81,7 @@ export function handleSocketCommands( message = result.values; } catch (exc) { wLogger.error( - `Failed to get data for command %${command}%:`, + `Failed to get settings for %${overlayName}% from %${getConnName(conn)}%:`, (exc as Error).message ); wLogger.debug(`Settings retrieval error details:`, exc); @@ -94,7 +98,7 @@ export function handleSocketCommands( }); if (json instanceof Error) { wLogger.error( - `Failed to parse JSON for command %${command}%:`, + `Failed to parse JSON for command %${command}% from %${getConnName(conn)}%:`, (json as Error).message ); wLogger.debug(`JSON parsing error details:`, json); @@ -113,20 +117,23 @@ export function handleSocketCommands( }); if (json instanceof Error) { wLogger.error( - `Failed to parse JSON for command %${command}%:`, + `Failed to parse JSON for command %${command}% from %${getConnName(conn)}%:`, (json as Error).message ); wLogger.debug(`JSON parsing error details:`, json); return; } - saveDelay(overlayFrom, json); + const targetOverlay = + overlayName && overlayName !== 'undefined' + ? overlayName + : conn.overlayName || ''; + saveDelay(targetOverlay, json); - ws.socket.emit( - 'message', - socket.id, + channel.dispatchCommand( + conn.id, 'updateSettings', - overlayName, + targetOverlay, payload ); return; @@ -135,12 +142,12 @@ export function handleSocketCommands( case 'applyFilters': { const json = JsonSafeParse({ isFile: false, - payload: payload.startsWith('[') ? payload : legacyPayload, // FIXME: + payload: payload.startsWith('[') ? payload : legacyPayload, defaultValue: new Error('Broken json') }); if (json instanceof Error) { wLogger.error( - `Failed to parse JSON for command %${command}%:`, + `Failed to parse JSON for command %${command}% from %${getConnName(conn)}%:`, (json as Error).message ); wLogger.debug(`JSON parsing error details:`, json); @@ -150,17 +157,17 @@ export function handleSocketCommands( try { if (!Array.isArray(json)) { wLogger.error( - `Invalid filter format for socket %${socket.id}% [${socket.pathname}]:`, + `Invalid filter format for socket %${getConnName(conn)}%:`, `Filters should be an array of strings (received: ${json})` ); return; } - socket.filters = json; + conn.filters = json; return; } catch (exc) { wLogger.error( - `Failed to apply filters for command %${command}%:`, + `Failed to apply filters for command %${command}% from %${getConnName(conn)}%:`, (exc as Error).message ); wLogger.debug(`Filter application error details:`, exc); @@ -169,7 +176,7 @@ export function handleSocketCommands( } try { - socket.send( + conn.socket.send( JSON.stringify({ command, message @@ -177,7 +184,7 @@ export function handleSocketCommands( ); } catch (exc) { wLogger.error( - `Failed to send response for command %${command}%:`, + `Failed to send response for command %${command}% to %${getConnName(conn)}%:`, (exc as Error).message ); wLogger.debug(`Command response error details:`, exc); diff --git a/packages/server/utils/directories.ts b/packages/server/utils/directories.ts index 3b99be3f..97131f14 100644 --- a/packages/server/utils/directories.ts +++ b/packages/server/utils/directories.ts @@ -6,6 +6,7 @@ import path from 'path'; import { type ExtendedIncomingMessage, getContentType } from '../index'; import { OVERLAYS_STATIC } from './homepage'; +import { createOverlayToken } from './socket'; const allowedRangeExtensions = [ '.mp3', @@ -99,7 +100,7 @@ export function directoryWalker({ } if (isHTML === true) { - html = addCounterMetadata(html, filePath); + html = injectOverlayRuntime(html); } res.writeHead(200, { @@ -123,7 +124,7 @@ export function directoryWalker({ } if (isHTML === true) { - content = addCounterMetadata(content.toString(), filePath); + content = injectOverlayRuntime(content.toString()); } if (req.headers.range) { @@ -189,25 +190,195 @@ export function readDirectory( }); } -export function addCounterMetadata(html: string, filePath: string) { +export interface HttpContext { + req: ExtendedIncomingMessage; + res: http.ServerResponse; +} + +export interface ServeStaticOptions { + ctx: HttpContext; + root: string; + pathname: string; + isOverlay?: boolean; +} + +async function resolveStaticPath({ + root, + pathname +}: Pick): Promise<{ + targetPath: string; + stats: fs.Stats; + needsRedirect?: boolean; +} | null> { + let cleanedUrl: string; try { - const staticPath = getStaticPath(); + cleanedUrl = decodeURIComponent(pathname); + } catch (error) { + wLogger.debug( + `Failed to decode URL pathname %${pathname}%:`, + (error as Error).message + ); + return null; + } - const counterPath = path - .dirname(filePath.replace(staticPath, '')) - .replace(/^(\\\\\\|\\\\|\\|\/|\/\/)/, '') - .replace(/\\/gm, '/'); + const resolvedFolder = path.resolve(root); + let targetPath = path.resolve( + resolvedFolder, + cleanedUrl.replace(/^[/\\]+/, '') + ); - html += `\n\n\n\n`; + if (!targetPath.startsWith(resolvedFolder)) { + wLogger.warn(`Blocked potential path traversal request: %${pathname}%`); + return null; + } - return html; + try { + let stats = await fs.promises.stat(targetPath); + + if (stats.isDirectory()) { + if (!pathname.endsWith('/')) { + return { targetPath, stats, needsRedirect: true }; + } + targetPath = path.join(targetPath, 'index.html'); + stats = await fs.promises.stat(targetPath); + } + + return { targetPath, stats }; + } catch (err: any) { + if (err?.code !== 'ENOENT') { + wLogger.debug( + `Failed to stat path %${targetPath}%:`, + (err as Error).message + ); + } + return null; + } +} + +interface StreamByteRangeOptions { + ctx: HttpContext; + targetPath: string; + fileSize: number; + contentType: string; +} + +function streamByteRange({ + ctx, + targetPath, + fileSize, + contentType +}: StreamByteRangeOptions) { + const { req, res } = ctx; + const range = (req.headers.range || '').replace('bytes=', '').split('-'); + const start = parseInt(range[0], 10); + const end = range[1] ? parseInt(range[1], 10) : fileSize - 1; + + if (start >= fileSize || end >= fileSize) { + res.writeHead(416, { 'Content-Range': `bytes */${fileSize}` }); + return res.end(); + } + + res.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Type': contentType, + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Content-Length': end - start + 1 + }); + + return fs.createReadStream(targetPath, { start, end }).pipe(res); +} + +export async function serveStaticFile(options: ServeStaticOptions) { + const { ctx, root, pathname, isOverlay = true } = options; + const { req, res } = ctx; + + const resolved = await resolveStaticPath({ root, pathname }); + if (!resolved) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('404 Not Found'); + } + + const { targetPath, stats, needsRedirect } = resolved; + + if (needsRedirect) { + res.writeHead(301, { Location: req.pathname + '/' }); + return res.end(); + } + + const contentType = getContentType(targetPath); + + if (isOverlay && targetPath.endsWith('.html')) { + try { + const rawContent = await fs.promises.readFile(targetPath, 'utf8'); + const relativeDir = path + .relative(getStaticPath(), path.dirname(targetPath)) + .replace(/\\/g, '/'); + + const token = createOverlayToken(relativeDir); + + res.writeHead(200, { + 'Content-Type': contentType + }); + return res.end(injectOverlayRuntime(rawContent, token)); + } catch { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + return res.end('Server Error'); + } + } + + if (req.headers.range) { + return streamByteRange({ + ctx, + targetPath, + fileSize: stats.size, + contentType + }); + } + + const headOptions: OutgoingHttpHeaders = { 'Content-Type': contentType }; + if (allowedRangeExtensions.includes(path.extname(targetPath))) { + headOptions['Accept-Ranges'] = 'bytes'; + headOptions['Content-Length'] = stats.size; + } + + res.writeHead(200, headOptions); + return fs.createReadStream(targetPath).pipe(res); +} + +export function injectOverlayRuntime(html: string, token?: string): string { + try { + const tokenScript = token ? `window.TOSU_TOKEN = "${token}";` : ''; + const injection = ` + + + + `.trim(); + + if (/]*>/i.test(html)) { + return html.replace( + /]*>/i, + (match) => `${match}\n${injection}` + ); + } + + return `${injection}\n${html}`; } catch (error) { wLogger.error( - 'Failed to add counter metadata:', - (error as any).message + 'Failed to inject overlay runtime:', + (error as Error).message ); - wLogger.debug('Counter metadata error details:', error); + wLogger.debug('Overlay runtime injection error details:', error); - return ''; + return html; } } diff --git a/packages/server/utils/hashing.ts b/packages/server/utils/hashing.ts deleted file mode 100644 index eff4b2d0..00000000 --- a/packages/server/utils/hashing.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function getUniqueID() { - const s4 = () => - Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); - return s4() + s4() + '-' + s4(); -} diff --git a/packages/server/utils/http.ts b/packages/server/utils/http.ts index 06eed9ba..d3ada86d 100644 --- a/packages/server/utils/http.ts +++ b/packages/server/utils/http.ts @@ -1,46 +1,83 @@ import { config, platformResolver, wLogger } from '@tosu/common'; import { exec } from 'child_process'; -import http, { IncomingMessage, ServerResponse } from 'http'; +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import type { InstanceManager } from 'tosu/instances/manager'; -import { sendJson } from './index'; - export interface ExtendedIncomingMessage extends IncomingMessage { + body?: string; + pathname?: string; + query: Record; + params: Record; instanceManager: InstanceManager; - body: string; - query: { [key: string]: string }; - params: { [key: string]: string }; - pathname: string; - getContentType: (text: string) => string; - sendJson: ( - response: http.ServerResponse, - json: object | any[] - ) => http.ServerResponse; } -type RequestHandler = ( - req: ExtendedIncomingMessage, - res: http.ServerResponse, - next: () => void -) => void; +interface HttpContext { + req: ExtendedIncomingMessage; + res: ServerResponse; +} + +export type RequestHandler = ( + req: HttpContext['req'], + res: HttpContext['res'], + next: (err?: unknown) => void +) => void | Promise; + +export type RouteHandler = ( + req: HttpContext['req'], + res: HttpContext['res'] +) => unknown | Promise; + +type HTTPMethod = (typeof http.METHODS)[number]; + +interface CompiledRoute { + method: string; + pattern: RegExp; + keys: string[]; + handler: RouteHandler; + originalPath: string | RegExp; +} -type RouteHandler = { - (req: ExtendedIncomingMessage, res: ServerResponse): void; -}; +function pathToRegex(path: string | RegExp): { + pattern: RegExp; + keys: string[]; +} { + const keys: string[] = []; + if (path instanceof RegExp) { + return { pattern: path, keys }; + } + + if (path === '*') { + return { pattern: /^.*$/, keys }; + } + + const sanitized = path.replace(/\/+/g, '/').replace(/\/$/, ''); + if (!sanitized) { + return { pattern: /^\/?$/, keys }; + } + + const regexPath = sanitized + .replace(/([.+?^=${}()|[\]\\])/g, '\\$1') + .replace(/:([a-zA-Z0-9_]+)/g, (_, key) => { + keys.push(key); + return '([^/]+)'; + }) + .replace(/\*/g, '(.*)'); + + return { + pattern: new RegExp(`^${regexPath}/?$`, 'i'), + keys + }; +} export class HttpServer { + public readonly server: http.Server; private middlewares: RequestHandler[] = []; - server: http.Server; - private routes: { - [method: string]: { - path: string | RegExp; - handler: RouteHandler; - }[]; - } = {}; + private routes: CompiledRoute[] = []; constructor() { - // @ts-ignore - this.server = http.createServer(this.handleRequest.bind(this)); + this.server = http.createServer((req, res) => { + this.handleRequest(req as ExtendedIncomingMessage, res); + }); this.server.on('error', (err) => { if (err.message.includes('getaddrinfo')) { @@ -55,36 +92,59 @@ export class HttpServer { }); } - use(middleware: RequestHandler) { + public use(middleware: RequestHandler) { this.middlewares.push(middleware); } - route( + public get(path: string | RegExp, handler: RouteHandler) { + this.route(path, 'GET', handler); + } + + public post(path: string | RegExp, handler: RouteHandler) { + this.route(path, 'POST', handler); + } + + public put(path: string | RegExp, handler: RouteHandler) { + this.route(path, 'PUT', handler); + } + + public delete(path: string | RegExp, handler: RouteHandler) { + this.route(path, 'DELETE', handler); + } + + public route( path: string | RegExp, - method: - | 'GET' - | 'POST' - | 'HEAD' - | 'PUT' - | 'DELETE' - | 'CONNECT' - | 'OPTIONS' - | 'TRACE' - | 'PATCH', + method: HTTPMethod | string, handler: RouteHandler ) { - if (this.routes[method] == null) this.routes[method] = []; + const m = method.toUpperCase(); + let pattern: RegExp; + let keys: string[] = []; + + if (path instanceof RegExp) { + pattern = path; + } else { + const compiled = pathToRegex(path); + pattern = compiled.pattern; + keys = compiled.keys; + } - const find = this.routes[method].find((r) => r.path === path); - if (!find) this.routes[method].push({ path, handler }); + const exists = this.routes.some( + (r) => r.method === m && r.originalPath === path + ); + if (!exists) { + this.routes.push({ + method: m, + pattern, + keys, + handler, + originalPath: path + }); + } } - private handleRequest( - req: ExtendedIncomingMessage, - res: http.ServerResponse - ) { + private handleRequest(req: ExtendedIncomingMessage, res: ServerResponse) { const startTime = performance.now(); - let body = ''; res.on('finish', () => { const elapsedTime = (performance.now() - startTime).toFixed(2); @@ -97,102 +157,109 @@ export class HttpServer { ); }); - const next = (index: number) => { + let index = 0; + const next = (err?: unknown) => { + if (err) { + wLogger.error( + 'Middleware execution error:', + (err as Error).message + ); + res.statusCode = 500; + res.end('Internal Server Error'); + return; + } + if (index < this.middlewares.length) { - const savedIndex = index; const middleware = this.middlewares[index++]; - try { - middleware(req, res, () => { - next(savedIndex + 1); - }); + middleware(req, res, next); } catch (exc) { - wLogger.error( - 'Middleware execution failed:', - (exc as Error).message - ); - wLogger.debug('Middleware error details:', exc); + next(exc); } return; } - // get data aka body - if (['POST', 'PUT', 'PATCH'].includes(req.method || '')) { + const method = (req.method || 'GET').toUpperCase(); + if (['POST', 'PUT', 'PATCH'].includes(method)) { + let body = ''; + const maxBodyLength = 10 * 1024 * 1024; // 10MB limit + req.on('data', (chunk) => { body += chunk; + if (body.length > maxBodyLength) { + res.statusCode = 413; + res.end('Payload Too Large'); + req.destroy(); + } + }); + + req.on('error', (err) => { + wLogger.error('Request body stream error:', err.message); + if (!res.headersSent) { + res.statusCode = 400; + res.end('Bad Request'); + } }); req.on('end', () => { + if (res.writableEnded) return; req.body = body; - - this.handleNext(req, res); + this.dispatchRoute(req, res); }); - return; + } else { + req.body = ''; + this.dispatchRoute(req, res); } - this.handleNext(req, res); }; - next(0); + next(); } - private handleNext(req: ExtendedIncomingMessage, res: http.ServerResponse) { - const method = req.method || 'GET'; - const hostname = req.headers.host; // Hostname - - const parsedURL = new URL(`http://${hostname}${req.url}`); + private dispatchRoute(req: ExtendedIncomingMessage, res: ServerResponse) { + const method = (req.method || 'GET').toUpperCase(); + const hostHeader = req.headers.host || 'localhost'; + + let parsedURL: URL; + try { + parsedURL = new URL(req.url || '/', `http://${hostHeader}`); + } catch { + res.statusCode = 400; + res.end('Bad Request'); + return; + } - // parse query parameters + req.pathname = parsedURL.pathname; req.query = {}; req.params = {}; - req.pathname = parsedURL.pathname; - parsedURL.searchParams.forEach( - (value, key) => (req.query[key] = value) - ); + parsedURL.searchParams.forEach((value, key) => { + req.query![key] = value; + }); - const routes = this.routes[method] || []; - for (let i = 0; i < routes.length; i++) { - const route = routes[i]; - let routeExists = false; + for (let i = 0; i < this.routes.length; i++) { + const route = this.routes[i]; + if (route.method !== method) continue; - if ( - route.path instanceof RegExp && - route.path.test(parsedURL.pathname) - ) { - routeExists = true; + const match = route.pattern.exec(parsedURL.pathname); + if (!match) continue; - // turn groups to route params - const array = Object.keys( - route.path.exec(parsedURL.pathname)?.groups || {} - ); - for (let g = 0; g < array.length; g++) { - const key = array[g]; - const value = route.path.exec(parsedURL.pathname)?.groups?.[ - key - ]; - - if (key == null || value == null) continue; - req.params[key] = value; + if (route.keys.length > 0) { + for (let k = 0; k < route.keys.length; k++) { + req.params[route.keys[k]] = decodeURIComponent( + match[k + 1] || '' + ); } - } else if (typeof route.path === 'string') { - routeExists = route.path === parsedURL.pathname; + } else if (match.groups) { + Object.assign(req.params, match.groups); } - if (!routeExists) continue; try { return route.handler(req, res); } catch (exc) { const message = typeof exc === 'string' ? exc : (exc as Error).message; - if ((exc as NodeJS.ErrnoException)?.code === 'ENOENT') - res.statusMessage = encodeURI( - `${parsedURL.pathname} ENOENT: no such file or directory` - ); - else res.statusMessage = encodeURI(message); - res.statusCode = 500; - wLogger.warn( `Request to %${parsedURL.pathname}% failed:`, message @@ -202,7 +269,8 @@ export class HttpServer { exc ); - return sendJson(res, { error: message }); + res.end('Internal Server Error'); + return; } } @@ -210,7 +278,7 @@ export class HttpServer { res.end('Not Found'); } - listen(port: number, hostname: string) { + public listen(port: number, hostname: string) { this.server.listen(port, hostname, () => { const ip = hostname === '0.0.0.0' ? 'localhost' : hostname; wLogger.info(`Dashboard server started on %http://${ip}:${port}%`); @@ -219,7 +287,7 @@ export class HttpServer { const platform = platformResolver(process.platform); exec( `${platform.command} http://${ip}:${port}`, - (error, stdout, stderr) => { + (error, _stdout, stderr) => { if (error || stderr) { return; } diff --git a/packages/server/utils/index.ts b/packages/server/utils/index.ts index 076fb56c..8c8434b5 100644 --- a/packages/server/utils/index.ts +++ b/packages/server/utils/index.ts @@ -77,7 +77,12 @@ export function getContentType(text: string) { return contentType; } -export function sendJson(response: http.ServerResponse, json: object | any[]) { +export function sendJson( + response: http.ServerResponse, + json: object | any[], + statusCode?: number +) { + if (statusCode) response.statusCode = statusCode; response.setHeader('Content-Type', 'application/json'); try { diff --git a/packages/server/utils/parseSettings.ts b/packages/server/utils/parseSettings.ts index 534dcf9a..836acf21 100644 --- a/packages/server/utils/parseSettings.ts +++ b/packages/server/utils/parseSettings.ts @@ -18,6 +18,10 @@ export function parseCounterSettings( action: 'parse' | 'user/save' | 'counter/get' | 'dev/save' | '', payload?: bodyPayload[] & ISettings[] ) { + if (!folderName || folderName === 'undefined' || folderName === 'null') { + return new Error('Invalid or missing overlay folder name'); + } + const ingameOverlay = folderName === '__ingame__'; const staticPath = getStaticPath(); const settingsPath = path.join( diff --git a/packages/server/utils/report.ts b/packages/server/utils/report.ts index 557bb672..a8ac5088 100644 --- a/packages/server/utils/report.ts +++ b/packages/server/utils/report.ts @@ -4,6 +4,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import * as readline from 'node:readline/promises'; import { battery, cpu, graphics, osInfo } from 'systeminformation'; +import type { AbstractInstance } from 'tosu/instances'; import type { InstanceManager } from 'tosu/instances/manager'; import { getLocalCounters } from './counters'; @@ -66,7 +67,7 @@ export async function generateReport( instanceManager: InstanceManager ): Promise { const instances = Object.values(instanceManager.osuInstances).map( - (instance) => ({ + (instance: AbstractInstance) => ({ pid: instance.pid, type: ClientType[instance.client] as keyof typeof ClientType, bitness: Bitness[instance.bitness] as keyof typeof Bitness, diff --git a/packages/server/utils/socket.ts b/packages/server/utils/socket.ts index 6923d666..b65a0723 100644 --- a/packages/server/utils/socket.ts +++ b/packages/server/utils/socket.ts @@ -1,231 +1,200 @@ -import { type ConfigKey, config, sleep, wLogger } from '@tosu/common'; -import type { AbstractInstance } from 'tosu/instances'; -import type { InstanceManager } from 'tosu/instances/manager'; +import { wLogger } from '@tosu/common'; +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; import { WebSocket, WebSocketServer } from 'ws'; -import { getUniqueID } from './hashing'; +export type Filter = string | { field: string; keys: Filter[] }; -type Filter = string | { field: string; keys: Filter[] }; - -export interface ModifiedWebsocket extends WebSocket { +export interface WsConnection { id: string; - pathname: string; - query: Record; - + socket: WebSocket; + overlayName: string | null; filters: Filter[]; - - hostAddress: string; - localAddress: string; - originAddress: string; - remoteAddress: string; } -type StateFunctionKey = { - [K in keyof T]: T[K] extends (instanceManager: InstanceManager) => unknown - ? K - : never; -}[keyof T]; - -export class Websocket { - private instanceManager: InstanceManager; - private onMessageCallback: ( +export interface ChannelOptions { + onMessage?: ( data: string, - socket: ModifiedWebsocket, - ws: Websocket + conn: WsConnection, + channel: WebSocketChannel ) => void; +} - private onConnectionCallback: (id: string, url: string | undefined) => void; - - socket: WebSocketServer; - clients = new Map(); - - constructor({ - instanceManager, - pollRateFieldName, - stateFunctionName, - onMessageCallback, - onConnectionCallback - }: { - instanceManager: InstanceManager; - pollRateFieldName: ConfigKey | ''; - stateFunctionName: StateFunctionKey | ''; - onMessageCallback?: ( - data: string, - socket: ModifiedWebsocket, - ws: Websocket - ) => void; - onConnectionCallback?: (id: string, url: string | undefined) => void; - }) { - this.socket = new WebSocketServer({ noServer: true }); - - this.instanceManager = instanceManager; - - if (typeof onMessageCallback === 'function') { - this.onMessageCallback = onMessageCallback; - } - if (typeof onConnectionCallback === 'function') { - this.onConnectionCallback = onConnectionCallback; - } +const pendingTokens = new Map< + string, + { overlayName: string; expiresAt: number } +>(); + +export function createOverlayToken(overlayName: string): string { + const token = randomUUID(); + + pendingTokens.set(token, { + overlayName, + expiresAt: Date.now() + 60000 + }); + + return token; +} + +export function redeemOverlayToken(token: string): string | null { + if (!token) return null; - this.handle = this.handle.bind(this); - this.start = this.start.bind(this); + const entry = pendingTokens.get(token); + if (!entry) return null; - this.handle(pollRateFieldName, stateFunctionName); + if (Date.now() > entry.expiresAt) { + pendingTokens.delete(token); + return null; } - handle( - pollRateFieldName: ConfigKey | '', - stateFunctionName: StateFunctionKey | '' - ) { - this.socket.on('connection', (ws: ModifiedWebsocket, request) => { - ws.id = getUniqueID(); + return entry.overlayName; +} + +export function extractOverlayName(request: IncomingMessage): string | null { + try { + const hostHeader = request.headers.host || 'localhost'; + const parsedURL = new URL(request.url || '/', `http://${hostHeader}`); + + const tokenParam = + parsedURL.searchParams.get('token') || + parsedURL.searchParams.get('tosu_token'); + if (tokenParam) { + const redeemed = redeemOverlayToken(tokenParam); + if (redeemed) return redeemed; + } + + const queryOverlay = + parsedURL.searchParams.get('l') || + parsedURL.searchParams.get('overlay'); + if (queryOverlay) { + return decodeURIComponent(queryOverlay); + } + } catch {} - ws.pathname = request.url as any; + return null; +} - ws.query = (request as any).query; +export function getConnName(conn: WsConnection): string { + return conn.overlayName + ? `${conn.overlayName} (#${conn.id})` + : `client (#${conn.id})`; +} - ws.hostAddress = request.headers.host || ''; - ws.localAddress = `${request.socket.localAddress}:${request.socket.localPort}`; - ws.originAddress = request.headers.origin || ''; - ws.remoteAddress = `${request.socket.remoteAddress}:${request.socket.remotePort}`; +export class WebSocketChannel { + public readonly server = new WebSocketServer({ noServer: true }); + public readonly connections = new Map(); - wLogger.debug(`WebSocket client connected: %${ws.id}%`); + private readonly onMessage?: ( + data: string, + conn: WsConnection, + channel: WebSocketChannel + ) => void; - ws.on('close', (reason, description) => { - this.clients.delete(ws.id); + constructor(options: ChannelOptions = {}) { + this.onMessage = options.onMessage; - wLogger.debug( - `WebSocket client disconnected: %${ws.id}%`, - reason, - description - ); - }); + this.server.on( + 'connection', + (socket: WebSocket, request: IncomingMessage) => { + const overlayName = extractOverlayName(request); - ws.on('error', (reason: unknown, description: unknown) => { - this.clients.delete(ws.id); + const conn: WsConnection = { + id: randomUUID(), + socket, + overlayName, + filters: [] + }; + this.connections.set(conn.id, conn); wLogger.debug( - `WebSocket client error: %${ws.id}%`, - reason, - description + `WebSocket client connected: %${getConnName(conn)}%` ); - }); - if (typeof this.onMessageCallback === 'function') { - ws.on('message', (data) => { - this.onMessageCallback(data.toString(), ws, this); - }); - } + const cleanup = () => { + this.connections.delete(conn.id); + wLogger.debug( + `WebSocket client disconnected: %${getConnName(conn)}%` + ); + }; - this.clients.set(ws.id, ws); - if (typeof this.onConnectionCallback === 'function') { - this.onConnectionCallback(ws.id, request.url); - } - }); + socket.on('close', cleanup); + socket.on('error', cleanup); - // resend commands internally "this.socket.emit" - this.socket.on( - 'message', - ( - id: string, - command: string, - overlayName: string, - payload?: string - ) => { - this.clients.forEach((client) => { - if (client.id === id) return; - - // skip sending settings to wrong overlay - if ( - (command === 'getSettings' || - command === 'updateSettings') && - overlayName !== decodeURI(client.query.l || '') - ) - return; - - client.emit( - 'message', - [command, overlayName, payload].join(':') - ); - }); + if (this.onMessage) { + socket.on('message', (data) => { + this.onMessage!(data.toString(), conn, this); + }); + } } ); - - if (pollRateFieldName && stateFunctionName !== '') { - this.start(pollRateFieldName, stateFunctionName); - } } - async start( - pollRateFieldName: ConfigKey, - stateFunctionName: StateFunctionKey + public dispatchCommand( + senderId: string, + command: string, + overlayName: string, + payload?: string ) { - let message = ''; - let values = {}; - - while (true) { - try { - const osuInstance = this.instanceManager.getInstance( - this.instanceManager.focusedClient - ); - if (!osuInstance || this.clients.size === 0) { - await sleep(500); - continue; - } - - const buildedData = osuInstance[stateFunctionName]( - this.instanceManager - ); - - this.clients.forEach((client) => { - if ( - Array.isArray(client.filters) && - client.filters.length > 0 - ) { - values = {}; - this.applyFilter(client.filters, buildedData, values); - - client.send(JSON.stringify(values)); - return; - } - - message = JSON.stringify(buildedData); - client.send(message); - }); - } catch (error) { - wLogger.error( - 'WebSocket data loop failed:', - (error as any).message - ); - wLogger.debug('WebSocket loop error details:', error); + this.connections.forEach((conn) => { + if (conn.id === senderId) return; + + if ( + (command === 'getSettings' || command === 'updateSettings') && + conn.overlayName && + overlayName !== conn.overlayName + ) { + return; } - await sleep(config[pollRateFieldName] as number); - } + if (conn.socket.readyState === WebSocket.OPEN) { + const message = + payload !== undefined + ? `${command}:${overlayName}:${payload}` + : `${command}:${overlayName}`; + conn.socket.send(message); + } + }); } - applyFilter(filters: Filter[], data: any, value: any) { - if (data === null || data === undefined) return; + public broadcast(data: unknown) { + if (this.connections.size === 0) return; - for (let i = 0; i < filters.length; i++) { - const filter = filters[i]; - switch (typeof filter) { - case 'string': - value[filter] = data[filter]; - break; + let cachedJson: string | null = null; - case 'object': { - if (!(filter.field && Array.isArray(filter.keys))) break; - if ( - data[filter.field] === null || - data[filter.field] === undefined - ) - break; + this.connections.forEach((conn) => { + if (conn.socket.readyState !== WebSocket.OPEN) return; + + if (Array.isArray(conn.filters) && conn.filters.length > 0) { + const values: Record = {}; + this.applyFilter(conn.filters, data, values); + conn.socket.send(JSON.stringify(values)); + } else { + if (cachedJson === null) { + cachedJson = + typeof data === 'string' ? data : JSON.stringify(data); + } + conn.socket.send(cachedJson); + } + }); + } + private applyFilter(filters: Filter[], data: any, value: any) { + if (!data) return; + + for (const filter of filters) { + if (typeof filter === 'string') { + value[filter] = data[filter]; + } else if ( + typeof filter === 'object' && + filter.field && + Array.isArray(filter.keys) + ) { + const fieldValue = data[filter.field]; + if (fieldValue !== null && fieldValue !== undefined) { value[filter.field] = {}; this.applyFilter( filter.keys, - data[filter.field], + fieldValue, value[filter.field] ); } diff --git a/packages/server/utils/task.ts b/packages/server/utils/task.ts new file mode 100644 index 00000000..f454a698 --- /dev/null +++ b/packages/server/utils/task.ts @@ -0,0 +1,90 @@ +import { debounce, wLogger } from '@tosu/common'; + +export interface TaskHandle { + stop: () => void; +} + +export class Task { + public static recur( + getInterval: () => number, + callback: (stop: () => void) => void | Promise + ): TaskHandle { + let timer: NodeJS.Timeout | null = null; + let stopped = false; + + const stop = () => { + stopped = true; + if (timer) clearTimeout(timer); + }; + + const loop = async () => { + if (stopped) return; + try { + await callback(stop); + } catch (err) { + wLogger.error( + 'Recurring task execution error:', + (err as Error).message + ); + wLogger.debug('Recurring task error details:', err); + } + + if (!stopped) { + const interval = Math.max(10, getInterval()); + timer = setTimeout(loop, interval); + } + }; + + const initialInterval = Math.max(10, getInterval()); + timer = setTimeout(loop, initialInterval); + + return { stop }; + } + + public static once( + predicate: () => T | false | null | undefined, + action: (result: T) => void | Promise, + checkIntervalMs = 100 + ): TaskHandle { + let timer: NodeJS.Timeout | null = null; + let done = false; + + const stop = () => { + done = true; + if (timer) clearTimeout(timer); + }; + + const check = async () => { + if (done) return; + try { + const result = predicate(); + if (result) { + done = true; + await action(result); + return; + } + } catch (err) { + wLogger.error( + 'Condition waiter task error:', + (err as Error).message + ); + wLogger.debug('Condition waiter error details:', err); + } + + if (!done) { + timer = setTimeout(check, checkIntervalMs); + } + }; + + check(); + + return { stop }; + } + + public static debounce void>( + delayMs: number, + fn: T + ): T { + return debounce(fn, delayMs) as unknown as T; + } +} diff --git a/packages/tosu/src/api/types/v2.ts b/packages/tosu/src/api/types/v2.ts index 9f778ae5..f4d485ca 100644 --- a/packages/tosu/src/api/types/v2.ts +++ b/packages/tosu/src/api/types/v2.ts @@ -1,5 +1,5 @@ -import { IRankedPlay } from '@/memory/types'; -import { CalculateMods } from '@/utils/osuMods.types'; +import type { IRankedPlay } from '@/memory/types'; +import type { CalculateMods } from '@/utils/osuMods.types'; export type ApiAnswer = TosuAPi | { error?: string }; export type ApiAnswerPrecise = TosuPreciseAnswer | { error?: string }; diff --git a/packages/tosu/src/instances/manager.ts b/packages/tosu/src/instances/manager.ts index 6f4622ed..1cacb764 100644 --- a/packages/tosu/src/instances/manager.ts +++ b/packages/tosu/src/instances/manager.ts @@ -13,6 +13,10 @@ import { ChildProcess } from 'node:child_process'; import { setTimeout } from 'node:timers/promises'; import { Process } from 'tsprocess'; +import { buildResult } from '@/api/utils/buildResult'; +import { buildResult as buildResultSC } from '@/api/utils/buildResultSC'; +import { buildResult as buildResultV2 } from '@/api/utils/buildResultV2'; +import { buildResult as buildResultV2Precise } from '@/api/utils/buildResultV2Precise'; import type { AbstractInstance } from '@/instances'; import { LazerInstance } from './lazerInstance'; @@ -61,6 +65,22 @@ export class InstanceManager { return Object.values(this.osuInstances)[0]; } + public getState() { + return buildResult(this); + } + + public getStateV2() { + return buildResultV2(this); + } + + public getStateSC() { + return buildResultSC(this); + } + + public getPreciseData() { + return buildResultV2Precise(this); + } + private onProcessDestroy(pid: number) { // FOOL PROTECTION if (!(pid in this.osuInstances)) {