init
Docker image / build-and-push (push) Successful in 2m55s

This commit is contained in:
ab
2026-07-13 15:32:53 +03:00
commit 571707c109
14 changed files with 3184 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.agents
.codex
node_modules
npm-debug.log
.env
.steam-auth.json
.steam-credentials.json
*.log
+52
View File
@@ -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 }}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
.steam-auth.json
.steam-credentials.json
*.log
+22
View File
@@ -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"]
+83
View File
@@ -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"
```
+31
View File
@@ -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;
}
+1084
View File
File diff suppressed because it is too large Load Diff
+579
View File
@@ -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"
}
}
}
+18
View File
@@ -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"
}
}
+119
View File
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Steam Auth Admin</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="topbar">
<div class="title">
<h1>Steam auth admin</h1>
<div class="subline">
<span id="adminStatus" class="badge badge-muted">locked</span>
<a class="admin-link" href="/">Lobby list</a>
</div>
</div>
<div class="metrics" aria-label="Auth status">
<div class="metric wide">
<span class="metric-label">credentials</span>
<strong id="credentialsSource">-</strong>
</div>
<div class="metric wide">
<span class="metric-label">refresh token</span>
<strong id="tokenStatus">-</strong>
</div>
<div class="metric wide">
<span class="metric-label">steam</span>
<strong id="steamStatus">-</strong>
</div>
<div class="metric wide">
<span class="metric-label">game coordinator</span>
<strong id="gcStatus">-</strong>
</div>
</div>
</header>
<main class="admin-main">
<section id="loginPanel" class="auth-panel">
<h2>Admin password</h2>
<div class="admin-grid">
<label>
<span>password</span>
<input id="adminPassword" type="password" autocomplete="current-password">
</label>
<div class="actions">
<button id="unlockBtn" class="primary" type="button">Unlock</button>
</div>
</div>
</section>
<p id="adminError" class="error" hidden></p>
<p id="adminMessage" class="notice" hidden></p>
<section id="credentialsPanel" class="auth-panel" hidden>
<h2>Steam credentials</h2>
<div class="admin-grid">
<label>
<span>account</span>
<input id="steamAccount" type="text" autocomplete="username">
</label>
<label>
<span>password</span>
<input id="steamPassword" type="text" autocomplete="current-password">
</label>
<label class="check">
<input id="clearToken" type="checkbox" checked>
<span>reset saved refresh token</span>
</label>
<div class="actions">
<button id="reloadBtn" type="button">Reload</button>
<button id="saveBtn" class="primary" type="button">Save and reconnect</button>
</div>
</div>
</section>
<section id="guardPanel" class="auth-panel" hidden>
<h2>Steam Guard</h2>
<div class="admin-grid">
<label>
<span>code</span>
<input id="steamGuardCode" type="text" autocomplete="one-time-code" inputmode="numeric">
</label>
<div class="actions">
<button id="submitGuardBtn" class="primary" type="button">Submit code</button>
</div>
</div>
<p id="guardHint" class="hint"></p>
</section>
<section id="tokenPanel" class="auth-panel" hidden>
<h2>Saved token</h2>
<dl class="details">
<div>
<dt>SteamID</dt>
<dd id="tokenSteamId">-</dd>
</div>
<div>
<dt>Account</dt>
<dd id="tokenAccount">-</dd>
</div>
<div>
<dt>Updated</dt>
<dd id="tokenUpdated">-</dd>
</div>
<div>
<dt>Expires</dt>
<dd id="tokenExpires">-</dd>
</div>
</dl>
<div class="actions left">
<button id="clearTokenBtn" type="button">Clear refresh token</button>
</div>
</section>
</main>
<script src="/admin.js"></script>
</body>
</html>
+228
View File
@@ -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);
+326
View File
@@ -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;
});
+110
View File
@@ -0,0 +1,110 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dota 2 Lobbies</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="topbar">
<div class="title">
<h1>Dota 2 lobbies</h1>
<div class="subline">
<span id="statusBadge" class="badge badge-muted">starting</span>
<span id="serverFilter">GC: Auto</span>
<a class="admin-link" href="/admin">Admin auth</a>
</div>
</div>
<div class="metrics" aria-label="Status">
<div class="metric">
<span class="metric-label">shown</span>
<strong id="visibleCount">0</strong>
</div>
<div class="metric">
<span class="metric-label">total</span>
<strong id="totalCount">0</strong>
</div>
<div class="metric wide">
<span class="metric-label">last update</span>
<strong id="lastUpdated">-</strong>
</div>
<div class="metric wide">
<span class="metric-label">next update</span>
<strong id="nextRefresh">-</strong>
</div>
</div>
</header>
<main>
<section class="filters" aria-label="Filters">
<label>
<span>search</span>
<input id="qFilter" type="search" autocomplete="off" placeholder="game, map, leader">
</label>
<label>
<span>game</span>
<input id="gameFilter" type="search" autocomplete="off" placeholder="hero clash">
</label>
<label>
<span>map</span>
<input id="mapFilter" type="search" autocomplete="off" placeholder="ffa">
</label>
<label>
<span>region</span>
<select id="regionFilter"></select>
</label>
<label>
<span>password</span>
<select id="passFilter">
<option value="all">any</option>
<option value="no">no password</option>
<option value="yes">has password</option>
</select>
</label>
<label>
<span>sort</span>
<select id="sortBy">
<option value="players-desc">most players</option>
<option value="open-desc">most open slots</option>
<option value="game-asc">game A-Z</option>
<option value="region-asc">region A-Z</option>
</select>
</label>
<label class="check">
<input id="openOnly" type="checkbox">
<span>open slots</span>
</label>
<div class="actions">
<button id="resetBtn" type="button">Reset</button>
<button id="refreshBtn" type="button" class="primary">Refresh</button>
</div>
</section>
<p id="errorBox" class="error" hidden></p>
<section class="table-wrap" aria-label="Lobbies">
<table>
<thead>
<tr>
<th>Game</th>
<th>Lobby</th>
<th>Map</th>
<th class="num">Players</th>
<th>Region</th>
<th>Password</th>
<th>Leader</th>
</tr>
</thead>
<tbody id="lobbyRows">
<tr>
<td colspan="7" class="empty">Loading...</td>
</tr>
</tbody>
</table>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+517
View File
@@ -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;
}
}