From 571707c1098a369550c0caa597d74dfdb6a929ab Mon Sep 17 00:00:00 2001 From: AB Date: Mon, 13 Jul 2026 15:32:53 +0300 Subject: [PATCH] init --- .dockerignore | 10 + .github/workflows/docker-image.yml | 52 ++ .gitignore | 5 + Dockerfile | 22 + README.md | 83 +++ dota.proto | 31 + index.js | 1084 ++++++++++++++++++++++++++++ package-lock.json | 579 +++++++++++++++ package.json | 18 + public/admin.html | 119 +++ public/admin.js | 228 ++++++ public/app.js | 326 +++++++++ public/index.html | 110 +++ public/styles.css | 517 +++++++++++++ 14 files changed, 3184 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-image.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 dota.proto create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d3ed0f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.agents +.codex +node_modules +npm-debug.log +.env +.steam-auth.json +.steam-credentials.json +*.log diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..a6ef797 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,52 @@ +name: Docker image + +on: + push: + branches: + - main + - master + tags: + - 'v*' + workflow_dispatch: + +env: + IMAGE_NAME: ultradesu/doka2-lobby-list + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=tag + type=sha,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1968c8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +.steam-auth.json +.steam-credentials.json +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a69fdf3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine + +ENV NODE_ENV=production \ + PORT=3000 \ + STEAM_AUTH_FILE=/data/.steam-auth.json \ + STEAM_CREDENTIALS_FILE=/data/.steam-credentials.json + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . + +RUN mkdir -p /data && chown -R node:node /app /data + +USER node + +EXPOSE 3000 +VOLUME ["/data"] + +CMD ["node", "index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c6e53e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# doka-lobby + +Web UI for Dota 2 joinable custom lobbies from the game coordinator. + +The app keeps the latest lobby snapshot in memory, refreshes it every 3 minutes, and streams updates to the browser. There is no database. Steam account credentials and the refresh token can be persisted on a PVC. + +## Local run + +```powershell +npm install +$env:AUTH_ADMIN_PASSWORD = "admin-password" +npm start +``` + +Open: + +```text +http://localhost:3000 +``` + +Use `/admin` to enter the admin password, save Steam credentials, and submit a Steam Guard code when Steam asks for it. + +## Kubernetes configuration + +Mount a PVC to `/data` and set: + +```yaml +env: + - name: AUTH_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: doka-lobby-admin + key: password + - name: STEAM_AUTH_FILE + value: /data/.steam-auth.json + - name: STEAM_CREDENTIALS_FILE + value: /data/.steam-credentials.json + - name: PORT + value: "3000" +volumeMounts: + - name: data + mountPath: /data +``` + +Run one replica only. A single Steam account should not be used by multiple pods at once. + +## Auth files + +- `.steam-credentials.json` stores the Steam account name and password saved from `/admin`. +- `.steam-auth.json` stores the Steam refresh token emitted by `steam-user`. +- Both files are ignored by git and Docker build context. + +If the refresh token is still valid, restarts use it. If it is missing or expired, the app falls back to the saved Steam account/password and may ask for Steam Guard again. + +## Docker image CI + +GitHub Actions builds and pushes: + +```text +ultradesu/doka2-lobby-list +``` + +Configure repository secrets: + +- `DOCKERHUB_USERNAME` +- `DOCKERHUB_TOKEN` + +The workflow publishes `latest` on the default branch, branch/tag refs, and `sha-*` tags. + +## Options + +```powershell +node index.js --port 3001 +node index.js --region 3 +node index.js --game-id 123456789 +node index.js --refresh-ms 180000 +``` + +One-shot console mode: + +```powershell +node index.js --once --map "ffa" +``` diff --git a/dota.proto b/dota.proto new file mode 100644 index 0000000..088bd21 --- /dev/null +++ b/dota.proto @@ -0,0 +1,31 @@ +syntax = "proto2"; + +// Minimal subset of Dota 2 GC protobufs (from SteamDatabase/GameTracking-Dota2) + +message CMsgClientHello { + optional uint32 version = 1; + optional uint32 client_session_need = 3; + optional int32 engine = 5; // 1 = Source 2 +} + +message CMsgJoinableCustomLobbiesRequest { + optional uint32 server_region = 1; + optional uint64 custom_game_id = 2; +} + +message CMsgJoinableCustomLobbiesResponseEntry { + optional fixed64 lobby_id = 1; + optional uint64 custom_game_id = 2; + optional string lobby_name = 3; + optional uint32 member_count = 4; + optional uint32 leader_account_id = 5; + optional string leader_name = 6; + optional string custom_map_name = 7; + optional uint32 max_player_count = 8; + optional uint32 server_region = 9; + optional bool has_pass_key = 11; +} + +message CMsgJoinableCustomLobbiesResponse { + repeated CMsgJoinableCustomLobbiesResponseEntry lobbies = 1; +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..66a4c91 --- /dev/null +++ b/index.js @@ -0,0 +1,1084 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const { EventEmitter } = require('events'); + +const SteamUser = require('steam-user'); +const protobuf = require('protobufjs'); + +const DOTA_APPID = 570; +const DEFAULT_PORT = 3000; +const DEFAULT_REFRESH_MS = 3 * 60 * 1000; +const READY_TIMEOUT_MS = 60 * 1000; +const LOBBY_TIMEOUT_MS = 30 * 1000; +const AUTH_FILE = process.env.STEAM_AUTH_FILE || path.join(__dirname, '.steam-auth.json'); +const CREDENTIALS_FILE = process.env.STEAM_CREDENTIALS_FILE || path.join(path.dirname(AUTH_FILE), '.steam-credentials.json'); +const ADMIN_PASSWORD = process.env.AUTH_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || ''; + +const MSG = { + GCClientWelcome: 4004, + GCClientHello: 4006, + JoinableCustomLobbiesRequest: 7468, + JoinableCustomLobbiesResponse: 7469, +}; + +const REGIONS = { + 0: 'Auto', + 1: 'US West', + 2: 'US East', + 3: 'Europe', + 5: 'Singapore', + 6: 'Dubai', + 7: 'Australia', + 8: 'Stockholm', + 9: 'Austria', + 10: 'Brazil', + 11: 'South Africa', + 14: 'Chile', + 15: 'Peru', + 16: 'India', + 19: 'Japan', + 37: 'Taiwan', + 38: 'Argentina', +}; + +const args = process.argv.slice(2); + +function hasArg(name) { + return args.includes(name); +} + +function argVal(name) { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +} + +function numberOption(name, envName, fallback) { + const raw = argVal(name) ?? process.env[envName]; + if (raw === undefined) return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function printUsage() { + console.log(`Usage: + node index.js [--port ] [--region ] [--game-id ] + +Options: + --port Web UI port, default ${DEFAULT_PORT} + --region GC server region, default 0 (${REGIONS[0]}) + --game-id Workshop id of a custom game, filters server-side + --once One-shot console mode, supports --game and --map filters + --refresh-ms Background refresh interval, default ${DEFAULT_REFRESH_MS} + +Environment: + AUTH_ADMIN_PASSWORD Password required for /admin auth editing + STEAM_AUTH_FILE Refresh-token file, default ${AUTH_FILE} + STEAM_CREDENTIALS_FILE Steam account/password file, default ${CREDENTIALS_FILE} + STEAM_ACCOUNT / STEAM_PASSWORD Optional fallback credentials + +Regions: + ${Object.entries(REGIONS).map(([k, v]) => `${k}=${v}`).join(', ')}`); +} + +if (hasArg('--help') || hasArg('-h')) { + printUsage(); + process.exit(0); +} + +const port = numberOption('--port', 'PORT', DEFAULT_PORT); +const refreshIntervalMs = numberOption('--refresh-ms', 'REFRESH_MS', DEFAULT_REFRESH_MS); +const serverRegion = numberOption('--region', 'DOTA_REGION', 0); +const customGameId = argVal('--game-id') ?? process.env.DOTA_CUSTOM_GAME_ID; +const oneShot = hasArg('--once'); +const initialGameFilter = argVal('--game')?.toLowerCase(); +const initialMapFilter = argVal('--map')?.toLowerCase(); + +const proto = protobuf.loadSync(path.join(__dirname, 'dota.proto')); +const Hello = proto.lookupType('CMsgClientHello'); +const LobbiesReq = proto.lookupType('CMsgJoinableCustomLobbiesRequest'); +const LobbiesResp = proto.lookupType('CMsgJoinableCustomLobbiesResponse'); + +function ensureParentDir(file) { + fs.mkdirSync(path.dirname(file), { recursive: true }); +} + +class JsonStore { + constructor(file) { + this.file = file; + } + + load() { + try { + const raw = fs.readFileSync(this.file, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err.code !== 'ENOENT') { + console.warn(`warn: could not read ${this.file}: ${err.message}`); + } + return null; + } + } + + save(data) { + ensureParentDir(this.file); + const payload = { + ...data, + updatedAt: new Date().toISOString(), + }; + fs.writeFileSync(this.file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + } + + remove() { + try { + fs.unlinkSync(this.file); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + } +} + +class AuthStore extends JsonStore { + load() { + const parsed = super.load(); + if (!parsed || typeof parsed.refreshToken !== 'string') return null; + return parsed; + } +} + +class CredentialsStore extends JsonStore { + load() { + const parsed = super.load(); + if (!parsed || typeof parsed.accountName !== 'string' || typeof parsed.password !== 'string') return null; + return parsed; + } +} + +function decodeJwtPayload(token) { + const [, payload] = String(token).split('.'); + if (!payload) return null; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + try { + return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')); + } catch { + return null; + } +} + +function tokenExpiresSoon(token) { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return false; + return payload.exp * 1000 < Date.now() + 24 * 60 * 60 * 1000; +} + +function tokenExpiresAt(token) { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return null; + return new Date(payload.exp * 1000).toISOString(); +} + +function resolveCredentials(credentialsStore) { + const stored = credentialsStore.load(); + if (stored?.accountName && stored?.password) { + return { + accountName: stored.accountName, + password: stored.password, + source: 'file', + updatedAt: stored.updatedAt || null, + }; + } + + if (process.env.STEAM_ACCOUNT && process.env.STEAM_PASSWORD) { + return { + accountName: process.env.STEAM_ACCOUNT, + password: process.env.STEAM_PASSWORD, + source: 'env', + updatedAt: null, + }; + } + + return { + accountName: '', + password: '', + source: 'missing', + updatedAt: null, + }; +} + +function createLogOnDetails({ authStore, accountName, accountPassword, forcePassword = false }) { + const auth = authStore.load(); + const tokenBelongsToAccount = !accountName || !auth?.accountName || auth.accountName === accountName; + const canRefreshWithPassword = Boolean(accountName && accountPassword); + const canUseToken = auth?.refreshToken + && tokenBelongsToAccount + && (!tokenExpiresSoon(auth.refreshToken) || !canRefreshWithPassword); + + if (!forcePassword && canUseToken) { + const details = { + refreshToken: auth.refreshToken, + machineName: 'doka-lobby', + }; + if (auth.steamID) details.steamID = auth.steamID; + + return { + source: 'refresh-token', + details, + }; + } + + if (!accountName || !accountPassword) { + return { source: 'missing', details: null }; + } + + return { + source: 'password', + details: { + accountName, + password: accountPassword, + machineName: 'doka-lobby', + }, + }; +} + +class DotaLobbyClient extends EventEmitter { + constructor({ authStore, credentialsStore }) { + super(); + this.authStore = authStore; + this.credentialsStore = credentialsStore; + this.credentials = resolveCredentials(credentialsStore); + this.user = null; + this.connectionState = 'idle'; + this.gcState = 'idle'; + this.authSource = null; + this.loggedOn = false; + this.loggingOn = false; + this.dotaLaunched = false; + this.gcReady = false; + this.forcePasswordLogin = false; + this.helloTimer = null; + this.reconnectTimer = null; + this.readyWaiters = new Set(); + this.pendingLobbyRequest = null; + this.steamGuardPrompt = null; + this.steamGuardCallback = null; + this.createSteamUser(); + } + + createSteamUser() { + this.user = new SteamUser({ autoRelogin: true, renewRefreshTokens: true }); + this.bindEvents(); + } + + status() { + return { + steam: this.connectionState, + gc: this.gcState, + authSource: this.authSource, + loggedOn: this.loggedOn, + dotaLaunched: this.dotaLaunched, + gcReady: this.gcReady, + credentialsSource: this.credentials.source, + steamGuard: this.steamGuardPrompt, + }; + } + + setConnectionState(state) { + if (this.connectionState === state) return; + this.connectionState = state; + this.emit('change'); + } + + setGcState(state) { + if (this.gcState === state) return; + this.gcState = state; + this.emit('change'); + } + + bindEvents() { + this.user.on('loggedOn', () => { + this.loggingOn = false; + this.loggedOn = true; + this.dotaLaunched = false; + this.gcReady = false; + this.steamGuardCallback = null; + this.steamGuardPrompt = null; + this.clearReconnectTimer(); + this.setConnectionState('online'); + console.log('Logged into Steam, launching Dota 2...'); + this.launchDota(); + this.emit('change'); + }); + + this.user.on('steamGuard', (domain, callback, lastCodeWrong) => { + this.steamGuardCallback = callback; + this.steamGuardPrompt = { + required: true, + domain: domain || null, + lastCodeWrong: Boolean(lastCodeWrong), + requestedAt: new Date().toISOString(), + }; + this.setConnectionState('steam-guard'); + this.emit('change'); + }); + + this.user.on('refreshToken', (refreshToken) => { + const steamID = this.user.steamID?.getSteamID64?.(); + this.authStore.save({ + refreshToken, + steamID, + accountName: this.credentials.accountName, + }); + this.authSource = 'refresh-token'; + console.log(`Saved Steam refresh token to ${this.authStore.file}`); + this.emit('change'); + }); + + this.user.on('disconnected', (eresult, msg) => { + this.loggedOn = false; + this.loggingOn = false; + this.dotaLaunched = false; + this.gcReady = false; + this.steamGuardCallback = null; + this.steamGuardPrompt = null; + this.clearHelloTimer(); + this.rejectReadyWaiters(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`)); + this.rejectLobbyRequest(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`)); + this.setConnectionState('reconnecting'); + this.setGcState('idle'); + this.emit('change'); + }); + + this.user.on('error', (err) => { + console.error('Steam error:', err.message); + this.loggedOn = false; + this.loggingOn = false; + this.dotaLaunched = false; + this.gcReady = false; + this.steamGuardCallback = null; + this.steamGuardPrompt = null; + this.clearHelloTimer(); + this.rejectReadyWaiters(err); + this.rejectLobbyRequest(err); + this.setConnectionState('error'); + this.setGcState('idle'); + + if (this.authSource === 'refresh-token' && this.credentials.accountName && this.credentials.password) { + console.warn('warn: refresh token login failed, falling back to password login.'); + this.forcePasswordLogin = true; + try { + this.authStore.remove(); + } catch (removeErr) { + console.warn(`warn: could not remove auth file: ${removeErr.message}`); + } + } + + this.scheduleReconnect(); + this.emit('change'); + }); + + this.user.on('appQuit', (appid) => { + if (appid !== DOTA_APPID || !this.loggedOn) return; + this.dotaLaunched = false; + this.gcReady = false; + this.clearHelloTimer(); + this.setGcState('reconnecting'); + this.launchDota(true); + }); + + this.user.on('receivedFromGC', (appid, msgType, payload) => { + if (appid !== DOTA_APPID) return; + + if (msgType === MSG.GCClientWelcome) { + this.handleGcWelcome(); + return; + } + + if (msgType === MSG.JoinableCustomLobbiesResponse) { + this.handleLobbyResponse(payload); + } + }); + } + + start() { + if (this.loggedOn || this.loggingOn || this.connectionState === 'reconnecting' || this.steamGuardCallback) return; + + this.credentials = resolveCredentials(this.credentialsStore); + const logOn = createLogOnDetails({ + authStore: this.authStore, + accountName: this.credentials.accountName, + accountPassword: this.credentials.password, + forcePassword: this.forcePasswordLogin, + }); + + if (!logOn.details) { + this.authSource = null; + this.setConnectionState('auth-required'); + throw new Error('Steam credentials are missing. Open Admin auth and save account credentials.'); + } + + this.authSource = logOn.source; + this.loggingOn = true; + this.setConnectionState('connecting'); + console.log(`Logging into Steam using ${logOn.source}...`); + this.user.logOn(logOn.details); + } + + restartWithCredentials({ accountName, password, clearRefreshToken }) { + this.credentialsStore.save({ accountName, password }); + this.credentials = resolveCredentials(this.credentialsStore); + this.forcePasswordLogin = true; + + if (clearRefreshToken) { + this.authStore.remove(); + } + + this.resetSteamUser(); + this.start(); + } + + resetSteamUser() { + this.clearReconnectTimer(); + this.clearHelloTimer(); + this.rejectReadyWaiters(new Error('Steam connection reset.')); + this.rejectLobbyRequest(new Error('Steam connection reset.')); + this.steamGuardCallback = null; + this.steamGuardPrompt = null; + + if (this.user) { + this.user.removeAllListeners(); + try { + if (this.loggedOn || this.loggingOn) this.user.logOff(); + } catch (err) { + console.warn(`warn: could not log off old Steam session: ${err.message}`); + } + } + + this.loggedOn = false; + this.loggingOn = false; + this.dotaLaunched = false; + this.gcReady = false; + this.connectionState = 'idle'; + this.gcState = 'idle'; + this.authSource = null; + this.createSteamUser(); + this.emit('change'); + } + + submitSteamGuardCode(code) { + if (!this.steamGuardCallback) { + throw new Error('Steam Guard code is not currently requested.'); + } + + const callback = this.steamGuardCallback; + this.steamGuardCallback = null; + this.steamGuardPrompt = { + required: false, + submittedAt: new Date().toISOString(), + }; + this.setConnectionState('connecting'); + callback(code); + this.emit('change'); + } + + scheduleReconnect() { + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + try { + this.start(); + } catch (err) { + console.error(err.message); + } + }, 15000); + } + + clearReconnectTimer() { + if (!this.reconnectTimer) return; + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + launchDota(force = false) { + if (!this.loggedOn) return; + if (this.dotaLaunched && !force) return; + try { + this.user.gamesPlayed([DOTA_APPID], force); + this.dotaLaunched = true; + this.setGcState('connecting'); + this.startHelloLoop(); + } catch (err) { + this.setGcState('error'); + this.emit('warning', err); + } + } + + startHelloLoop() { + if (!this.loggedOn || this.gcReady || this.helloTimer) return; + + const sendHello = () => { + if (!this.loggedOn || this.gcReady) return; + try { + this.user.sendToGC(DOTA_APPID, MSG.GCClientHello, {}, Hello.encode({ engine: 1 }).finish()); + } catch (err) { + this.setGcState('error'); + this.emit('warning', err); + } + }; + + sendHello(); + this.helloTimer = setInterval(sendHello, 3000); + } + + clearHelloTimer() { + if (!this.helloTimer) return; + clearInterval(this.helloTimer); + this.helloTimer = null; + } + + handleGcWelcome() { + this.clearHelloTimer(); + this.gcReady = true; + this.setGcState('ready'); + console.log('Connected to Dota 2 game coordinator.'); + + for (const waiter of this.readyWaiters) { + clearTimeout(waiter.timeout); + waiter.resolve(); + } + this.readyWaiters.clear(); + } + + rejectReadyWaiters(err) { + for (const waiter of this.readyWaiters) { + clearTimeout(waiter.timeout); + waiter.reject(err); + } + this.readyWaiters.clear(); + } + + waitForReady(timeoutMs = READY_TIMEOUT_MS) { + if (this.gcReady) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timeout: setTimeout(() => { + this.readyWaiters.delete(waiter); + this.gcReady = false; + this.setGcState('timeout'); + reject(new Error('Dota 2 game coordinator did not become ready in time.')); + }, timeoutMs), + }; + + this.readyWaiters.add(waiter); + + try { + this.start(); + if (this.loggedOn) { + this.launchDota(); + this.startHelloLoop(); + } + } catch (err) { + clearTimeout(waiter.timeout); + this.readyWaiters.delete(waiter); + reject(err); + } + }); + } + + async requestLobbies({ region, gameId }) { + await this.waitForReady(); + + if (this.pendingLobbyRequest) { + throw new Error('A lobby request is already in progress.'); + } + + return new Promise((resolve, reject) => { + const request = { + resolve, + reject, + timeout: setTimeout(() => { + if (this.pendingLobbyRequest !== request) return; + this.pendingLobbyRequest = null; + this.gcReady = false; + this.setGcState('timeout'); + this.clearHelloTimer(); + this.launchDota(true); + reject(new Error('No lobby list response from GC after 30s.')); + }, LOBBY_TIMEOUT_MS), + }; + + this.pendingLobbyRequest = request; + + const payload = { serverRegion: region }; + if (gameId) payload.customGameId = gameId; + + try { + this.user.sendToGC(DOTA_APPID, MSG.JoinableCustomLobbiesRequest, {}, LobbiesReq.encode(payload).finish()); + } catch (err) { + clearTimeout(request.timeout); + this.pendingLobbyRequest = null; + reject(err); + } + }); + } + + handleLobbyResponse(payload) { + const request = this.pendingLobbyRequest; + if (!request) return; + + clearTimeout(request.timeout); + this.pendingLobbyRequest = null; + + try { + const decoded = LobbiesResp.toObject(LobbiesResp.decode(payload), { longs: String }); + request.resolve(decoded.lobbies || []); + } catch (err) { + request.reject(err); + } + } + + rejectLobbyRequest(err) { + if (!this.pendingLobbyRequest) return; + clearTimeout(this.pendingLobbyRequest.timeout); + this.pendingLobbyRequest.reject(err); + this.pendingLobbyRequest = null; + } +} + +const gameNameCache = new Map(); + +async function resolveGameNames(ids) { + const unique = [...new Set(ids.map((id) => String(id || '')).filter(Boolean))]; + const missing = unique.filter((id) => !gameNameCache.has(id)); + + for (let i = 0; i < missing.length; i += 100) { + const chunk = missing.slice(i, i + 100); + const body = new URLSearchParams({ itemcount: String(chunk.length) }); + chunk.forEach((id, index) => body.append(`publishedfileids[${index}]`, id)); + + try { + const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', { + method: 'POST', + body, + }); + const json = await res.json(); + for (const detail of json.response?.publishedfiledetails || []) { + gameNameCache.set(String(detail.publishedfileid), detail.title || String(detail.publishedfileid)); + } + } catch (err) { + console.error(`warn: could not resolve custom game names: ${err.message}`); + for (const id of chunk) gameNameCache.set(id, id); + } + } + + return gameNameCache; +} + +function normalizeLobby(lobby, names) { + const customGameIdValue = String(lobby.customGameId || ''); + const memberCount = Number(lobby.memberCount || 0); + const maxPlayerCount = Number(lobby.maxPlayerCount || 0); + + return { + id: String(lobby.lobbyId || ''), + customGameId: customGameIdValue, + game: names.get(customGameIdValue) || customGameIdValue, + lobby: lobby.lobbyName || '', + map: lobby.customMapName || '', + memberCount, + maxPlayerCount, + openSlots: Math.max(0, maxPlayerCount - memberCount), + players: `${memberCount}/${maxPlayerCount || '?'}`, + regionId: Number(lobby.serverRegion || 0), + region: REGIONS[lobby.serverRegion] || String(lobby.serverRegion || ''), + hasPassKey: Boolean(lobby.hasPassKey), + leader: lobby.leaderName || '', + leaderAccountId: String(lobby.leaderAccountId || ''), + }; +} + +function filterRows(rows, { game, map }) { + return rows + .filter((row) => !game || String(row.game).toLowerCase().includes(game)) + .filter((row) => !map || String(row.map ?? '').toLowerCase().includes(map)); +} + +function tableRows(rows) { + return rows.map((row) => ({ + game: row.game, + lobby: row.lobby, + map: row.map, + players: row.players, + region: row.region, + pass: row.hasPassKey ? 'yes' : '', + leader: row.leader, + })); +} + +const state = { + status: 'starting', + lobbies: [], + totalLobbies: 0, + refreshCount: 0, + refreshInProgress: false, + lastUpdatedAt: null, + lastStartedAt: null, + lastError: null, + nextRefreshAt: null, +}; + +const authStore = new AuthStore(AUTH_FILE); +const credentialsStore = new CredentialsStore(CREDENTIALS_FILE); +const dota = new DotaLobbyClient({ authStore, credentialsStore }); +const sseClients = new Set(); +let refreshTimer = null; +let activeRefresh = null; + +function publicState() { + return { + ...state, + connection: dota.status(), + refreshIntervalMs, + regions: REGIONS, + auth: { + adminConfigured: Boolean(ADMIN_PASSWORD), + credentialsConfigured: Boolean(dota.credentials.accountName && dota.credentials.password), + tokenConfigured: Boolean(authStore.load()?.refreshToken), + }, + serverFilters: { + region: serverRegion, + regionName: REGIONS[serverRegion] || String(serverRegion), + customGameId: customGameId || null, + }, + }; +} + +function adminAuthState() { + const credentials = resolveCredentials(credentialsStore); + const auth = authStore.load(); + return { + adminConfigured: Boolean(ADMIN_PASSWORD), + files: { + authFile: authStore.file, + credentialsFile: credentialsStore.file, + }, + credentials: { + accountName: credentials.accountName, + password: credentials.password, + source: credentials.source, + updatedAt: credentials.updatedAt, + }, + refreshToken: { + exists: Boolean(auth?.refreshToken), + accountName: auth?.accountName || null, + steamID: auth?.steamID || null, + updatedAt: auth?.updatedAt || null, + expiresAt: auth?.refreshToken ? tokenExpiresAt(auth.refreshToken) : null, + }, + connection: dota.status(), + }; +} + +function sendJson(res, statusCode, data) { + const body = JSON.stringify(data); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store', + }); + res.end(body); +} + +function sendText(res, statusCode, text) { + res.writeHead(statusCode, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(text), + }); + res.end(text); +} + +function safeEqual(a, b) { + const left = Buffer.from(String(a || '')); + const right = Buffer.from(String(b || '')); + if (left.length !== right.length) return false; + return crypto.timingSafeEqual(left, right); +} + +function requireAdmin(req, res) { + if (!ADMIN_PASSWORD) { + sendJson(res, 503, { error: 'Admin password is not configured. Set AUTH_ADMIN_PASSWORD.' }); + return false; + } + + const supplied = req.headers['x-admin-password']; + if (!safeEqual(supplied, ADMIN_PASSWORD)) { + sendJson(res, 401, { error: 'Invalid admin password.' }); + return false; + } + + return true; +} + +function readJsonBody(req, maxBytes = 64 * 1024) { + return new Promise((resolve, reject) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + if (Buffer.byteLength(body) > maxBytes) { + reject(new Error('Request body is too large.')); + req.destroy(); + } + }); + req.on('end', () => { + if (!body) { + resolve({}); + return; + } + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('Request body must be valid JSON.')); + } + }); + req.on('error', reject); + }); +} + +function broadcastState() { + const data = `data: ${JSON.stringify(publicState())}\n\n`; + for (const client of sseClients) { + client.write(data); + } +} + +dota.on('change', broadcastState); +dota.on('warning', (err) => { + state.lastError = err.message; + broadcastState(); +}); + +function scheduleNextRefresh(delay = refreshIntervalMs) { + if (refreshTimer) clearTimeout(refreshTimer); + state.nextRefreshAt = new Date(Date.now() + delay).toISOString(); + broadcastState(); + refreshTimer = setTimeout(() => { + refreshTimer = null; + refreshLobbies('timer').catch((err) => { + console.error(err.message); + }); + }, delay); +} + +async function refreshLobbies(reason) { + if (activeRefresh) return activeRefresh; + + activeRefresh = (async () => { + state.status = 'refreshing'; + state.refreshInProgress = true; + state.lastStartedAt = new Date().toISOString(); + state.lastError = null; + state.nextRefreshAt = null; + broadcastState(); + + try { + const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId }); + const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId)); + const lobbies = rawLobbies.map((lobby) => normalizeLobby(lobby, names)); + state.lobbies = lobbies; + state.totalLobbies = rawLobbies.length; + state.refreshCount += 1; + state.lastUpdatedAt = new Date().toISOString(); + state.status = 'ready'; + console.log(`Lobby refresh (${reason}) completed: ${lobbies.length} lobbies.`); + } catch (err) { + state.status = state.lobbies.length ? 'stale' : 'error'; + state.lastError = err.message; + console.error(`Lobby refresh (${reason}) failed: ${err.message}`); + } finally { + state.refreshInProgress = false; + activeRefresh = null; + scheduleNextRefresh(); + broadcastState(); + } + + return publicState(); + })(); + + return activeRefresh; +} + +function serveStatic(req, res) { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const files = { + '/': ['index.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/admin': ['admin.html', 'text/html; charset=utf-8'], + '/admin/': ['admin.html', 'text/html; charset=utf-8'], + '/admin.js': ['admin.js', 'text/javascript; charset=utf-8'], + }; + + const match = files[url.pathname]; + if (!match) return false; + + const [file, contentType] = match; + const filePath = path.join(__dirname, 'public', file); + fs.readFile(filePath, (err, data) => { + if (err) { + sendText(res, 500, err.message); + return; + } + + res.writeHead(200, { + 'Content-Type': contentType, + 'Cache-Control': 'no-store', + 'Content-Length': data.length, + }); + res.end(data); + }); + return true; +} + +function createServer() { + return http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + + if (req.method === 'GET' && url.pathname === '/api/state') { + sendJson(res, 200, publicState()); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/refresh') { + try { + const snapshot = await refreshLobbies('manual'); + sendJson(res, 200, snapshot); + } catch (err) { + sendJson(res, 500, { error: err.message }); + } + return; + } + + if (url.pathname.startsWith('/api/admin/')) { + if (!requireAdmin(req, res)) return; + + try { + if (req.method === 'GET' && url.pathname === '/api/admin/auth') { + sendJson(res, 200, adminAuthState()); + return; + } + + if (req.method === 'PUT' && url.pathname === '/api/admin/auth') { + const body = await readJsonBody(req); + const accountName = String(body.accountName || '').trim(); + const password = String(body.password || ''); + if (!accountName || !password) { + sendJson(res, 400, { error: 'Steam account and password are required.' }); + return; + } + + dota.restartWithCredentials({ + accountName, + password, + clearRefreshToken: body.clearRefreshToken !== false, + }); + + refreshLobbies('auth-update').catch((err) => { + console.error(err.message); + }); + sendJson(res, 200, adminAuthState()); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/admin/steam-guard') { + const body = await readJsonBody(req); + const code = String(body.code || '').trim(); + if (!code) { + sendJson(res, 400, { error: 'Steam Guard code is required.' }); + return; + } + + dota.submitSteamGuardCode(code); + refreshLobbies('steam-guard').catch((err) => { + console.error(err.message); + }); + sendJson(res, 200, adminAuthState()); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/admin/clear-token') { + authStore.remove(); + dota.forcePasswordLogin = true; + sendJson(res, 200, adminAuthState()); + return; + } + } catch (err) { + sendJson(res, 500, { error: err.message }); + return; + } + + sendText(res, 404, 'Not found'); + return; + } + + if (req.method === 'GET' && url.pathname === '/events') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write(`data: ${JSON.stringify(publicState())}\n\n`); + const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 25000); + sseClients.add(res); + req.on('close', () => { + clearInterval(heartbeat); + sseClients.delete(res); + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { ok: true, status: state.status, connection: dota.status() }); + return; + } + + if (req.method === 'GET' && serveStatic(req, res)) return; + + sendText(res, 404, 'Not found'); + }); +} + +async function runOnce() { + try { + const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId }); + const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId)); + const rows = rawLobbies.map((lobby) => normalizeLobby(lobby, names)); + const matched = filterRows(rows, { game: initialGameFilter, map: initialMapFilter }); + console.log(`\n${matched.length} of ${rows.length} lobbies match:`); + if (matched.length) console.table(tableRows(matched)); + process.exit(0); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} + +if (oneShot) { + runOnce(); +} else { + const server = createServer(); + server.listen(port, () => { + console.log(`Dota lobby web UI: http://localhost:${port}`); + if (!ADMIN_PASSWORD) { + console.warn('warn: AUTH_ADMIN_PASSWORD is not set; Steam auth cannot be edited from the web UI.'); + } + refreshLobbies('startup').catch((err) => { + console.error(err.message); + }); + }); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ab3e95a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,579 @@ +{ + "name": "doka-lobby", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "doka-lobby", + "version": "1.0.0", + "dependencies": { + "protobufjs": "^7.4.0", + "steam-user": "^5.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@bbob/parser": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@bbob/parser/-/parser-2.9.0.tgz", + "integrity": "sha512-tldSYsMoEclke/B1nqL7+HbYMWZHTKvpbEHRSHuY+sZvS1o7Jpdfjb+KPpwP9wLI3p3r7GPv69/wGy+Xibs9yA==", + "license": "MIT", + "dependencies": { + "@bbob/plugin-helper": "^2.9.0" + } + }, + "node_modules/@bbob/plugin-helper": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@bbob/plugin-helper/-/plugin-helper-2.9.0.tgz", + "integrity": "sha512-idpUcNQ2co6T1oU/7/DG/ZRfipSSkTn9Ozw9f5vaXH7nzV3qhqZnhFVlHTzGGnRlzKlBwWOBzOdWi4Zeqg1c5A==", + "license": "MIT" + }, + "node_modules/@doctormckay/stdlib": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-2.10.0.tgz", + "integrity": "sha512-bwy+gPn6oa2KTpfxJKX3leZoV/wHDVtO0/gq3usPvqPswG//dcf3jVB8LcbRRsKO3BXCt5DqctOQ+Xb07ivxnw==", + "license": "MIT", + "dependencies": { + "psl": "^1.9.0" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@doctormckay/steam-crypto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@doctormckay/steam-crypto/-/steam-crypto-1.2.0.tgz", + "integrity": "sha512-lsxgLw640gEdZBOXpVIcYWcYD+V+QbtEsMPzRvjmjz2XXKc7QeEMyHL07yOFRmay+cUwO4ObKTJO0dSInEuq5g==", + "license": "MIT" + }, + "node_modules/@doctormckay/user-agents": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@doctormckay/user-agents/-/user-agents-1.0.0.tgz", + "integrity": "sha512-F+sL1YmebZTY2CnjoR9BXFEULpq7y8dxyLx48LZVa0BSDseXdLG/DtPISfM1iNv1XKCeiBzVNfAT/MOQ69v1Zw==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/binarykvparser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binarykvparser/-/binarykvparser-2.3.0.tgz", + "integrity": "sha512-B1N5ZxC8I9oSLis7Rg36DxsZJoIikUGU2XwpI0FKFCaPIJIEYi0B9UeIk3QU006axzq0TI9KC3iXelfGGgnWew==", + "bundleDependencies": [ + "long" + ], + "license": "MIT", + "dependencies": { + "long": "^3.2.0" + } + }, + "node_modules/bytebuffer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-5.0.1.tgz", + "integrity": "sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ==", + "license": "Apache-2.0", + "dependencies": { + "long": "~3" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/bytebuffer/node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/file-manager": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/file-manager/-/file-manager-2.0.1.tgz", + "integrity": "sha512-y/K/1OCha04OXOxzo3cXJYtIzEk/CUMBb7Okipxueu0u+xCiuoocbwPyh1smUBasOobo4GAYmjgjD9Vh5zI51w==", + "license": "MIT", + "dependencies": { + "@doctormckay/stdlib": "^1.14.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/file-manager/node_modules/@doctormckay/stdlib": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-1.16.1.tgz", + "integrity": "sha512-XhuUOzElz6fnNdt70IYNKqhPAEpGaL4JHOhAvklRh0hAhVPW+/wLxaWT3DWUbaG5Dta5YvIp7+cZK3GhIpAuug==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/kvparser": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/kvparser/-/kvparser-1.0.2.tgz", + "integrity": "sha512-5P/5qpTAHjVYWqcI55B3yQwSY2FUrYYrJj5i65V1Wmg7/4W4OnBcaodaEvLyVuugeOnS+BAaKm9LbPazGJcRyA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lzma": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/lzma/-/lzma-2.3.2.tgz", + "integrity": "sha512-DcfiawQ1avYbW+hsILhF38IKAlnguc/fjHrychs9hdxe4qLykvhT5VTGNs5YRWgaNePh7NTxGD4uv4gKsRomCQ==", + "license": "MIT", + "bin": { + "lzma.js": "bin/lzma.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-bignumber": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/node-bignumber/-/node-bignumber-1.2.2.tgz", + "integrity": "sha512-VoTZHmdFQpZH1+q1dz2qcHNCwTWsJg2T3PYwlAyDNFOfVhSYUKQBLFcCpCud+wJBGgCttGavZILaIggDIKqEQQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/permessage-deflate": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/permessage-deflate/-/permessage-deflate-0.1.7.tgz", + "integrity": "sha512-EUNi/RIsyJ1P1u9QHFwMOUWMYetqlE22ZgGbad7YP856WF4BFF0B7DuNy6vEGsgNNud6c/SkdWzkne71hH8MjA==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "*" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/steam-appticket": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/steam-appticket/-/steam-appticket-1.0.2.tgz", + "integrity": "sha512-zwDwZALGv3RanE8RHNYcQU3u4Ez23EzMuQ4Lh15uIHddpDh6TI6uFGbC0HNyt6y+UJYSILe77A33VhFZKQiaqQ==", + "license": "MIT", + "dependencies": { + "@doctormckay/stdlib": "^1.6.0", + "@doctormckay/steam-crypto": "^1.2.0", + "bytebuffer": "^5.0.1", + "protobufjs": "^6.8.8", + "steamid": "^1.1.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/steam-appticket/node_modules/@doctormckay/stdlib": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-1.16.1.tgz", + "integrity": "sha512-XhuUOzElz6fnNdt70IYNKqhPAEpGaL4JHOhAvklRh0hAhVPW+/wLxaWT3DWUbaG5Dta5YvIp7+cZK3GhIpAuug==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/steam-appticket/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/steam-appticket/node_modules/protobufjs": { + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/steam-appticket/node_modules/steamid": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steamid/-/steamid-1.1.3.tgz", + "integrity": "sha512-t86YjtP1LtPt8D+TaIARm6PtC9tBnF1FhxQeLFs6ohG7vDUfQuy/M8II14rx1TTUkVuYoWHP/7DlvTtoCGULcw==", + "license": "MIT", + "dependencies": { + "cuint": "^0.2.1" + } + }, + "node_modules/steam-session": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/steam-session/-/steam-session-1.9.4.tgz", + "integrity": "sha512-MLvg1uMLEOIRHZS5LKruy1w5OqHb8EL7TeMxIY2a4mUcaVOgszz060+jo4c7s3gFeedyuoqGcfwJrR0pLKYzLw==", + "license": "MIT", + "dependencies": { + "@doctormckay/stdlib": "^2.9.0", + "@doctormckay/user-agents": "^1.0.0", + "debug": "^4.3.4", + "kvparser": "^1.0.1", + "node-bignumber": "^1.2.2", + "protobufjs": "^7.1.0", + "socks-proxy-agent": "^7.0.0", + "steamid": "^2.0.0", + "tiny-typed-emitter": "^2.1.0", + "websocket13": "^4.0.0" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/steam-totp": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/steam-totp/-/steam-totp-2.1.2.tgz", + "integrity": "sha512-bTKlc/NoIUQId+my+O556s55DDsNNXfVIPWFDNVu68beql7AJhV0c+GTjFxfwCDYfdc4NkAme+0WrDdnY2D2VA==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/steam-user": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/steam-user/-/steam-user-5.3.0.tgz", + "integrity": "sha512-/92MOZGIocixlgzjloXrDffbAL5sF9rz4sbsafZ73samhkv2ITQNJrKaKGCJbAq5Xz8CbtCzstdjH8qXawLJVg==", + "license": "MIT", + "dependencies": { + "@bbob/parser": "^2.2.0", + "@doctormckay/stdlib": "^2.9.1", + "@doctormckay/steam-crypto": "^1.2.0", + "adm-zip": "^0.5.10", + "binarykvparser": "^2.2.0", + "bytebuffer": "^5.0.0", + "file-manager": "^2.0.0", + "kvparser": "^1.0.1", + "lzma": "^2.3.2", + "protobufjs": "^7.2.4", + "socks-proxy-agent": "^7.0.0", + "steam-appticket": "^1.0.1", + "steam-session": "^1.8.0", + "steam-totp": "^2.0.1", + "steamid": "^2.0.0", + "websocket13": "^4.0.0", + "zstddec": "^0.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "lzma-native": "^8.0.0" + }, + "peerDependenciesMeta": { + "lzma-native": { + "optional": true + } + } + }, + "node_modules/steamid": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/steamid/-/steamid-2.1.0.tgz", + "integrity": "sha512-ndt1cvuuSC+i8fcxVsmeyRlgGsR1QsoAuIXz+eabj8/Y4GIWE2+mgHA7Hys61JDHOxttfWtXHtN2m5TNYTlORg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket13": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/websocket13/-/websocket13-4.1.0.tgz", + "integrity": "sha512-7+hxkUVTKQlUDTzN2rJI7fJRBXCT6dvRXr1aZflxUZlpNJutHBkKiEIbZOCGs0A1s7vxAmcAXngsNUQMSUTiVQ==", + "license": "MIT", + "dependencies": { + "@doctormckay/stdlib": "^2.7.1", + "bytebuffer": "^5.0.1", + "permessage-deflate": "^0.1.7", + "tiny-typed-emitter": "^2.1.0", + "websocket-extensions": "^0.1.4" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/zstddec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", + "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==", + "license": "MIT AND BSD-3-Clause" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e0d446a --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "doka-lobby", + "version": "1.0.0", + "private": true, + "description": "List joinable Dota 2 custom game lobbies, filtered by game name / map", + "main": "index.js", + "scripts": { + "start": "node index.js", + "once": "node index.js --once" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "protobufjs": "^7.4.0", + "steam-user": "^5.2.0" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..7123d4f --- /dev/null +++ b/public/admin.html @@ -0,0 +1,119 @@ + + + + + + Steam Auth Admin + + + +
+
+

Steam auth admin

+
+ locked + Lobby list +
+
+
+
+ credentials + - +
+
+ refresh token + - +
+
+ steam + - +
+
+ game coordinator + - +
+
+
+ +
+
+

Admin password

+
+ +
+ +
+
+
+ + + + + + + + + +
+ + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..7e49e85 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,228 @@ +const els = { + adminStatus: document.querySelector('#adminStatus'), + credentialsSource: document.querySelector('#credentialsSource'), + tokenStatus: document.querySelector('#tokenStatus'), + steamStatus: document.querySelector('#steamStatus'), + gcStatus: document.querySelector('#gcStatus'), + loginPanel: document.querySelector('#loginPanel'), + credentialsPanel: document.querySelector('#credentialsPanel'), + guardPanel: document.querySelector('#guardPanel'), + tokenPanel: document.querySelector('#tokenPanel'), + adminPassword: document.querySelector('#adminPassword'), + unlockBtn: document.querySelector('#unlockBtn'), + adminError: document.querySelector('#adminError'), + adminMessage: document.querySelector('#adminMessage'), + steamAccount: document.querySelector('#steamAccount'), + steamPassword: document.querySelector('#steamPassword'), + clearToken: document.querySelector('#clearToken'), + reloadBtn: document.querySelector('#reloadBtn'), + saveBtn: document.querySelector('#saveBtn'), + steamGuardCode: document.querySelector('#steamGuardCode'), + submitGuardBtn: document.querySelector('#submitGuardBtn'), + guardHint: document.querySelector('#guardHint'), + tokenSteamId: document.querySelector('#tokenSteamId'), + tokenAccount: document.querySelector('#tokenAccount'), + tokenUpdated: document.querySelector('#tokenUpdated'), + tokenExpires: document.querySelector('#tokenExpires'), + clearTokenBtn: document.querySelector('#clearTokenBtn'), +}; + +let adminPassword = sessionStorage.getItem('dokaAdminPassword') || ''; +let credentialsDirty = false; + +const dateTimeFormat = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +function formatDate(iso) { + if (!iso) return '-'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return '-'; + return dateTimeFormat.format(date); +} + +function setBadge(label, klass) { + els.adminStatus.textContent = label; + els.adminStatus.className = `badge ${klass}`; +} + +function showError(message) { + els.adminError.hidden = !message; + els.adminError.textContent = message || ''; +} + +function showMessage(message) { + els.adminMessage.hidden = !message; + els.adminMessage.textContent = message || ''; +} + +async function adminFetch(url, options = {}) { + const res = await fetch(url, { + ...options, + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Password': adminPassword, + ...(options.headers || {}), + }, + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +function unlocked() { + return Boolean(adminPassword); +} + +function render(data, options = {}) { + const preserveEditedInputs = options.preserveEditedInputs !== false; + const connection = data.connection || {}; + const credentials = data.credentials || {}; + const token = data.refreshToken || {}; + const steamGuard = connection.steamGuard || {}; + + els.loginPanel.hidden = unlocked(); + els.credentialsPanel.hidden = !unlocked(); + els.tokenPanel.hidden = !unlocked(); + els.guardPanel.hidden = !unlocked() || !steamGuard.required; + + setBadge(unlocked() ? 'unlocked' : 'locked', unlocked() ? 'badge-ok' : 'badge-muted'); + els.credentialsSource.textContent = credentials.source || '-'; + els.tokenStatus.textContent = token.exists ? 'saved' : 'missing'; + els.steamStatus.textContent = connection.steam || '-'; + els.gcStatus.textContent = connection.gc || '-'; + + const editingCredentials = document.activeElement === els.steamAccount + || document.activeElement === els.steamPassword + || credentialsDirty; + + if (!preserveEditedInputs || !editingCredentials) { + els.steamAccount.value = credentials.accountName || ''; + els.steamPassword.value = credentials.password || ''; + credentialsDirty = false; + } + + els.tokenSteamId.textContent = token.steamID || '-'; + els.tokenAccount.textContent = token.accountName || '-'; + els.tokenUpdated.textContent = formatDate(token.updatedAt); + els.tokenExpires.textContent = formatDate(token.expiresAt); + + if (steamGuard.required) { + const where = steamGuard.domain ? `Email domain: ${steamGuard.domain}` : 'Mobile authenticator code required'; + els.guardHint.textContent = steamGuard.lastCodeWrong ? `Last code was rejected. ${where}.` : where; + } else { + els.guardHint.textContent = ''; + } +} + +async function loadAuth(options = {}) { + if (!unlocked()) { + render({}); + return; + } + + try { + showError(''); + const data = await adminFetch('/api/admin/auth'); + render(data, options); + } catch (err) { + showError(err.message); + if (err.message.includes('Invalid admin password')) { + adminPassword = ''; + sessionStorage.removeItem('dokaAdminPassword'); + } + render({}); + } +} + +async function saveCredentials() { + try { + showError(''); + showMessage(''); + const data = await adminFetch('/api/admin/auth', { + method: 'PUT', + body: JSON.stringify({ + accountName: els.steamAccount.value, + password: els.steamPassword.value, + clearRefreshToken: els.clearToken.checked, + }), + }); + credentialsDirty = false; + render(data); + showMessage('Credentials saved. Reconnect started.'); + } catch (err) { + showError(err.message); + } +} + +async function submitSteamGuard() { + try { + showError(''); + showMessage(''); + const data = await adminFetch('/api/admin/steam-guard', { + method: 'POST', + body: JSON.stringify({ code: els.steamGuardCode.value }), + }); + els.steamGuardCode.value = ''; + render(data); + showMessage('Steam Guard code submitted.'); + } catch (err) { + showError(err.message); + } +} + +async function clearRefreshToken() { + try { + showError(''); + showMessage(''); + const data = await adminFetch('/api/admin/clear-token', { method: 'POST', body: '{}' }); + render(data); + showMessage('Refresh token cleared.'); + } catch (err) { + showError(err.message); + } +} + +els.unlockBtn.addEventListener('click', () => { + adminPassword = els.adminPassword.value; + sessionStorage.setItem('dokaAdminPassword', adminPassword); + loadAuth({ preserveEditedInputs: false }); +}); + +els.adminPassword.addEventListener('keydown', (event) => { + if (event.key === 'Enter') els.unlockBtn.click(); +}); + +els.reloadBtn.addEventListener('click', () => { + credentialsDirty = false; + loadAuth({ preserveEditedInputs: false }); +}); +els.saveBtn.addEventListener('click', saveCredentials); +els.submitGuardBtn.addEventListener('click', submitSteamGuard); +els.clearTokenBtn.addEventListener('click', clearRefreshToken); + +els.steamAccount.addEventListener('input', () => { + credentialsDirty = true; +}); + +els.steamPassword.addEventListener('input', () => { + credentialsDirty = true; +}); + +els.steamGuardCode.addEventListener('keydown', (event) => { + if (event.key === 'Enter') els.submitGuardBtn.click(); +}); + +if (adminPassword) { + els.adminPassword.value = adminPassword; +} + +loadAuth({ preserveEditedInputs: false }); +setInterval(loadAuth, 5000); diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..647fec0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,326 @@ +const els = { + statusBadge: document.querySelector('#statusBadge'), + serverFilter: document.querySelector('#serverFilter'), + visibleCount: document.querySelector('#visibleCount'), + totalCount: document.querySelector('#totalCount'), + lastUpdated: document.querySelector('#lastUpdated'), + nextRefresh: document.querySelector('#nextRefresh'), + errorBox: document.querySelector('#errorBox'), + rows: document.querySelector('#lobbyRows'), + qFilter: document.querySelector('#qFilter'), + gameFilter: document.querySelector('#gameFilter'), + mapFilter: document.querySelector('#mapFilter'), + regionFilter: document.querySelector('#regionFilter'), + passFilter: document.querySelector('#passFilter'), + sortBy: document.querySelector('#sortBy'), + openOnly: document.querySelector('#openOnly'), + resetBtn: document.querySelector('#resetBtn'), + refreshBtn: document.querySelector('#refreshBtn'), +}; + +let snapshot = null; +let regionsReady = false; + +const dateTimeFormat = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +function text(value) { + return String(value || '').toLowerCase().trim(); +} + +function formatDate(iso) { + if (!iso) return '-'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return '-'; + return dateTimeFormat.format(date); +} + +function formatCountdown(iso) { + if (!iso) return snapshot?.refreshInProgress ? 'refreshing' : '-'; + const ms = new Date(iso).getTime() - Date.now(); + if (!Number.isFinite(ms) || ms <= 0) return snapshot?.refreshInProgress ? 'refreshing' : 'soon'; + const seconds = Math.ceil(ms / 1000); + const minutes = Math.floor(seconds / 60); + const rest = String(seconds % 60).padStart(2, '0'); + return `${minutes}:${rest}`; +} + +function statusInfo(data) { + if (!data) return ['starting', 'badge-muted']; + if (data.refreshInProgress) return ['refreshing', 'badge-work']; + if (data.connection?.steam === 'auth-required') return ['auth required', 'badge-warn']; + if (data.connection?.steam === 'steam-guard') return ['steam guard', 'badge-warn']; + if (data.status === 'ready') return ['online', 'badge-ok']; + if (data.status === 'stale') return ['stale', 'badge-warn']; + if (data.status === 'error') return ['error', 'badge-error']; + if (data.connection?.steam !== 'online' || data.connection?.gc !== 'ready') return ['connecting', 'badge-work']; + return [data.status || 'starting', 'badge-muted']; +} + +function setBadge(label, klass) { + els.statusBadge.textContent = label; + els.statusBadge.className = `badge ${klass}`; +} + +function initRegions(regions) { + if (regionsReady || !regions) return; + const current = els.regionFilter.value || 'all'; + els.regionFilter.replaceChildren(); + + const all = document.createElement('option'); + all.value = 'all'; + all.textContent = 'all'; + els.regionFilter.append(all); + + Object.entries(regions) + .sort(([a], [b]) => Number(a) - Number(b)) + .forEach(([id, name]) => { + const option = document.createElement('option'); + option.value = id; + option.textContent = name; + els.regionFilter.append(option); + }); + + els.regionFilter.value = current; + regionsReady = true; +} + +function filterLobbies(lobbies) { + const q = text(els.qFilter.value); + const game = text(els.gameFilter.value); + const map = text(els.mapFilter.value); + const region = els.regionFilter.value; + const pass = els.passFilter.value; + const openOnly = els.openOnly.checked; + + return lobbies.filter((row) => { + const haystack = text([ + row.game, + row.lobby, + row.map, + row.region, + row.leader, + row.customGameId, + row.leaderAccountId, + ].join(' ')); + + if (q && !haystack.includes(q)) return false; + if (game && !text(row.game).includes(game)) return false; + if (map && !text(row.map).includes(map)) return false; + if (region !== 'all' && String(row.regionId) !== region) return false; + if (pass === 'yes' && !row.hasPassKey) return false; + if (pass === 'no' && row.hasPassKey) return false; + if (openOnly && Number(row.openSlots) <= 0) return false; + return true; + }); +} + +function sortLobbies(rows) { + const sorted = [...rows]; + const byName = (a, b, key) => String(a[key] || '').localeCompare(String(b[key] || ''), 'en'); + + sorted.sort((a, b) => { + if (els.sortBy.value === 'open-desc') { + return Number(b.openSlots) - Number(a.openSlots) || byName(a, b, 'game'); + } + if (els.sortBy.value === 'game-asc') { + return byName(a, b, 'game') || Number(b.memberCount) - Number(a.memberCount); + } + if (els.sortBy.value === 'region-asc') { + return byName(a, b, 'region') || byName(a, b, 'game'); + } + return Number(b.memberCount) - Number(a.memberCount) || byName(a, b, 'game'); + }); + + return sorted; +} + +function makeCell(value, className) { + const td = document.createElement('td'); + if (className) td.className = className; + td.textContent = value || '-'; + return td; +} + +function renderRows(rows, totalLoaded) { + els.rows.replaceChildren(); + + if (!rows.length) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 7; + td.className = 'empty'; + td.textContent = totalLoaded ? 'No lobbies match the selected filters' : 'No data yet'; + tr.append(td); + els.rows.append(tr); + return; + } + + const fragment = document.createDocumentFragment(); + + for (const row of rows) { + const tr = document.createElement('tr'); + + const game = document.createElement('td'); + game.className = 'game'; + const gameName = document.createElement('div'); + gameName.textContent = row.game || row.customGameId || '-'; + game.append(gameName); + if (row.customGameId && row.game !== row.customGameId) { + const id = document.createElement('div'); + id.className = 'muted'; + id.textContent = row.customGameId; + game.append(id); + } + + const players = document.createElement('td'); + players.className = 'num'; + const playersPill = document.createElement('span'); + playersPill.className = 'pill'; + playersPill.textContent = row.players || '-'; + players.append(playersPill); + + const pass = document.createElement('td'); + const passPill = document.createElement('span'); + passPill.className = row.hasPassKey ? 'pill pass' : 'muted'; + passPill.textContent = row.hasPassKey ? 'yes' : 'no'; + pass.append(passPill); + + tr.append( + game, + makeCell(row.lobby), + makeCell(row.map), + players, + makeCell(row.region), + pass, + makeCell(row.leader), + ); + fragment.append(tr); + } + + els.rows.append(fragment); +} + +function render() { + const data = snapshot; + const lobbies = data?.lobbies || []; + const filtered = sortLobbies(filterLobbies(lobbies)); + const [label, klass] = statusInfo(data); + + setBadge(label, klass); + els.visibleCount.textContent = String(filtered.length); + els.totalCount.textContent = String(data?.totalLobbies || lobbies.length || 0); + els.lastUpdated.textContent = formatDate(data?.lastUpdatedAt); + els.nextRefresh.textContent = formatCountdown(data?.nextRefreshAt); + els.refreshBtn.disabled = Boolean(data?.refreshInProgress); + els.refreshBtn.textContent = data?.refreshInProgress ? 'Refreshing' : 'Refresh'; + + if (data?.serverFilters) { + const parts = [`GC: ${data.serverFilters.regionName}`]; + if (data.serverFilters.customGameId) parts.push(`game ${data.serverFilters.customGameId}`); + els.serverFilter.textContent = parts.join(', '); + } + + if (data?.lastError) { + els.errorBox.hidden = false; + els.errorBox.textContent = data.lastError; + } else { + els.errorBox.hidden = true; + els.errorBox.textContent = ''; + } + + renderRows(filtered, lobbies.length); +} + +function setSnapshot(data) { + snapshot = data; + initRegions(data?.regions); + render(); +} + +async function fetchState() { + const res = await fetch('/api/state', { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setSnapshot(await res.json()); +} + +async function refreshNow() { + els.refreshBtn.disabled = true; + try { + const res = await fetch('/api/refresh', { method: 'POST', cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setSnapshot(await res.json()); + } catch (err) { + if (snapshot) { + snapshot.lastError = err.message; + render(); + } else { + els.errorBox.hidden = false; + els.errorBox.textContent = err.message; + } + } finally { + els.refreshBtn.disabled = Boolean(snapshot?.refreshInProgress); + } +} + +function connectEvents() { + if (!window.EventSource) return; + const events = new EventSource('/events'); + events.onmessage = (event) => { + try { + setSnapshot(JSON.parse(event.data)); + } catch (err) { + console.error(err); + } + }; +} + +[ + els.qFilter, + els.gameFilter, + els.mapFilter, + els.regionFilter, + els.passFilter, + els.sortBy, + els.openOnly, +].forEach((el) => { + el.addEventListener('input', render); + el.addEventListener('change', render); +}); + +els.resetBtn.addEventListener('click', () => { + els.qFilter.value = ''; + els.gameFilter.value = ''; + els.mapFilter.value = ''; + els.regionFilter.value = 'all'; + els.passFilter.value = 'all'; + els.sortBy.value = 'players-desc'; + els.openOnly.checked = false; + render(); +}); + +els.refreshBtn.addEventListener('click', refreshNow); + +setInterval(() => { + if (snapshot) els.nextRefresh.textContent = formatCountdown(snapshot.nextRefreshAt); +}, 1000); + +setInterval(() => { + fetchState().catch((err) => { + if (!snapshot) { + els.errorBox.hidden = false; + els.errorBox.textContent = err.message; + } + }); +}, 30000); + +connectEvents(); +fetchState().catch((err) => { + els.errorBox.hidden = false; + els.errorBox.textContent = err.message; +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..ff06cc6 --- /dev/null +++ b/public/index.html @@ -0,0 +1,110 @@ + + + + + + Dota 2 Lobbies + + + +
+
+

Dota 2 lobbies

+
+ starting + GC: Auto + Admin auth +
+
+
+
+ shown + 0 +
+
+ total + 0 +
+
+ last update + - +
+
+ next update + - +
+
+
+ +
+
+ + + + + + + +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + +
GameLobbyMapPlayersRegionPasswordLeader
Loading...
+
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..0f964fe --- /dev/null +++ b/public/styles.css @@ -0,0 +1,517 @@ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --panel-2: #eef2f4; + --text: #18202a; + --muted: #667384; + --line: #d8dee6; + --accent: #246b5a; + --accent-strong: #15513f; + --blue: #275f9f; + --warn: #956a13; + --danger: #a33b36; + --shadow: 0 8px 24px rgba(17, 24, 39, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: var(--bg); + color: var(--text); + font-family: Inter, Segoe UI, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 14px; + line-height: 1.45; +} + +button, +input, +select { + font: inherit; +} + +.topbar { + position: sticky; + top: 0; + z-index: 5; + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(440px, 1.4fr); + gap: 18px; + align-items: center; + padding: 18px 24px; + background: rgba(246, 247, 249, 0.94); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(14px); +} + +.title h1 { + margin: 0 0 8px; + font-size: 24px; + line-height: 1.1; + font-weight: 720; + letter-spacing: 0; +} + +.subline { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + color: var(--muted); +} + +.admin-link { + color: var(--accent-strong); + font-weight: 700; + text-decoration: none; +} + +.admin-link:hover { + text-decoration: underline; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.badge-ok { + border-color: rgba(36, 107, 90, 0.3); + background: #e8f4ef; + color: var(--accent-strong); +} + +.badge-work { + border-color: rgba(39, 95, 159, 0.28); + background: #eaf1fa; + color: var(--blue); +} + +.badge-warn { + border-color: rgba(149, 106, 19, 0.3); + background: #fff3d8; + color: var(--warn); +} + +.badge-error { + border-color: rgba(163, 59, 54, 0.28); + background: #fdecea; + color: var(--danger); +} + +.badge-muted { + background: var(--panel-2); +} + +.metrics { + display: grid; + grid-template-columns: minmax(92px, 0.7fr) minmax(92px, 0.7fr) minmax(150px, 1fr) minmax(150px, 1fr); + gap: 10px; +} + +.metric { + min-width: 0; + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; +} + +.metric-label { + display: block; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.metric strong { + display: block; + overflow: hidden; + margin-top: 2px; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 18px; +} + +.metric.wide strong { + font-size: 14px; +} + +main { + width: min(1600px, 100%); + margin: 0 auto; + padding: 18px 24px 28px; +} + +.filters { + display: grid; + grid-template-columns: minmax(190px, 1.1fr) minmax(160px, 1fr) minmax(140px, 0.8fr) minmax(130px, 0.75fr) minmax(120px, 0.7fr) minmax(150px, 0.8fr) auto auto; + gap: 12px; + align-items: end; + margin-bottom: 14px; + padding: 14px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.filters label { + min-width: 0; +} + +.filters label > span, +.check span { + display: block; + margin-bottom: 5px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.filters input[type='search'], +.filters select { + width: 100%; + height: 36px; + padding: 0 10px; + color: var(--text); + background: #fbfcfd; + border: 1px solid var(--line); + border-radius: 6px; + outline: none; +} + +.filters input:focus, +.filters select:focus { + border-color: rgba(36, 107, 90, 0.55); + box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12); +} + +.check { + display: flex; + gap: 8px; + align-items: center; + min-height: 36px; + padding-bottom: 1px; + white-space: nowrap; +} + +.check span { + margin: 0; +} + +.check input { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; + white-space: nowrap; +} + +button { + height: 36px; + padding: 0 13px; + color: var(--text); + background: #fbfcfd; + border: 1px solid var(--line); + border-radius: 6px; + cursor: pointer; +} + +button:hover { + background: #f0f4f5; +} + +button:disabled { + cursor: wait; + opacity: 0.65; +} + +button.primary { + color: #ffffff; + background: var(--accent); + border-color: var(--accent); +} + +button.primary:hover { + background: var(--accent-strong); +} + +.error { + margin: 0 0 12px; + padding: 10px 12px; + color: var(--danger); + background: #fdecea; + border: 1px solid rgba(163, 59, 54, 0.28); + border-radius: 8px; +} + +.notice { + margin: 0 0 12px; + padding: 10px 12px; + color: var(--accent-strong); + background: #e8f4ef; + border: 1px solid rgba(36, 107, 90, 0.3); + border-radius: 8px; +} + +.admin-main { + max-width: 1100px; +} + +.auth-panel { + margin-bottom: 14px; + padding: 16px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.auth-panel h2 { + margin: 0 0 14px; + font-size: 16px; + line-height: 1.2; +} + +.admin-grid { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto auto; + gap: 12px; + align-items: end; +} + +.admin-grid label { + min-width: 0; +} + +.admin-grid label > span, +.details dt, +.hint { + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.admin-grid input[type='text'], +.admin-grid input[type='password'] { + width: 100%; + height: 36px; + padding: 0 10px; + color: var(--text); + background: #fbfcfd; + border: 1px solid var(--line); + border-radius: 6px; + outline: none; +} + +.admin-grid input:focus { + border-color: rgba(36, 107, 90, 0.55); + box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12); +} + +.details { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 14px; +} + +.details div { + min-width: 0; + padding: 10px 12px; + background: #fbfcfd; + border: 1px solid var(--line); + border-radius: 8px; +} + +.details dt { + margin-bottom: 4px; +} + +.details dd { + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; +} + +.actions.left { + justify-content: flex-start; +} + +.hint { + margin: 12px 0 0; +} + +.table-wrap { + overflow: auto; + max-height: calc(100vh - 214px); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +table { + width: 100%; + min-width: 980px; + border-collapse: separate; + border-spacing: 0; +} + +thead th { + position: sticky; + top: 0; + z-index: 2; + padding: 10px 12px; + color: #3a4654; + background: #eef2f4; + border-bottom: 1px solid var(--line); + font-size: 11px; + font-weight: 800; + text-align: left; + text-transform: uppercase; +} + +tbody td { + padding: 10px 12px; + border-bottom: 1px solid #edf0f3; + vertical-align: middle; +} + +tbody tr:hover { + background: #f7faf9; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.num { + text-align: right; +} + +.game { + max-width: 460px; + font-weight: 700; +} + +.muted { + color: var(--muted); +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 1px 8px; + border-radius: 999px; + background: var(--panel-2); + color: #354150; + font-size: 12px; + font-weight: 700; +} + +.pill.pass { + background: #fff3d8; + color: var(--warn); +} + +.empty { + height: 160px; + color: var(--muted); + text-align: center; +} + +@media (max-width: 1180px) { + .topbar { + grid-template-columns: 1fr; + } + + .metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .filters { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .actions { + justify-content: start; + } + + .admin-grid, + .details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .topbar { + padding: 14px 14px; + } + + main { + padding: 14px; + } + + .metrics, + .filters { + grid-template-columns: 1fr 1fr; + } + + .metric.wide { + grid-column: span 2; + } + + .actions { + grid-column: span 2; + } + + .actions button { + flex: 1; + } + + .table-wrap { + max-height: calc(100vh - 318px); + } + + .admin-grid, + .details { + grid-template-columns: 1fr; + } +} + +@media (max-width: 460px) { + .metrics, + .filters { + grid-template-columns: 1fr; + } + + .metric.wide, + .actions { + grid-column: auto; + } +}