feat: add admin backend and compose deployment
This commit is contained in:
@@ -11,6 +11,8 @@ config.js
|
||||
.env
|
||||
session.json
|
||||
refresh.token
|
||||
auth.sqlite
|
||||
data/
|
||||
|
||||
logs/
|
||||
*.log
|
||||
|
||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
STEAM_CHAT_IMAGE=steam-chat:latest
|
||||
STEAM_CHAT_CONTAINER_NAME=steam-chat
|
||||
STEAM_CHAT_BIND=0.0.0.0
|
||||
STEAM_CHAT_PORT=3000
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,10 +8,12 @@ config.js
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
data/
|
||||
|
||||
# Auth / Secrets
|
||||
session.json
|
||||
refresh.token
|
||||
auth.sqlite
|
||||
|
||||
# Build
|
||||
dist/
|
||||
|
||||
@@ -15,12 +15,15 @@ RUN npm run build \
|
||||
FROM node:26-bookworm-slim AS runtime
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV STEAM_CHAT_DATA_DIR=/app/data
|
||||
ENV STEAM_CHAT_HOST=0.0.0.0
|
||||
ENV STEAM_CHAT_PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
RUN chown node:node /app
|
||||
RUN mkdir -p /app/data \
|
||||
&& chown -R node:node /app
|
||||
|
||||
COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./
|
||||
COPY --from=build --chown=node:node /app/config.example.js ./
|
||||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/dist ./dist
|
||||
|
||||
|
||||
32
compose.yaml
Normal file
32
compose.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
steam-chat:
|
||||
image: ${STEAM_CHAT_IMAGE:-steam-chat:latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${STEAM_CHAT_CONTAINER_NAME:-steam-chat}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
ports:
|
||||
- "${STEAM_CHAT_BIND:-0.0.0.0}:${STEAM_CHAT_PORT:-3000}:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
STEAM_CHAT_DATA_DIR: /app/data
|
||||
STEAM_CHAT_HOST: 0.0.0.0
|
||||
STEAM_CHAT_PORT: 3000
|
||||
STEAM_CHAT_WS_PATH: /ws
|
||||
volumes:
|
||||
- steam-chat-data:/app/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
steam-chat-data:
|
||||
300
docs/backend-redesign.md
Normal file
300
docs/backend-redesign.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Steam Chat 后台改造设计
|
||||
|
||||
## 目标
|
||||
|
||||
本次改造要把当前的 Steam Chat 从“启动时读取 `config.js` 并立即登录 Steam”改成“应用先启动,后台用户登录后再由管理员在 Web 页面完成 Steam 登录”。
|
||||
|
||||
核心目标:
|
||||
|
||||
- 不再依赖 `config.js` 保存 Steam 账号密码。
|
||||
- 服务无 Steam token 时也能启动并打开 Web 后台。
|
||||
- 增加 SQLite 多用户后台,管理员负责用户管理和 Steam 登录,普通用户只使用已连接的 Steam 会话聊天。
|
||||
- 前端完全重写为后台应用,不再在现有聊天页面上追加登录和管理功能。
|
||||
- 首次 Steam 登录成功后只持久化 `refresh.token`,不保存 Steam 明文密码或 Steam Guard 验证码。
|
||||
- 保留现有聊天、好友、群组、历史、图片和 WebSocket 能力。
|
||||
|
||||
## 当前问题
|
||||
|
||||
当前入口在启动时调用 Steam 登录流程。如果项目根目录没有 `config.js`,会退回 `config.example.js`;示例配置没有真实 Steam 凭据时,登录流程会报错,应用也无法作为一个可配置的后台服务正常交付。
|
||||
|
||||
当前持久化路径也分散在项目根目录下:
|
||||
|
||||
- `refresh.token`
|
||||
- `logs/chat.jsonl`
|
||||
- `logs/images/`
|
||||
- `logs/stickers/`
|
||||
|
||||
容器部署后这些状态必须进入统一数据目录,否则镜像重建或容器重建会丢失登录 token、聊天记录和媒体缓存。
|
||||
|
||||
## 运行模型
|
||||
|
||||
改造后的启动顺序:
|
||||
|
||||
1. 读取环境变量和数据目录配置。
|
||||
2. 初始化 SQLite 用户库和应用密钥。
|
||||
3. 启动 HTTP/WebSocket 服务。
|
||||
4. 如果数据目录存在 `refresh.token`,后台自动尝试 Steam token 登录。
|
||||
5. 如果没有 token,Web 后台保持可访问,管理员在页面发起 Steam 登录。
|
||||
|
||||
Steam 登录状态由后端统一维护:
|
||||
|
||||
- `logged_out`:未登录 Steam,聊天发送、好友、群组、表情等依赖 Steam 的接口返回 503。
|
||||
- `logging_in`:正在提交 Steam 登录。
|
||||
- `waiting_guard`:Steam 要求邮箱码或手机 2FA,等待管理员提交验证码。
|
||||
- `online`:Steam 登录成功,聊天功能可用。
|
||||
- `error`:最近一次登录失败,状态接口返回错误信息。
|
||||
- `reconnecting`:已登录后断线,后台自动重连或等待管理员处理。
|
||||
|
||||
Web 页面在应用登录后始终可打开。未连接 Steam 时,聊天区域显示当前 Steam 状态;管理员可看到 Steam 登录表单,普通用户只能看到等待连接状态。
|
||||
|
||||
## 用户系统
|
||||
|
||||
使用 Node 26 内置 `node:sqlite`,不引入额外数据库容器或 native npm 依赖。
|
||||
|
||||
SQLite 文件位置:
|
||||
|
||||
```text
|
||||
${STEAM_CHAT_DATA_DIR}/auth.sqlite
|
||||
```
|
||||
|
||||
建议表结构:
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
session_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
密码规则:
|
||||
|
||||
- 用户名长度 3 到 64,只允许字母、数字、下划线、短横线和点。
|
||||
- 密码最少 8 位。
|
||||
- 使用 `crypto.scryptSync` 加随机盐保存密码哈希。
|
||||
- 哈希字段保存为自描述格式,例如 `scrypt:v1:<salt>:<hash>`。
|
||||
- 登录、重置密码、禁用、启用、角色变更时不记录明文密码。
|
||||
|
||||
首次访问时,如果 `users` 表为空,前端显示初始化管理员页面。初始化接口创建第一个启用的 `admin` 用户。之后该接口必须拒绝再次调用。
|
||||
|
||||
权限规则:
|
||||
|
||||
- `admin`:可管理后台用户,可执行 Steam 登录、Steam Guard 提交、Steam 退出。
|
||||
- `user`:可使用已登录 Steam 会话聊天,可读取聊天所需的好友、群组、历史和媒体接口。
|
||||
- 不能删除当前登录用户自己。
|
||||
- 不能删除、禁用或降级最后一个启用的管理员。
|
||||
- 禁用用户后,该用户已有 Cookie 必须失效。
|
||||
|
||||
## 应用会话
|
||||
|
||||
使用签名 Cookie,不引入服务端 session 表。
|
||||
|
||||
Cookie:
|
||||
|
||||
- 名称:`steam_chat_session`
|
||||
- 属性:`HttpOnly`、`SameSite=Lax`、`Path=/`
|
||||
- 过期时间:默认 7 天
|
||||
- HTTPS 反代下设置 `Secure`
|
||||
|
||||
签名密钥:
|
||||
|
||||
- 首次启动时生成 32 字节随机密钥。
|
||||
- 保存在 `app_meta.session_secret`。
|
||||
- 数据目录持久化后,容器重建不会导致所有用户退出。
|
||||
|
||||
Cookie payload 包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1,
|
||||
"role": "admin",
|
||||
"sv": 1,
|
||||
"iat": 1710000000000,
|
||||
"exp": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
校验规则:
|
||||
|
||||
- HMAC 使用 `sha256`。
|
||||
- 每个受保护请求都根据 `uid` 查询数据库。
|
||||
- 用户不存在、被禁用、`session_version` 不匹配、Cookie 过期或签名不正确时返回 401。
|
||||
- 重置密码、禁用用户、启用用户、变更角色时递增 `session_version`,使旧 Cookie 失效。
|
||||
|
||||
## HTTP API
|
||||
|
||||
所有响应使用 JSON。未认证返回 401,权限不足返回 403。
|
||||
|
||||
公开接口:
|
||||
|
||||
- `GET /healthz`
|
||||
- 仅表示应用进程和 HTTP 服务可用,不要求 Steam 已登录。
|
||||
- 返回:`{ "ok": true }`
|
||||
- `GET /api/auth/me`
|
||||
- 未登录也可访问。
|
||||
- 返回是否需要初始化、当前登录用户和 Steam 状态摘要。
|
||||
- `POST /api/auth/setup`
|
||||
- 仅在没有任何用户时可用。
|
||||
- 请求:`{ "username": "admin", "password": "..." }`
|
||||
- 成功后直接写入登录 Cookie。
|
||||
- `POST /api/auth/login`
|
||||
- 请求:`{ "username": "...", "password": "..." }`
|
||||
- 成功后写入登录 Cookie。
|
||||
- `POST /api/auth/logout`
|
||||
- 清除当前 Cookie。
|
||||
|
||||
登录用户接口:
|
||||
|
||||
- `POST /api/auth/password`
|
||||
- 当前用户修改自己的后台密码。
|
||||
- 请求:`{ "oldPassword": "...", "newPassword": "..." }`
|
||||
|
||||
管理员用户管理接口:
|
||||
|
||||
- `GET /api/users`
|
||||
- 返回用户列表,不返回密码哈希。
|
||||
- `POST /api/users`
|
||||
- 请求:`{ "username": "...", "password": "...", "role": "user" }`
|
||||
- `PATCH /api/users/:id`
|
||||
- 支持修改 `role`、`disabled`。
|
||||
- `POST /api/users/:id/password`
|
||||
- 管理员重置指定用户密码。
|
||||
- `DELETE /api/users/:id`
|
||||
- 删除非自身用户,并执行最后管理员保护。
|
||||
|
||||
Steam 管理接口:
|
||||
|
||||
- `GET /api/steam/status`
|
||||
- 返回当前 Steam 状态、是否需要 Guard、最近错误、当前 SteamID。
|
||||
- `POST /api/steam/login`
|
||||
- 仅管理员。
|
||||
- 请求:`{ "accountName": "...", "password": "...", "logonID": 123 }`
|
||||
- `logonID` 可选;缺省时后端生成稳定随机值并保存在数据目录配置中。
|
||||
- 如果需要 Steam Guard,返回 `waiting_guard`。
|
||||
- `POST /api/steam/guard`
|
||||
- 仅管理员。
|
||||
- 请求:`{ "code": "ABCDE" }`
|
||||
- 后端把验证码交给当前待处理的 Steam Guard callback。
|
||||
- `POST /api/steam/logout`
|
||||
- 仅管理员。
|
||||
- 调用 Steam `logOff()`,删除数据目录里的 `refresh.token`,状态变为 `logged_out`。
|
||||
|
||||
聊天相关的既有接口和 WebSocket 协议保留,但必须先通过应用登录认证。未登录 Steam 时,依赖 Steam 的接口返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Steam is not logged in",
|
||||
"steamStatus": "logged_out"
|
||||
}
|
||||
```
|
||||
|
||||
## Steam 登录实现
|
||||
|
||||
将 Steam 生命周期从“构造时绑定固定 config”调整为“运行期按请求传入登录参数”。
|
||||
|
||||
后端新增 `SteamLoginService`,负责:
|
||||
|
||||
- 启动时读取 `${STEAM_CHAT_DATA_DIR}/refresh.token` 并尝试 token 登录。
|
||||
- 管理 Steam 状态机。
|
||||
- 监听 `steamGuard(domain, callback, lastCodeWrong)`。
|
||||
- 保存待提交的 Guard callback,并通过状态接口暴露 `guardType`、`domain`、`lastCodeWrong`。
|
||||
- 登录成功后监听 `refreshToken` 事件并写入数据目录。
|
||||
- Steam 退出时删除 token。
|
||||
|
||||
Guard 类型规则:
|
||||
|
||||
- `domain` 为字符串时表示邮箱码,前端展示邮箱域名。
|
||||
- `domain` 为 `null` 时表示手机 2FA。
|
||||
- `lastCodeWrong` 为 `true` 时,前端提示等待新验证码后再提交,避免 2FA 循环。
|
||||
|
||||
安全要求:
|
||||
|
||||
- 请求日志、错误日志、前端状态都不能输出 Steam 密码、后台密码、Steam Guard code、refresh token。
|
||||
- 同一时间只允许一个 Steam 登录流程。已有 `logging_in` 或 `waiting_guard` 时,再次登录请求返回 409。
|
||||
- 管理员手动重新登录前,应先调用 Steam 退出或等待当前登录流程结束。
|
||||
|
||||
## 前端重写
|
||||
|
||||
前端必须按新的后台业务模型完全重写。旧的 `web/index.html`、`web/app.ts`、`web/style.css` 只作为功能参考,不作为增量改造基础;实现时允许删除旧 DOM 结构、旧全局状态机和旧样式组织。
|
||||
|
||||
重写目标:
|
||||
|
||||
- 首屏是后台应用入口,不是默认聊天界面。
|
||||
- 先解决后台初始化和后台登录,再进入业务工作台。
|
||||
- Steam 连接状态是后台工作台的一部分,而不是启动前置条件。
|
||||
- 聊天能力作为已登录后台用户的一个业务模块存在。
|
||||
- 管理员和普通用户看到的导航、按钮和操作必须按权限区分。
|
||||
|
||||
页面结构:
|
||||
|
||||
1. 初始化页
|
||||
- 仅在没有任何后台用户时展示。
|
||||
- 创建第一个管理员,成功后直接进入后台。
|
||||
2. 后台登录页
|
||||
- 已初始化但未登录时展示。
|
||||
- 只处理后台用户登录,不处理 Steam 登录。
|
||||
3. 应用外壳
|
||||
- 登录后展示统一后台布局。
|
||||
- 包含侧边导航、顶部状态区、当前后台用户入口、退出登录入口。
|
||||
4. Steam 连接页
|
||||
- 管理员可提交 Steam 账号密码。
|
||||
- 需要 Steam Guard 时切换到验证码提交状态。
|
||||
- 普通用户只能查看当前 Steam 状态。
|
||||
5. 用户管理页
|
||||
- 仅管理员可见。
|
||||
- 支持新增用户、重置密码、启用/禁用、删除。
|
||||
6. 聊天页
|
||||
- 重新实现会话列表、好友/群组列表、消息区、输入区、图片发送、表情/贴纸选择。
|
||||
- Steam 未在线时展示不可用状态,并禁用发送、图片、表情等操作。
|
||||
|
||||
前端启动顺序:
|
||||
|
||||
1. 请求 `GET /api/auth/me`。
|
||||
2. 如果需要初始化,渲染初始化页。
|
||||
3. 如果未登录,渲染后台登录页。
|
||||
4. 如果已登录,进入应用外壳。
|
||||
5. 加载 `GET /api/steam/status` 和 `GET /api/config`。
|
||||
6. 只有后台已登录后才建立 WebSocket。
|
||||
7. Steam 未在线时保留页面可访问,但禁用依赖 Steam 的交互。
|
||||
|
||||
实现约束:
|
||||
|
||||
- v1 继续使用当前 TypeScript 静态构建链路,不强制引入前端框架或 bundler。
|
||||
- 可以把 `web/app.ts` 拆成多个 TypeScript 模块,但输出仍由 `tsc` 生成到 `dist/web`。
|
||||
- 所有前端请求默认携带同源 Cookie;401 时关闭 WebSocket 并回到后台登录页。
|
||||
- 前端状态中不能保存或打印 Steam 密码、后台密码、Steam Guard code、refresh token。
|
||||
- 移动端必须能完成初始化、后台登录、Steam Guard 提交和基础聊天。
|
||||
|
||||
## 测试要求
|
||||
|
||||
新增或调整以下测试:
|
||||
|
||||
- 用户初始化:无用户时允许 setup,有用户后拒绝 setup。
|
||||
- 登录认证:密码正确写 Cookie,密码错误拒绝,禁用用户拒绝。
|
||||
- Cookie 校验:签名错误、过期、`session_version` 不匹配都返回 401。
|
||||
- 用户管理:管理员可新增、重置密码、禁用、启用、删除;普通用户返回 403。
|
||||
- 最后管理员保护:不能删除、禁用或降级最后一个启用管理员。
|
||||
- Steam 状态:无 token 时服务启动且状态为 `logged_out`。
|
||||
- Steam token 登录:存在 `refresh.token` 时启动后自动尝试登录。
|
||||
- Steam Guard:触发 `waiting_guard`,提交验证码后继续登录,错误验证码保留状态。
|
||||
- 聊天接口:应用未登录返回 401;应用已登录但 Steam 未登录返回 503;Steam 在线后保持现有行为。
|
||||
- WebSocket:未认证握手拒绝,认证后可连接。
|
||||
|
||||
验收命令:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
312
docs/docker-compose-deployment.md
Normal file
312
docs/docker-compose-deployment.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Steam Chat Docker Compose 部署设计
|
||||
|
||||
## 目标
|
||||
|
||||
提供一个单服务 Docker Compose 部署方式,让服务器上可以用下面的命令完成构建和启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
部署后,用户通过浏览器访问 Web 后台,首次初始化管理员,然后由管理员完成 Steam 登录。
|
||||
|
||||
本部署方案不包含反向代理、HTTPS、外部数据库或额外服务。生产公网访问建议在外层自行接入 HTTPS 反向代理。
|
||||
|
||||
## 交付文件
|
||||
|
||||
需要新增或调整:
|
||||
|
||||
- `compose.yaml`
|
||||
- `.env.example`
|
||||
- `Dockerfile`
|
||||
- `.dockerignore`
|
||||
- `.gitignore`
|
||||
- 后台改造文档中约定的数据目录和健康检查接口
|
||||
|
||||
Compose 文件使用本地源码构建镜像,同时设置固定镜像名,避免只有 `image` 而没有 `build` 时 `docker compose up --build` 仍尝试拉取远端镜像。
|
||||
|
||||
## Compose 服务定义
|
||||
|
||||
目标 `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
steam-chat:
|
||||
image: ${STEAM_CHAT_IMAGE:-steam-chat:latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${STEAM_CHAT_CONTAINER_NAME:-steam-chat}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
ports:
|
||||
- "${STEAM_CHAT_BIND:-0.0.0.0}:${STEAM_CHAT_PORT:-3000}:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
STEAM_CHAT_DATA_DIR: /app/data
|
||||
STEAM_CHAT_HOST: 0.0.0.0
|
||||
STEAM_CHAT_PORT: 3000
|
||||
STEAM_CHAT_WS_PATH: /ws
|
||||
volumes:
|
||||
- steam-chat-data:/app/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
steam-chat-data:
|
||||
```
|
||||
|
||||
默认使用 Docker named volume,而不是 `./data:/app/data`。原因是运行容器使用非 root 用户时,bind mount 容易遇到宿主机目录权限问题;named volume 会继承镜像内 `/app/data` 的权限,更适合快速部署。
|
||||
|
||||
如果必须把数据放在当前目录,可以把 volume 改成:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
```
|
||||
|
||||
并在首次启动前执行:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
sudo chown -R 1000:1000 data
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
`.env.example`:
|
||||
|
||||
```dotenv
|
||||
STEAM_CHAT_IMAGE=steam-chat:latest
|
||||
STEAM_CHAT_CONTAINER_NAME=steam-chat
|
||||
STEAM_CHAT_BIND=0.0.0.0
|
||||
STEAM_CHAT_PORT=3000
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- Compose 会自动读取同目录 `.env` 做 YAML 变量替换。
|
||||
- `.env` 中的变量不会自动进入容器,只有在 `environment:` 中显式声明的变量才会成为容器环境变量。
|
||||
- 本项目不在 `.env` 中保存 Steam 账号、Steam 密码、Steam Guard code 或 refresh token。
|
||||
- Steam 登录凭据只通过 Web 后台提交,登录成功后只保存 `refresh.token` 到数据卷。
|
||||
|
||||
## Dockerfile 调整
|
||||
|
||||
继续使用多阶段构建:
|
||||
|
||||
1. `build` 阶段执行 `npm ci` 和 `npm run build`。
|
||||
2. `runtime` 阶段只复制 `dist/`、`node_modules/`、`package.json`、`package-lock.json`。
|
||||
3. 运行用户使用 `node`。
|
||||
4. 创建 `/app/data` 并设置为 `node:node`。
|
||||
5. 默认命令为 `node dist/src/index.js`。
|
||||
|
||||
运行时环境:
|
||||
|
||||
```dockerfile
|
||||
ENV NODE_ENV=production
|
||||
ENV STEAM_CHAT_DATA_DIR=/app/data
|
||||
ENV STEAM_CHAT_HOST=0.0.0.0
|
||||
ENV STEAM_CHAT_PORT=3000
|
||||
```
|
||||
|
||||
镜像内不复制或生成以下敏感/状态文件:
|
||||
|
||||
- `config.js`
|
||||
- `.env`
|
||||
- `refresh.token`
|
||||
- `auth.sqlite`
|
||||
- `logs/`
|
||||
- `data/`
|
||||
|
||||
`.dockerignore` 和 `.gitignore` 都应包含:
|
||||
|
||||
```text
|
||||
data/
|
||||
auth.sqlite
|
||||
```
|
||||
|
||||
## 数据目录
|
||||
|
||||
容器内固定数据目录:
|
||||
|
||||
```text
|
||||
/app/data
|
||||
```
|
||||
|
||||
目录内容:
|
||||
|
||||
```text
|
||||
/app/data/auth.sqlite
|
||||
/app/data/refresh.token
|
||||
/app/data/logs/chat.jsonl
|
||||
/app/data/logs/images/
|
||||
/app/data/logs/stickers/
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `auth.sqlite` 保存后台用户、角色、禁用状态和应用会话密钥。
|
||||
- `refresh.token` 保存 Steam refresh token。
|
||||
- `logs/chat.jsonl` 保存聊天历史。
|
||||
- `logs/images/` 和 `logs/stickers/` 保存媒体缓存。
|
||||
|
||||
## 健康检查
|
||||
|
||||
新增 `GET /healthz`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
该接口只检查应用 HTTP 服务是否可用,不检查 Steam 是否已经登录。原因是首次部署时 Steam 本来就可能未登录,如果健康检查依赖 Steam,会导致容器在正常初始化阶段被判定为 unhealthy。
|
||||
|
||||
Steam 状态通过后台接口查看:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/api/steam/status
|
||||
```
|
||||
|
||||
该接口需要后台登录 Cookie,主要给 Web 后台使用。
|
||||
|
||||
## 部署流程
|
||||
|
||||
首次部署:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose config --quiet
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://服务器地址:3000
|
||||
```
|
||||
|
||||
首次打开页面:
|
||||
|
||||
1. 初始化管理员。
|
||||
2. 使用管理员账号登录后台。
|
||||
3. 在后台提交 Steam 账号密码。
|
||||
4. 如果 Steam 要求 Guard,提交邮箱码或手机 2FA。
|
||||
5. Steam 登录成功后开始使用聊天功能。
|
||||
|
||||
更新部署:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
查看日志:
|
||||
|
||||
```bash
|
||||
docker compose logs -f steam-chat
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
删除容器但保留数据卷:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
删除数据卷会清空后台用户、Steam token、聊天历史和缓存,只有明确要重置时才执行:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
默认 named volume 备份:
|
||||
|
||||
```bash
|
||||
mkdir -p backups
|
||||
docker run --rm \
|
||||
-v steam-chat_steam-chat-data:/data:ro \
|
||||
-v "$PWD/backups:/backup" \
|
||||
alpine \
|
||||
tar -czf /backup/steam-chat-data.tgz -C /data .
|
||||
```
|
||||
|
||||
恢复:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker run --rm \
|
||||
-v steam-chat_steam-chat-data:/data \
|
||||
-v "$PWD/backups:/backup" \
|
||||
alpine \
|
||||
sh -c "rm -rf /data/* && tar -xzf /backup/steam-chat-data.tgz -C /data"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
如果使用 bind mount `./data:/app/data`,直接备份宿主机 `data/` 目录即可。
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 不要把 Steam 密码、后台密码、Steam Guard code 或 refresh token 写入 `.env`、compose 文件或镜像。
|
||||
- 公网部署时建议使用 HTTPS 反向代理。
|
||||
- 如果只允许本机反向代理访问应用端口,可设置:
|
||||
|
||||
```dotenv
|
||||
STEAM_CHAT_BIND=127.0.0.1
|
||||
```
|
||||
|
||||
- 后台登录 Cookie 在 HTTPS 下应设置 `Secure`;应用可以根据 `X-Forwarded-Proto: https` 或显式环境变量判断。
|
||||
- 反向代理需要支持 WebSocket Upgrade,并转发 `/ws`。
|
||||
|
||||
## 验收命令
|
||||
|
||||
配置校验:
|
||||
|
||||
```bash
|
||||
docker compose config --quiet
|
||||
```
|
||||
|
||||
镜像构建:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:${STEAM_CHAT_PORT:-3000}/healthz
|
||||
```
|
||||
|
||||
代码质量:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
|
||||
194
src/auth/session.ts
Normal file
194
src/auth/session.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
'use strict';
|
||||
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { PublicUser } from './store';
|
||||
import { isRecord } from '../types';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
type AuthStoreLike = {
|
||||
getOrCreateSessionSecret: () => string;
|
||||
getUserById: (id: unknown) => (PublicUser & { passwordHash?: string }) | null;
|
||||
};
|
||||
|
||||
type SessionPayload = {
|
||||
uid: number;
|
||||
role: 'admin' | 'user';
|
||||
sv: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type AppSession = {
|
||||
payload: SessionPayload;
|
||||
user: PublicUser;
|
||||
};
|
||||
|
||||
type SessionManagerOptions = {
|
||||
store: AuthStoreLike;
|
||||
cookieName?: string;
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_COOKIE_NAME = 'steam_chat_session';
|
||||
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function base64urlJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
function sign(value: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(value).digest('base64url');
|
||||
}
|
||||
|
||||
function timingSafeTextEqual(left: unknown, right: unknown): boolean {
|
||||
const leftBuffer = Buffer.from(String(left || ''));
|
||||
const rightBuffer = Buffer.from(String(right || ''));
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function parseCookieHeader(header: unknown): Record<string, string> {
|
||||
const cookies: Record<string, string> = {};
|
||||
for (const part of String(header || '').split(';')) {
|
||||
const index = part.indexOf('=');
|
||||
if (index === -1) continue;
|
||||
const key = part.slice(0, index).trim();
|
||||
if (!key) continue;
|
||||
cookies[key] = decodeURIComponent(part.slice(index + 1).trim());
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function secureCookieFor(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
const socket = req.socket as IncomingMessage['socket'] & { encrypted?: boolean };
|
||||
if (socket?.encrypted) return true;
|
||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||
return String(Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto || '').split(',')[0].trim() === 'https';
|
||||
}
|
||||
|
||||
function serializeCookie(name: string, value: string, options: {
|
||||
maxAge?: number;
|
||||
expires?: Date;
|
||||
secure?: boolean;
|
||||
} = {}) {
|
||||
const parts = [
|
||||
`${name}=${encodeURIComponent(value)}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax'
|
||||
];
|
||||
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
||||
if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
||||
if (options.secure) parts.push('Secure');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function userToPublic(user: PublicUser & { passwordHash?: string }): PublicUser {
|
||||
const { passwordHash: _passwordHash, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
function createSessionManager(options: SessionManagerOptions) {
|
||||
const store = options.store;
|
||||
const cookieName = options.cookieName || DEFAULT_COOKIE_NAME;
|
||||
const maxAgeMs = options.maxAgeMs || DEFAULT_MAX_AGE_MS;
|
||||
const secret = store.getOrCreateSessionSecret();
|
||||
|
||||
function encode(payload: SessionPayload): string {
|
||||
const encoded = base64urlJson(payload);
|
||||
return `${encoded}.${sign(encoded, secret)}`;
|
||||
}
|
||||
|
||||
function decode(raw: unknown): SessionPayload | null {
|
||||
const text = String(raw || '');
|
||||
const index = text.lastIndexOf('.');
|
||||
if (index <= 0) return null;
|
||||
const encoded = text.slice(0, index);
|
||||
const signature = text.slice(index + 1);
|
||||
if (!timingSafeTextEqual(signature, sign(encoded, secret))) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
||||
if (!isRecord(parsed)) return null;
|
||||
const payload: SessionPayload = {
|
||||
uid: Number(parsed.uid || 0),
|
||||
role: parsed.role === 'admin' ? 'admin' : 'user',
|
||||
sv: Number(parsed.sv || 0),
|
||||
iat: Number(parsed.iat || 0),
|
||||
exp: Number(parsed.exp || 0)
|
||||
};
|
||||
if (!payload.uid || !payload.sv || !payload.iat || !payload.exp) return null;
|
||||
return payload;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSession(req: IncomingMessage): AppSession | null {
|
||||
const raw = parseCookieHeader(req.headers.cookie)[cookieName];
|
||||
const payload = decode(raw);
|
||||
if (!payload || payload.exp <= Date.now()) return null;
|
||||
const user = store.getUserById(payload.uid);
|
||||
if (!user || user.disabled || user.sessionVersion !== payload.sv || user.role !== payload.role) return null;
|
||||
return { payload, user: userToPublic(user) };
|
||||
}
|
||||
|
||||
function createSetCookie(user: PublicUser, req?: IncomingMessage) {
|
||||
const iat = Date.now();
|
||||
const payload: SessionPayload = {
|
||||
uid: user.id,
|
||||
role: user.role,
|
||||
sv: user.sessionVersion,
|
||||
iat,
|
||||
exp: iat + maxAgeMs
|
||||
};
|
||||
return serializeCookie(cookieName, encode(payload), {
|
||||
maxAge: maxAgeMs / 1000,
|
||||
expires: new Date(payload.exp),
|
||||
secure: secureCookieFor(req)
|
||||
});
|
||||
}
|
||||
|
||||
function createClearCookie(req?: IncomingMessage) {
|
||||
return serializeCookie(cookieName, '', {
|
||||
maxAge: 0,
|
||||
expires: new Date(0),
|
||||
secure: secureCookieFor(req)
|
||||
});
|
||||
}
|
||||
|
||||
function requireSession(req: IncomingMessage): AppSession {
|
||||
const session = getSession(req);
|
||||
if (!session) throw Object.assign(new Error('Unauthorized'), { statusCode: 401 });
|
||||
return session;
|
||||
}
|
||||
|
||||
function requireAdmin(req: IncomingMessage): AppSession {
|
||||
const session = requireSession(req);
|
||||
if (session.user.role !== 'admin') throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
||||
return session;
|
||||
}
|
||||
|
||||
return {
|
||||
cookieName,
|
||||
createClearCookie,
|
||||
createSetCookie,
|
||||
decode,
|
||||
encode,
|
||||
getSession,
|
||||
maxAgeMs,
|
||||
requireAdmin,
|
||||
requireSession
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_COOKIE_NAME,
|
||||
DEFAULT_MAX_AGE_MS,
|
||||
createSessionManager,
|
||||
parseCookieHeader,
|
||||
secureCookieFor,
|
||||
serializeCookie,
|
||||
timingSafeTextEqual
|
||||
};
|
||||
326
src/auth/store.ts
Normal file
326
src/auth/store.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
'use strict';
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { UnknownRecord } from '../types';
|
||||
import { isRecord } from '../types';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync: NodeDatabaseSync } = require('node:sqlite');
|
||||
const { AUTH_DB_PATH } = require('../paths');
|
||||
|
||||
type SQLRow = Record<string, unknown>;
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
|
||||
export type PublicUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
disabled: boolean;
|
||||
sessionVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
|
||||
type UserRow = PublicUser & {
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
type AuthStoreOptions = {
|
||||
dbPath?: string;
|
||||
database?: DatabaseSync;
|
||||
};
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_.-]{3,64}$/;
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
const HASH_PREFIX = 'scrypt:v1';
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(statusCode: number, message: string) {
|
||||
return Object.assign(new Error(message), { statusCode });
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === 'bigint' ? Number(value) : Number(value || 0);
|
||||
}
|
||||
|
||||
function asRole(value: unknown): UserRole {
|
||||
return value === 'admin' ? 'admin' : 'user';
|
||||
}
|
||||
|
||||
function validateUsername(username: unknown): string {
|
||||
const value = String(username || '').trim();
|
||||
if (!USERNAME_PATTERN.test(value)) {
|
||||
throw httpError(400, 'Username must be 3-64 characters and contain only letters, numbers, underscores, hyphens, or dots');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validatePassword(password: unknown): string {
|
||||
const value = String(password || '');
|
||||
if (value.length < PASSWORD_MIN_LENGTH) {
|
||||
throw httpError(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRole(role: unknown): UserRole {
|
||||
if (role === 'admin' || role === 'user') return role;
|
||||
throw httpError(400, 'Role must be admin or user');
|
||||
}
|
||||
|
||||
function hashPassword(password: string) {
|
||||
const salt = crypto.randomBytes(16).toString('base64url');
|
||||
const hash = crypto.scryptSync(password, salt, 64).toString('base64url');
|
||||
return `${HASH_PREFIX}:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(password: unknown, stored: unknown): boolean {
|
||||
const text = String(stored || '');
|
||||
const [scheme, version, salt, expected] = text.split(':');
|
||||
if (`${scheme}:${version}` !== HASH_PREFIX || !salt || !expected) return false;
|
||||
const actual = crypto.scryptSync(String(password || ''), salt, 64).toString('base64url');
|
||||
const left = Buffer.from(actual);
|
||||
const right = Buffer.from(expected);
|
||||
if (left.length !== right.length) return false;
|
||||
return crypto.timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function userFromRow(row: SQLRow | undefined): UserRow | null {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: asNumber(row.id),
|
||||
username: String(row.username || ''),
|
||||
passwordHash: String(row.password_hash || ''),
|
||||
role: asRole(row.role),
|
||||
disabled: Boolean(asNumber(row.disabled)),
|
||||
sessionVersion: asNumber(row.session_version),
|
||||
createdAt: String(row.created_at || ''),
|
||||
updatedAt: String(row.updated_at || ''),
|
||||
lastLoginAt: typeof row.last_login_at === 'string' ? row.last_login_at : null
|
||||
};
|
||||
}
|
||||
|
||||
function publicUser(user: UserRow): PublicUser {
|
||||
const { passwordHash: _passwordHash, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
function createAuthStore(options: AuthStoreOptions = {}) {
|
||||
const dbPath = options.dbPath || AUTH_DB_PATH;
|
||||
if (!options.database && dbPath !== ':memory:') {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
const db: DatabaseSync = options.database || new NodeDatabaseSync(dbPath, { timeout: 5000 });
|
||||
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
session_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
function getUserById(id: unknown): UserRow | null {
|
||||
return userFromRow(db.prepare('SELECT * FROM users WHERE id = ?').get(asNumber(id)));
|
||||
}
|
||||
|
||||
function getUserByUsername(username: unknown): UserRow | null {
|
||||
return userFromRow(db.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim()));
|
||||
}
|
||||
|
||||
function countUsers(): number {
|
||||
const row = db.prepare('SELECT COUNT(*) AS count FROM users').get();
|
||||
return asNumber(row?.count);
|
||||
}
|
||||
|
||||
function enabledAdminCount(): number {
|
||||
const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND disabled = 0").get();
|
||||
return asNumber(row?.count);
|
||||
}
|
||||
|
||||
function ensureCanRemoveAdmin(user: UserRow) {
|
||||
if (user.role === 'admin' && !user.disabled && enabledAdminCount() <= 1) {
|
||||
throw httpError(409, 'Cannot remove the last enabled admin');
|
||||
}
|
||||
}
|
||||
|
||||
function createUser(input: UnknownRecord): PublicUser {
|
||||
const username = validateUsername(input.username);
|
||||
const password = validatePassword(input.password);
|
||||
const role = validateRole(input.role || 'user');
|
||||
const at = nowIso();
|
||||
try {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (username, password_hash, role, disabled, session_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 0, 1, ?, ?)
|
||||
`).run(username, hashPassword(password), role, at, at);
|
||||
const user = getUserById(result.lastInsertRowid);
|
||||
if (!user) throw httpError(500, 'Created user cannot be loaded');
|
||||
return publicUser(user);
|
||||
} catch (error) {
|
||||
if (isRecord(error) && String(error.message || '').includes('UNIQUE')) {
|
||||
throw httpError(409, 'Username already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialAdmin(input: UnknownRecord): PublicUser {
|
||||
if (countUsers() !== 0) throw httpError(409, 'Setup has already been completed');
|
||||
return createUser({ ...input, role: 'admin' });
|
||||
}
|
||||
|
||||
function authenticate(username: unknown, password: unknown): PublicUser | null {
|
||||
const user = getUserByUsername(username);
|
||||
if (!user || user.disabled || !verifyPassword(password, user.passwordHash)) return null;
|
||||
const at = nowIso();
|
||||
db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(at, at, user.id);
|
||||
return publicUser({ ...user, lastLoginAt: at, updatedAt: at });
|
||||
}
|
||||
|
||||
function listUsers(): PublicUser[] {
|
||||
return db.prepare('SELECT * FROM users ORDER BY id ASC')
|
||||
.all()
|
||||
.map((row: SQLRow) => publicUser(userFromRow(row)!));
|
||||
}
|
||||
|
||||
function updateUser(id: unknown, patch: UnknownRecord): PublicUser {
|
||||
const user = getUserById(id);
|
||||
if (!user) throw httpError(404, 'User not found');
|
||||
const nextRole = Object.prototype.hasOwnProperty.call(patch, 'role') ? validateRole(patch.role) : user.role;
|
||||
const nextDisabled = Object.prototype.hasOwnProperty.call(patch, 'disabled') ? Boolean(patch.disabled) : user.disabled;
|
||||
if (user.role === 'admin' && !user.disabled && (nextRole !== 'admin' || nextDisabled)) {
|
||||
ensureCanRemoveAdmin(user);
|
||||
}
|
||||
if (nextRole === user.role && nextDisabled === user.disabled) return publicUser(user);
|
||||
const at = nowIso();
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET role = ?, disabled = ?, session_version = session_version + 1, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(nextRole, nextDisabled ? 1 : 0, at, user.id);
|
||||
const next = getUserById(user.id);
|
||||
if (!next) throw httpError(500, 'Updated user cannot be loaded');
|
||||
return publicUser(next);
|
||||
}
|
||||
|
||||
function setPassword(id: unknown, password: unknown): PublicUser {
|
||||
const user = getUserById(id);
|
||||
if (!user) throw httpError(404, 'User not found');
|
||||
const nextPassword = validatePassword(password);
|
||||
const at = nowIso();
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?, session_version = session_version + 1, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(hashPassword(nextPassword), at, user.id);
|
||||
const next = getUserById(user.id);
|
||||
if (!next) throw httpError(500, 'Updated user cannot be loaded');
|
||||
return publicUser(next);
|
||||
}
|
||||
|
||||
function changeOwnPassword(id: unknown, oldPassword: unknown, newPassword: unknown): PublicUser {
|
||||
const user = getUserById(id);
|
||||
if (!user) throw httpError(404, 'User not found');
|
||||
if (!verifyPassword(oldPassword, user.passwordHash)) throw httpError(401, 'Old password is incorrect');
|
||||
return setPassword(user.id, newPassword);
|
||||
}
|
||||
|
||||
function deleteUser(id: unknown, currentUserId: unknown): void {
|
||||
const user = getUserById(id);
|
||||
if (!user) throw httpError(404, 'User not found');
|
||||
if (user.id === asNumber(currentUserId)) throw httpError(400, 'Cannot delete the current user');
|
||||
ensureCanRemoveAdmin(user);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
}
|
||||
|
||||
function getMeta(key: string): string | null {
|
||||
const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(key);
|
||||
return typeof row?.value === 'string' ? row.value : null;
|
||||
}
|
||||
|
||||
function setMeta(key: string, value: string): void {
|
||||
db.prepare(`
|
||||
INSERT INTO app_meta (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`).run(key, value, nowIso());
|
||||
}
|
||||
|
||||
function getOrCreateSessionSecret(): string {
|
||||
const existing = getMeta('session_secret');
|
||||
if (existing) return existing;
|
||||
const secret = crypto.randomBytes(32).toString('base64url');
|
||||
setMeta('session_secret', secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
function getOrCreateSteamLogonID(): number {
|
||||
const existing = Number.parseInt(String(getMeta('steam.logon_id') || ''), 10);
|
||||
if (Number.isFinite(existing) && existing > 0) return existing;
|
||||
const generated = crypto.randomInt(1, 0x7fffffff);
|
||||
setMeta('steam.logon_id', String(generated));
|
||||
return generated;
|
||||
}
|
||||
|
||||
function requiresSetup(): boolean {
|
||||
return countUsers() === 0;
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
dbPath,
|
||||
authenticate,
|
||||
changeOwnPassword,
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
countUsers,
|
||||
createInitialAdmin,
|
||||
createUser,
|
||||
deleteUser,
|
||||
enabledAdminCount,
|
||||
getMeta,
|
||||
getOrCreateSessionSecret,
|
||||
getOrCreateSteamLogonID,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
listUsers,
|
||||
requiresSetup,
|
||||
setMeta,
|
||||
setPassword,
|
||||
updateUser
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PASSWORD_MIN_LENGTH,
|
||||
USERNAME_PATTERN,
|
||||
createAuthStore,
|
||||
hashPassword,
|
||||
publicUser,
|
||||
validatePassword,
|
||||
validateRole,
|
||||
validateUsername,
|
||||
verifyPassword
|
||||
};
|
||||
@@ -7,14 +7,26 @@ const path = require('node:path');
|
||||
const { PROJECT_ROOT } = require('../paths');
|
||||
|
||||
function loadConfig(): UnknownRecord {
|
||||
let fileConfig: UnknownRecord = {};
|
||||
try {
|
||||
const loaded: unknown = require(path.join(PROJECT_ROOT, 'config'));
|
||||
return isRecord(loaded) ? loaded : {};
|
||||
fileConfig = isRecord(loaded) ? loaded : {};
|
||||
} catch (error) {
|
||||
if (!isRecord(error) || error.code !== 'MODULE_NOT_FOUND') throw error;
|
||||
const example: unknown = require(path.join(PROJECT_ROOT, 'config.example'));
|
||||
return isRecord(example) ? example : {};
|
||||
}
|
||||
|
||||
const chatInput = isRecord(fileConfig.chat) ? fileConfig.chat : {};
|
||||
const chat = {
|
||||
...chatInput,
|
||||
host: process.env.STEAM_CHAT_HOST || chatInput.host || '0.0.0.0',
|
||||
port: Number.parseInt(String(process.env.STEAM_CHAT_PORT || chatInput.port || 3000), 10),
|
||||
wsPath: process.env.STEAM_CHAT_WS_PATH || chatInput.wsPath || '/ws'
|
||||
};
|
||||
|
||||
return {
|
||||
...fileConfig,
|
||||
chat
|
||||
};
|
||||
}
|
||||
|
||||
function isChatEnabled(chatConfig: unknown) {
|
||||
|
||||
60
src/index.ts
60
src/index.ts
@@ -5,10 +5,12 @@ import { errorMessage, isRecord } from './types';
|
||||
|
||||
const winston = require('winston');
|
||||
|
||||
const { loadConfig, isChatEnabled } = require('./config/load');
|
||||
const { REFRESH_TOKEN_PATH } = require('./paths');
|
||||
const { createAuthStore } = require('./auth/store');
|
||||
const { createSessionManager } = require('./auth/session');
|
||||
const { loadConfig } = require('./config/load');
|
||||
const { AUTH_DB_PATH, REFRESH_TOKEN_PATH } = require('./paths');
|
||||
const { createChatService } = require('./server/chat-service');
|
||||
const { createSteamLifecycle } = require('./steam/lifecycle');
|
||||
const { createSteamLoginService } = require('./steam/lifecycle');
|
||||
const {
|
||||
steamIdToString
|
||||
} = require('./storage/chat-log');
|
||||
@@ -66,6 +68,8 @@ const config = loadConfig();
|
||||
const steamUser = new SteamUser({ renewRefreshTokens: true });
|
||||
const steamCommunity = new SteamCommunity();
|
||||
const users: Record<string, Persona> = {};
|
||||
const authStore = createAuthStore({ dbPath: AUTH_DB_PATH });
|
||||
const sessionManager = createSessionManager({ store: authStore });
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
@@ -122,12 +126,16 @@ async function getSelfName() {
|
||||
return info.player_name || info.personaName || 'Me';
|
||||
}
|
||||
|
||||
const lifecycle = createSteamLifecycle({
|
||||
const lifecycle = createSteamLoginService({
|
||||
steamUser,
|
||||
steamCommunity,
|
||||
config,
|
||||
config: {
|
||||
steamID: typeof config.steamID === 'string' ? config.steamID : undefined,
|
||||
identitySecret: typeof config.identitySecret === 'string' ? config.identitySecret : undefined
|
||||
},
|
||||
logger,
|
||||
refreshTokenPath: REFRESH_TOKEN_PATH
|
||||
refreshTokenPath: REFRESH_TOKEN_PATH,
|
||||
getDefaultLogonID: authStore.getOrCreateSteamLogonID
|
||||
});
|
||||
|
||||
createSteamMessageLogger({
|
||||
@@ -142,39 +150,45 @@ let steamWebLoginPromise = lifecycle.waitForWebSession();
|
||||
let chatService: ChatServiceRuntime | null = null;
|
||||
|
||||
function start() {
|
||||
steamLoginPromise = lifecycle.start();
|
||||
const tokenLoginPromise = lifecycle.start().catch((error: unknown) => {
|
||||
logger.warn('Steam token login failed', { error: errorMessage(error) });
|
||||
return false;
|
||||
});
|
||||
steamLoginPromise = lifecycle.waitForLogin();
|
||||
steamWebLoginPromise = lifecycle.waitForWebSession();
|
||||
if (isChatEnabled(config.chat)) {
|
||||
chatService = createChatService({
|
||||
config,
|
||||
steamUser,
|
||||
steamCommunity,
|
||||
logger,
|
||||
getUserInfo,
|
||||
getSelfName,
|
||||
waitForLogin: steamLoginPromise,
|
||||
waitForWebSession: steamWebLoginPromise,
|
||||
refreshWebSession: lifecycle.refreshWebSession
|
||||
});
|
||||
chatService.start();
|
||||
}
|
||||
return steamLoginPromise;
|
||||
chatService = createChatService({
|
||||
config,
|
||||
steamUser,
|
||||
steamCommunity,
|
||||
logger,
|
||||
getUserInfo,
|
||||
getSelfName,
|
||||
waitForLogin: () => lifecycle.waitForLogin(),
|
||||
waitForWebSession: () => lifecycle.waitForWebSession(),
|
||||
refreshWebSession: lifecycle.refreshWebSession,
|
||||
authStore,
|
||||
sessionManager,
|
||||
steamLoginService: lifecycle
|
||||
});
|
||||
chatService.start();
|
||||
return tokenLoginPromise;
|
||||
}
|
||||
|
||||
if (process.env.STEAM_CHAT_DISABLE_AUTOSTART !== '1') {
|
||||
start().catch((error: unknown) => {
|
||||
logger.error('Steam startup failed', { error: errorMessage(error) });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authStore,
|
||||
chatService,
|
||||
config,
|
||||
getSelfName,
|
||||
getUserInfo,
|
||||
lifecycle,
|
||||
logger,
|
||||
sessionManager,
|
||||
start,
|
||||
steamCommunity,
|
||||
steamLoginPromise,
|
||||
|
||||
@@ -5,14 +5,18 @@ const path = require('node:path');
|
||||
const BUILD_ROOT = path.resolve(__dirname, '..');
|
||||
const PROJECT_ROOT = path.basename(BUILD_ROOT) === 'dist' ? path.resolve(BUILD_ROOT, '..') : BUILD_ROOT;
|
||||
const WEB_DIR = path.join(BUILD_ROOT, 'web');
|
||||
const LOG_DIR = path.join(PROJECT_ROOT, 'logs');
|
||||
const DATA_DIR = path.resolve(process.env.STEAM_CHAT_DATA_DIR || path.join(PROJECT_ROOT, 'data'));
|
||||
const LOG_DIR = path.join(DATA_DIR, 'logs');
|
||||
const CHAT_LOG_PATH = path.join(LOG_DIR, 'chat.jsonl');
|
||||
const IMAGE_CACHE_DIR = path.join(LOG_DIR, 'images');
|
||||
const STICKER_CACHE_DIR = path.join(LOG_DIR, 'stickers');
|
||||
const REFRESH_TOKEN_PATH = path.join(PROJECT_ROOT, 'refresh.token');
|
||||
const REFRESH_TOKEN_PATH = path.join(DATA_DIR, 'refresh.token');
|
||||
const AUTH_DB_PATH = path.join(DATA_DIR, 'auth.sqlite');
|
||||
|
||||
module.exports = {
|
||||
AUTH_DB_PATH,
|
||||
CHAT_LOG_PATH,
|
||||
DATA_DIR,
|
||||
IMAGE_CACHE_DIR,
|
||||
LOG_DIR,
|
||||
PROJECT_ROOT,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { RawData, WebSocket as WsConnection, WebSocketServer as WsServer } from 'ws';
|
||||
import type { AppSession } from '../auth/session';
|
||||
import type { PublicUser } from '../auth/store';
|
||||
import type {
|
||||
AuthConfig,
|
||||
CallbackStyleFunction,
|
||||
@@ -97,6 +99,47 @@ type ChatServiceOptions = {
|
||||
getEmoticons?: (options: GetEmoticonsOptions) => Promise<EmoticonPayload>;
|
||||
fetchImpl?: typeof fetch;
|
||||
server?: Server;
|
||||
authStore?: AuthStoreLike;
|
||||
sessionManager?: SessionManagerLike;
|
||||
steamLoginService?: SteamLoginServiceLike;
|
||||
};
|
||||
|
||||
type AuthStoreLike = {
|
||||
authenticate: (username: unknown, password: unknown) => PublicUser | null;
|
||||
changeOwnPassword: (id: unknown, oldPassword: unknown, newPassword: unknown) => PublicUser;
|
||||
createInitialAdmin: (input: UnknownRecord) => PublicUser;
|
||||
createUser: (input: UnknownRecord) => PublicUser;
|
||||
deleteUser: (id: unknown, currentUserId: unknown) => void;
|
||||
listUsers: () => PublicUser[];
|
||||
requiresSetup: () => boolean;
|
||||
setPassword: (id: unknown, password: unknown) => PublicUser;
|
||||
updateUser: (id: unknown, patch: UnknownRecord) => PublicUser;
|
||||
};
|
||||
|
||||
type SessionManagerLike = {
|
||||
createClearCookie: (req?: IncomingMessage) => string;
|
||||
createSetCookie: (user: PublicUser, req?: IncomingMessage) => string;
|
||||
getSession: (req: IncomingMessage) => AppSession | null;
|
||||
requireAdmin: (req: IncomingMessage) => AppSession;
|
||||
requireSession: (req: IncomingMessage) => AppSession;
|
||||
};
|
||||
|
||||
type SteamStatusSummary = {
|
||||
status: string;
|
||||
requiresGuard?: boolean;
|
||||
guardType?: string | null;
|
||||
domain?: string | null;
|
||||
lastCodeWrong?: boolean;
|
||||
error?: string | null;
|
||||
steamId?: string | null;
|
||||
};
|
||||
|
||||
type SteamLoginServiceLike = {
|
||||
ensureOnline: () => void;
|
||||
getStatus: () => SteamStatusSummary;
|
||||
login: (input: UnknownRecord) => SteamStatusSummary;
|
||||
logout: () => SteamStatusSummary;
|
||||
submitGuard: (code: unknown) => SteamStatusSummary;
|
||||
};
|
||||
|
||||
type WsPayload = UnknownRecord & {
|
||||
@@ -196,6 +239,16 @@ function jsonResponse(res: ServerResponse, statusCode: number, payload: unknown,
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function errorPayload(error: unknown): UnknownRecord {
|
||||
const payload: UnknownRecord = {
|
||||
error: errorMessage(error) || 'Internal Server Error'
|
||||
};
|
||||
if (isRecord(error) && typeof error.steamStatus === 'string') {
|
||||
payload.steamStatus = error.steamStatus;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function textResponse(res: ServerResponse, statusCode: number, payload: string | Buffer, headers: Record<string, string> = {}) {
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(payload);
|
||||
@@ -252,6 +305,14 @@ function staticFileForUrl(pathname: string) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function isStaticRequest(pathname: string) {
|
||||
return pathname === '/'
|
||||
|| pathname === '/index.html'
|
||||
|| pathname === '/style.css'
|
||||
|| pathname === '/app.js'
|
||||
|| pathname === '/favicon.ico';
|
||||
}
|
||||
|
||||
async function serveStatic(req: IncomingMessage, res: ServerResponse, pathname: string) {
|
||||
const filePath = staticFileForUrl(pathname);
|
||||
if (!filePath) {
|
||||
@@ -342,7 +403,10 @@ function createChatService(options: ChatServiceOptions = {}) {
|
||||
const getSelfName = options.getSelfName || (async () => 'Me');
|
||||
const getEmoticons = options.getEmoticons || defaultGetEmoticons;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const auth = createAuthChecker(config.auth);
|
||||
const authStore = options.authStore;
|
||||
const sessionManager = options.sessionManager;
|
||||
const steamLoginService = options.steamLoginService;
|
||||
const legacyAuth = createAuthChecker(config.auth);
|
||||
const clients = new Set<WsConnection>();
|
||||
const recentSentText = new Map<string, number>();
|
||||
const recentSentImages = new Map<string, number>();
|
||||
@@ -365,7 +429,175 @@ function createChatService(options: ChatServiceOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function currentSteamStatus(): SteamStatusSummary {
|
||||
return steamLoginService?.getStatus?.() || {
|
||||
status: 'online',
|
||||
requiresGuard: false,
|
||||
guardType: null,
|
||||
domain: null,
|
||||
lastCodeWrong: false,
|
||||
error: null,
|
||||
steamId: null
|
||||
};
|
||||
}
|
||||
|
||||
function requireLegacyOrSession(req: IncomingMessage): AppSession | null {
|
||||
if (sessionManager) return sessionManager.requireSession(req);
|
||||
if (!legacyAuth.isAuthorized(req)) throw Object.assign(new Error('Unauthorized'), { statusCode: 401, legacyChallenge: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireAdminSession(req: IncomingMessage): AppSession | null {
|
||||
if (sessionManager) return sessionManager.requireAdmin(req);
|
||||
if (!legacyAuth.isAuthorized(req)) throw Object.assign(new Error('Unauthorized'), { statusCode: 401, legacyChallenge: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireSteamOnline() {
|
||||
steamLoginService?.ensureOnline?.();
|
||||
}
|
||||
|
||||
function writeAuthError(req: IncomingMessage, res: ServerResponse, error: unknown) {
|
||||
if (isRecord(error) && error.legacyChallenge && !sessionManager) {
|
||||
legacyAuth.challenge(res);
|
||||
return;
|
||||
}
|
||||
jsonResponse(res, statusCodeForError(error), errorPayload(error));
|
||||
}
|
||||
|
||||
function authMe(req: IncomingMessage) {
|
||||
const session = sessionManager?.getSession(req) || null;
|
||||
return {
|
||||
needsSetup: authStore?.requiresSetup?.() ?? false,
|
||||
user: session?.user || null,
|
||||
steam: currentSteamStatus()
|
||||
};
|
||||
}
|
||||
|
||||
async function handleAuthApi(req: IncomingMessage, res: ServerResponse, pathname: string) {
|
||||
if (!authStore || !sessionManager) return false;
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/auth/me') {
|
||||
jsonResponse(res, 200, authMe(req));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/auth/setup') {
|
||||
const body = await readJsonBody(req);
|
||||
const user = authStore.createInitialAdmin(body);
|
||||
jsonResponse(res, 200, { ok: true, user, steam: currentSteamStatus() }, {
|
||||
'Set-Cookie': sessionManager.createSetCookie(user, req)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/auth/login') {
|
||||
const body = await readJsonBody(req);
|
||||
const user = authStore.authenticate(body.username, body.password);
|
||||
if (!user) throw Object.assign(new Error('Invalid username or password'), { statusCode: 401 });
|
||||
jsonResponse(res, 200, { ok: true, user, steam: currentSteamStatus() }, {
|
||||
'Set-Cookie': sessionManager.createSetCookie(user, req)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/auth/logout') {
|
||||
jsonResponse(res, 200, { ok: true }, {
|
||||
'Set-Cookie': sessionManager.createClearCookie(req)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/auth/password') {
|
||||
const session = sessionManager.requireSession(req);
|
||||
const body = await readJsonBody(req);
|
||||
const user = authStore.changeOwnPassword(session.user.id, body.oldPassword, body.newPassword);
|
||||
jsonResponse(res, 200, { ok: true, user }, {
|
||||
'Set-Cookie': sessionManager.createSetCookie(user, req)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleUsersApi(req: IncomingMessage, res: ServerResponse, pathname: string) {
|
||||
if (!authStore || !sessionManager) return false;
|
||||
if (pathname === '/api/users') {
|
||||
requireAdminSession(req);
|
||||
if (req.method === 'GET') {
|
||||
jsonResponse(res, 200, { users: authStore.listUsers() });
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
const user = authStore.createUser(await readJsonBody(req));
|
||||
jsonResponse(res, 201, { ok: true, user });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwordMatch = pathname.match(/^\/api\/users\/(\d+)\/password$/);
|
||||
if (passwordMatch && req.method === 'POST') {
|
||||
requireAdminSession(req);
|
||||
const body = await readJsonBody(req);
|
||||
const user = authStore.setPassword(passwordMatch[1], body.password);
|
||||
jsonResponse(res, 200, { ok: true, user });
|
||||
return true;
|
||||
}
|
||||
|
||||
const userMatch = pathname.match(/^\/api\/users\/(\d+)$/);
|
||||
if (userMatch) {
|
||||
const session = requireAdminSession(req);
|
||||
if (req.method === 'PATCH') {
|
||||
const user = authStore.updateUser(userMatch[1], await readJsonBody(req));
|
||||
jsonResponse(res, 200, { ok: true, user });
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
authStore.deleteUser(userMatch[1], session?.user.id);
|
||||
jsonResponse(res, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleSteamApi(req: IncomingMessage, res: ServerResponse, pathname: string) {
|
||||
if (!steamLoginService) return false;
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/steam/status') {
|
||||
requireLegacyOrSession(req);
|
||||
jsonResponse(res, 200, currentSteamStatus());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/steam/login') {
|
||||
requireAdminSession(req);
|
||||
const status = steamLoginService.login(await readJsonBody(req));
|
||||
jsonResponse(res, 200, status);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/steam/guard') {
|
||||
requireAdminSession(req);
|
||||
const body = await readJsonBody(req);
|
||||
jsonResponse(res, 200, steamLoginService.submitGuard(body.code));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/steam/logout') {
|
||||
requireAdminSession(req);
|
||||
jsonResponse(res, 200, steamLoginService.logout());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function withSteamRetry<T>(operation: () => Promise<T> | T, needsWebSession = false): Promise<T> {
|
||||
requireSteamOnline();
|
||||
await resolveWaiter(waitForLogin);
|
||||
if (needsWebSession) {
|
||||
await resolveWaiter(waitForWebSession);
|
||||
@@ -481,26 +713,39 @@ function createChatService(options: ChatServiceOptions = {}) {
|
||||
async function handleHttpRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
try {
|
||||
if (!auth.isAuthorized(req)) {
|
||||
auth.challenge(res);
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') {
|
||||
jsonResponse(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && isStaticRequest(url.pathname)) {
|
||||
if (await serveStatic(req, res, url.pathname)) return;
|
||||
}
|
||||
|
||||
if (await handleAuthApi(req, res, url.pathname)) return;
|
||||
if (await handleSteamApi(req, res, url.pathname)) return;
|
||||
if (await handleUsersApi(req, res, url.pathname)) return;
|
||||
|
||||
requireLegacyOrSession(req);
|
||||
|
||||
if (req.method === 'GET') {
|
||||
if (url.pathname === '/api/config') {
|
||||
jsonResponse(res, 200, { wsPath: config.wsPath });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/emoticons') {
|
||||
requireSteamOnline();
|
||||
const data = await getEmoticons({ steamUser, waitForLogin, waitForWebSession });
|
||||
jsonResponse(res, 200, data);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/friends') {
|
||||
requireSteamOnline();
|
||||
jsonResponse(res, 200, await listFriends(steamUser));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/groups') {
|
||||
requireSteamOnline();
|
||||
jsonResponse(res, 200, await listGroups(steamUser));
|
||||
return;
|
||||
}
|
||||
@@ -556,7 +801,7 @@ function createChatService(options: ChatServiceOptions = {}) {
|
||||
|
||||
jsonResponse(res, req.method === 'GET' ? 404 : 405, { error: 'Not found' });
|
||||
} catch (error) {
|
||||
jsonResponse(res, statusCodeForError(error), { error: errorMessage(error) || 'Internal Server Error' });
|
||||
writeAuthError(req, res, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,20 +849,23 @@ function createChatService(options: ChatServiceOptions = {}) {
|
||||
return;
|
||||
}
|
||||
if (type === 'get_emoticons' || type === 'emoticons') {
|
||||
requireSteamOnline();
|
||||
reply({ type: 'emoticons', ...(await getEmoticons({ steamUser, waitForLogin, waitForWebSession })) });
|
||||
return;
|
||||
}
|
||||
if (type === 'get_friends' || type === 'friends') {
|
||||
requireSteamOnline();
|
||||
reply({ type: 'friends', friends: await listFriends(steamUser) });
|
||||
return;
|
||||
}
|
||||
if (type === 'get_groups' || type === 'groups') {
|
||||
requireSteamOnline();
|
||||
reply({ type: 'groups', groups: await listGroups(steamUser) });
|
||||
return;
|
||||
}
|
||||
reply({ type: 'error', error: `Unsupported WebSocket type: ${type || 'unknown'}` });
|
||||
} catch (error) {
|
||||
reply({ type: 'error', error: errorMessage(error) || 'Request failed' });
|
||||
reply({ type: 'error', error: errorMessage(error) || 'Request failed', statusCode: statusCodeForError(error), ...(errorPayload(error)) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,8 +875,21 @@ function createChatService(options: ChatServiceOptions = {}) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!auth.isAuthorized(req)) {
|
||||
auth.challengeUpgrade(socket);
|
||||
try {
|
||||
requireLegacyOrSession(req);
|
||||
} catch (error) {
|
||||
if (sessionManager) {
|
||||
socket.write([
|
||||
'HTTP/1.1 401 Unauthorized',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'Connection: close',
|
||||
'',
|
||||
JSON.stringify({ error: 'Unauthorized' })
|
||||
].join('\r\n'));
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
legacyAuth.challengeUpgrade(socket);
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws: WsConnection) => {
|
||||
|
||||
@@ -51,9 +51,11 @@ type SteamUserLifecycleLike = {
|
||||
(event: 'loggedOn', listener: () => void): void;
|
||||
(event: 'webSession', listener: (sessionID: string, cookies: string[]) => void): void;
|
||||
(event: 'refreshToken', listener: (refreshToken: string) => void): void;
|
||||
(event: 'steamGuard', listener: (domain: string | null, callback: (code: string) => void, lastCodeWrong: boolean) => void): void;
|
||||
(event: 'error', listener: (error: unknown) => void): void;
|
||||
(event: 'disconnected', listener: (eresult: unknown, message?: string) => void): void;
|
||||
};
|
||||
steamID?: unknown;
|
||||
logOn: (options: LogOnOptions) => void;
|
||||
webLogOn: () => void;
|
||||
setPersona?: (state: number) => void;
|
||||
@@ -76,6 +78,34 @@ type SteamLifecycleOptions = {
|
||||
timers?: TimerApi;
|
||||
};
|
||||
|
||||
type SteamStatus = 'logged_out' | 'logging_in' | 'waiting_guard' | 'online' | 'error' | 'reconnecting';
|
||||
|
||||
type SteamGuardInfo = {
|
||||
guardType: 'email' | 'device';
|
||||
domain: string | null;
|
||||
lastCodeWrong: boolean;
|
||||
};
|
||||
|
||||
type SteamStatusSummary = {
|
||||
status: SteamStatus;
|
||||
requiresGuard: boolean;
|
||||
guardType: 'email' | 'device' | null;
|
||||
domain: string | null;
|
||||
lastCodeWrong: boolean;
|
||||
error: string | null;
|
||||
steamId: string | null;
|
||||
};
|
||||
|
||||
type SteamLoginRequest = {
|
||||
accountName?: unknown;
|
||||
password?: unknown;
|
||||
logonID?: unknown;
|
||||
};
|
||||
|
||||
type SteamLoginServiceOptions = SteamLifecycleOptions & {
|
||||
getDefaultLogonID?: () => number;
|
||||
};
|
||||
|
||||
function createDeferred<T = unknown>(): Deferred<T> {
|
||||
let resolve: (value: T | PromiseLike<T>) => void;
|
||||
let reject: (reason?: unknown) => void;
|
||||
@@ -141,6 +171,34 @@ function readRefreshToken(refreshTokenPath: string, fileSystem: RefreshTokenFile
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRefreshToken(refreshTokenPath: string) {
|
||||
try {
|
||||
fs.unlinkSync(refreshTokenPath);
|
||||
} catch (error) {
|
||||
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function steamIdToText(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
if (isRecord(value) && typeof value.getSteamID64 === 'function') return String(value.getSteamID64.call(value));
|
||||
if (isRecord(value) && value.steamid) return String(value.steamid);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function steamUnavailableError(status: SteamStatus) {
|
||||
return Object.assign(new Error('Steam is not logged in'), {
|
||||
statusCode: 503,
|
||||
steamStatus: status
|
||||
});
|
||||
}
|
||||
|
||||
function createHandledDeferred<T = unknown>(): Deferred<T> {
|
||||
const deferred = createDeferred<T>();
|
||||
deferred.promise.catch(() => {});
|
||||
return deferred;
|
||||
}
|
||||
|
||||
function buildLogOnOptions(config: SteamLifecycleConfig, refreshTokenPath: string, fileSystem: RefreshTokenFileSystem = fs): LogOnOptions {
|
||||
const refreshToken = readRefreshToken(refreshTokenPath, fileSystem);
|
||||
const base: LogOnOptions = {};
|
||||
@@ -306,13 +364,296 @@ function createSteamLifecycle(options: SteamLifecycleOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function createSteamLoginService(options: SteamLoginServiceOptions) {
|
||||
const {
|
||||
steamUser,
|
||||
steamCommunity,
|
||||
config = {},
|
||||
logger = console,
|
||||
refreshTokenPath = DEFAULT_REFRESH_TOKEN_PATH,
|
||||
fileSystem = fs,
|
||||
timers = { setTimeout, clearTimeout },
|
||||
getDefaultLogonID
|
||||
} = options;
|
||||
|
||||
if (!steamUser) {
|
||||
throw new Error('steamUser is required');
|
||||
}
|
||||
|
||||
let status: SteamStatus = 'logged_out';
|
||||
let lastError: string | null = null;
|
||||
let guard: SteamGuardInfo | null = null;
|
||||
let pendingGuardCallback: ((code: string) => void) | null = null;
|
||||
let loginDeferred = createHandledDeferred<boolean>();
|
||||
let webDeferred = createHandledDeferred<WebSession>();
|
||||
let retryTimer: TimerHandle | null = null;
|
||||
let retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
||||
let latestWebSession: WebSession | null = null;
|
||||
let stopped = false;
|
||||
let manualLogoff = false;
|
||||
let steamId: string | null = null;
|
||||
|
||||
function log(level: 'info' | 'warn' | 'error', message: string, meta?: unknown) {
|
||||
const method = logger[level] || logger.log || (() => {});
|
||||
method.call(logger, message, meta);
|
||||
}
|
||||
|
||||
function resetDeferreds() {
|
||||
loginDeferred = createHandledDeferred<boolean>();
|
||||
webDeferred = createHandledDeferred<WebSession>();
|
||||
}
|
||||
|
||||
function clearRetryTimer() {
|
||||
if (retryTimer) {
|
||||
timers.clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function baseLoginOptions(): LogOnOptions {
|
||||
const base: LogOnOptions = {};
|
||||
if (config.steamID) base.steamID = config.steamID;
|
||||
return base;
|
||||
}
|
||||
|
||||
function beginLogin(logOnOptions: LogOnOptions, label: string) {
|
||||
clearRetryTimer();
|
||||
resetDeferreds();
|
||||
status = 'logging_in';
|
||||
guard = null;
|
||||
pendingGuardCallback = null;
|
||||
lastError = null;
|
||||
try {
|
||||
log('info', `Logging on Steam with ${label}`);
|
||||
steamUser.logOn(logOnOptions);
|
||||
} catch (error) {
|
||||
handleLoginFailure(error);
|
||||
}
|
||||
return loginDeferred.promise;
|
||||
}
|
||||
|
||||
function tryTokenLogin(nextStatus: SteamStatus = 'logging_in'): Promise<boolean> {
|
||||
const refreshToken = readRefreshToken(refreshTokenPath, fileSystem);
|
||||
if (!refreshToken) {
|
||||
status = 'logged_out';
|
||||
guard = null;
|
||||
pendingGuardCallback = null;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
status = nextStatus;
|
||||
const logOnOptions = { ...baseLoginOptions(), refreshToken };
|
||||
return beginLogin(logOnOptions, 'refresh token');
|
||||
}
|
||||
|
||||
function scheduleReconnect(reason: unknown) {
|
||||
if (stopped || retryTimer) return;
|
||||
const refreshToken = readRefreshToken(refreshTokenPath, fileSystem);
|
||||
if (!refreshToken) {
|
||||
status = 'logged_out';
|
||||
return;
|
||||
}
|
||||
status = 'reconnecting';
|
||||
lastError = errorMessage(reason);
|
||||
const delay = retryDelayMs;
|
||||
retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_DELAY_MS);
|
||||
log('warn', `Steam reconnect scheduled in ${delay} ms`, { reason: errorMessage(reason) });
|
||||
retryTimer = timers.setTimeout(() => {
|
||||
retryTimer = null;
|
||||
tryTokenLogin('reconnecting').catch(() => {});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function handleLoginFailure(error: unknown) {
|
||||
if (stopped) return;
|
||||
lastError = errorMessage(error);
|
||||
guard = null;
|
||||
pendingGuardCallback = null;
|
||||
if (isRecoverableLoginError(error)) {
|
||||
scheduleReconnect(error);
|
||||
return;
|
||||
}
|
||||
status = 'error';
|
||||
log('error', 'Steam login failed', { error: lastError });
|
||||
loginDeferred.reject(error);
|
||||
webDeferred.reject(error);
|
||||
}
|
||||
|
||||
function refreshWebSession(): Promise<WebSession> {
|
||||
if (stopped) return Promise.reject(new Error('Steam login service is stopped'));
|
||||
webDeferred = createHandledDeferred<WebSession>();
|
||||
try {
|
||||
steamUser.webLogOn();
|
||||
} catch (error) {
|
||||
webDeferred.reject(error);
|
||||
}
|
||||
return webDeferred.promise;
|
||||
}
|
||||
|
||||
steamUser.on('steamGuard', (domain: string | null, callback: (code: string) => void, lastCodeWrong: boolean) => {
|
||||
status = 'waiting_guard';
|
||||
guard = {
|
||||
guardType: typeof domain === 'string' ? 'email' : 'device',
|
||||
domain: typeof domain === 'string' ? domain : null,
|
||||
lastCodeWrong: Boolean(lastCodeWrong)
|
||||
};
|
||||
pendingGuardCallback = callback;
|
||||
lastError = lastCodeWrong ? 'Steam Guard code was rejected' : null;
|
||||
});
|
||||
|
||||
steamUser.on('loggedOn', () => {
|
||||
clearRetryTimer();
|
||||
retryDelayMs = INITIAL_RETRY_DELAY_MS;
|
||||
status = 'online';
|
||||
guard = null;
|
||||
pendingGuardCallback = null;
|
||||
lastError = null;
|
||||
steamId = steamIdToText(steamUser.steamID);
|
||||
loginDeferred.resolve(true);
|
||||
log('info', 'Steam logged on');
|
||||
try {
|
||||
steamUser.setPersona?.(steamUser.EPersonaState?.Online || 1);
|
||||
} catch (_) {
|
||||
// setPersona is optional and not important enough to break runtime login.
|
||||
}
|
||||
refreshWebSession().catch((error: unknown) => {
|
||||
log('warn', 'Steam webLogOn failed after login', { error: errorMessage(error) });
|
||||
});
|
||||
});
|
||||
|
||||
steamUser.on('webSession', (sessionID: string, cookies: string[]) => {
|
||||
latestWebSession = { sessionID, cookies };
|
||||
if (steamCommunity && typeof steamCommunity.setCookies === 'function') {
|
||||
steamCommunity.setCookies(cookies);
|
||||
}
|
||||
if (steamCommunity && config.identitySecret && typeof steamCommunity.startConfirmationChecker === 'function') {
|
||||
steamCommunity.startConfirmationChecker(10 * 1000, config.identitySecret);
|
||||
}
|
||||
webDeferred.resolve(latestWebSession);
|
||||
});
|
||||
|
||||
steamUser.on('refreshToken', (refreshToken: string) => {
|
||||
if (!refreshToken) return;
|
||||
fileSystem.mkdirSync(path.dirname(refreshTokenPath), { recursive: true });
|
||||
fileSystem.writeFileSync(refreshTokenPath, `${refreshToken}\n`, 'utf8');
|
||||
log('info', 'Steam refresh token saved');
|
||||
});
|
||||
|
||||
steamUser.on('error', handleLoginFailure);
|
||||
|
||||
steamUser.on('disconnected', (eresult: unknown, message?: string) => {
|
||||
if (manualLogoff) {
|
||||
manualLogoff = false;
|
||||
return;
|
||||
}
|
||||
const error = new Error(message || `Steam disconnected: ${eresult || 'unknown'}`);
|
||||
if (status === 'online' || status === 'reconnecting') {
|
||||
scheduleReconnect(error);
|
||||
return;
|
||||
}
|
||||
handleLoginFailure(error);
|
||||
});
|
||||
|
||||
function getStatus(): SteamStatusSummary {
|
||||
return {
|
||||
status,
|
||||
requiresGuard: status === 'waiting_guard',
|
||||
guardType: guard?.guardType || null,
|
||||
domain: guard?.domain || null,
|
||||
lastCodeWrong: Boolean(guard?.lastCodeWrong),
|
||||
error: lastError,
|
||||
steamId
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
stopped = false;
|
||||
return tryTokenLogin();
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearRetryTimer();
|
||||
if (typeof steamUser.logOff === 'function') steamUser.logOff();
|
||||
},
|
||||
login(input: SteamLoginRequest) {
|
||||
if (status === 'logging_in' || status === 'waiting_guard' || status === 'reconnecting' || status === 'online') {
|
||||
throw Object.assign(new Error('Steam login is already active'), { statusCode: 409 });
|
||||
}
|
||||
const accountName = String(input.accountName || '').trim();
|
||||
const password = String(input.password || '');
|
||||
if (!accountName || !password) {
|
||||
throw Object.assign(new Error('accountName and password are required'), { statusCode: 400 });
|
||||
}
|
||||
const parsedLogonID = Number(input.logonID);
|
||||
const logonID = Number.isFinite(parsedLogonID) && parsedLogonID > 0
|
||||
? Math.floor(parsedLogonID)
|
||||
: getDefaultLogonID?.();
|
||||
const optionsForLogin: LogOnOptions = {
|
||||
...baseLoginOptions(),
|
||||
accountName,
|
||||
password
|
||||
};
|
||||
if (logonID) optionsForLogin.logonID = logonID;
|
||||
beginLogin(optionsForLogin, 'account credentials').catch(() => {});
|
||||
return getStatus();
|
||||
},
|
||||
submitGuard(code: unknown) {
|
||||
const value = String(code || '').trim();
|
||||
if (!value) throw Object.assign(new Error('Steam Guard code is required'), { statusCode: 400 });
|
||||
if (status !== 'waiting_guard' || !pendingGuardCallback) {
|
||||
throw Object.assign(new Error('Steam Guard is not pending'), { statusCode: 409 });
|
||||
}
|
||||
const callback = pendingGuardCallback;
|
||||
pendingGuardCallback = null;
|
||||
guard = null;
|
||||
lastError = null;
|
||||
status = 'logging_in';
|
||||
callback(value);
|
||||
return getStatus();
|
||||
},
|
||||
logout() {
|
||||
clearRetryTimer();
|
||||
manualLogoff = true;
|
||||
guard = null;
|
||||
pendingGuardCallback = null;
|
||||
latestWebSession = null;
|
||||
steamId = null;
|
||||
lastError = null;
|
||||
status = 'logged_out';
|
||||
resetDeferreds();
|
||||
deleteRefreshToken(refreshTokenPath);
|
||||
if (typeof steamUser.logOff === 'function') steamUser.logOff();
|
||||
return getStatus();
|
||||
},
|
||||
ensureOnline() {
|
||||
if (status !== 'online') throw steamUnavailableError(status);
|
||||
},
|
||||
waitForLogin() {
|
||||
return status === 'online' ? Promise.resolve(true) : loginDeferred.promise;
|
||||
},
|
||||
waitForWebSession() {
|
||||
return latestWebSession ? Promise.resolve(latestWebSession) : webDeferred.promise;
|
||||
},
|
||||
refreshWebSession,
|
||||
getLatestWebSession() {
|
||||
return latestWebSession;
|
||||
},
|
||||
getStatus,
|
||||
getRetryDelayMs() {
|
||||
return retryDelayMs;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_REFRESH_TOKEN_PATH,
|
||||
INITIAL_RETRY_DELAY_MS,
|
||||
MAX_RETRY_DELAY_MS,
|
||||
buildLogOnOptions,
|
||||
createDeferred,
|
||||
createSteamLoginService,
|
||||
createSteamLifecycle,
|
||||
deleteRefreshToken,
|
||||
isRecoverableLoginError,
|
||||
isUnrecoverableLoginError,
|
||||
readRefreshToken
|
||||
|
||||
83
test/backend-auth.test.ts
Normal file
83
test/backend-auth.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { TestContext } from 'node:test';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const { createAuthStore } = require('../src/auth/store');
|
||||
const { createSessionManager } = require('../src/auth/session');
|
||||
|
||||
function tempDbPath() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'steam-chat-auth-'));
|
||||
return path.join(dir, 'auth.sqlite');
|
||||
}
|
||||
|
||||
function requestWithCookie(cookie: string): IncomingMessage {
|
||||
return {
|
||||
headers: { cookie },
|
||||
socket: { remoteAddress: '127.0.0.1' }
|
||||
} as IncomingMessage;
|
||||
}
|
||||
|
||||
function cookiePair(setCookie: string) {
|
||||
return setCookie.split(';')[0];
|
||||
}
|
||||
|
||||
test('auth setup creates the first admin once and login rejects bad or disabled users', (t: TestContext) => {
|
||||
const store = createAuthStore({ dbPath: tempDbPath() });
|
||||
t.after(() => store.close());
|
||||
|
||||
assert.equal(store.requiresSetup(), true);
|
||||
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
|
||||
assert.equal(admin.role, 'admin');
|
||||
assert.equal(admin.disabled, false);
|
||||
assert.equal(store.requiresSetup(), false);
|
||||
assert.throws(() => store.createInitialAdmin({ username: 'next', password: 'password123' }), /already/);
|
||||
|
||||
assert.equal(store.authenticate('admin', 'wrong'), null);
|
||||
assert.equal(store.authenticate('admin', 'password123').username, 'admin');
|
||||
const user = store.createUser({ username: 'worker', password: 'password123', role: 'user' });
|
||||
store.updateUser(user.id, { disabled: true });
|
||||
assert.equal(store.authenticate('worker', 'password123'), null);
|
||||
});
|
||||
|
||||
test('session cookies reject tampering, expiry, and stale session_version', (t: TestContext) => {
|
||||
const store = createAuthStore({ dbPath: tempDbPath() });
|
||||
t.after(() => store.close());
|
||||
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
|
||||
const sessions = createSessionManager({ store });
|
||||
const cookie = cookiePair(sessions.createSetCookie(admin));
|
||||
|
||||
assert.equal(sessions.getSession(requestWithCookie(cookie))?.user.username, 'admin');
|
||||
assert.equal(sessions.getSession(requestWithCookie(`${cookie}x`)), null);
|
||||
|
||||
const expiredSessions = createSessionManager({ store, maxAgeMs: -1000 });
|
||||
const expired = cookiePair(expiredSessions.createSetCookie(admin));
|
||||
assert.equal(expiredSessions.getSession(requestWithCookie(expired)), null);
|
||||
|
||||
store.setPassword(admin.id, 'password456');
|
||||
assert.equal(sessions.getSession(requestWithCookie(cookie)), null);
|
||||
});
|
||||
|
||||
test('user management protects the current user and the last enabled admin', (t: TestContext) => {
|
||||
const store = createAuthStore({ dbPath: tempDbPath() });
|
||||
t.after(() => store.close());
|
||||
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
|
||||
const user = store.createUser({ username: 'worker', password: 'password123', role: 'user' });
|
||||
|
||||
assert.throws(() => store.updateUser(admin.id, { role: 'user' }), /last enabled admin/);
|
||||
assert.throws(() => store.updateUser(admin.id, { disabled: true }), /last enabled admin/);
|
||||
assert.throws(() => store.deleteUser(admin.id, user.id), /last enabled admin/);
|
||||
assert.throws(() => store.deleteUser(admin.id, admin.id), /current user/);
|
||||
|
||||
const admin2 = store.createUser({ username: 'admin2', password: 'password123', role: 'admin' });
|
||||
store.updateUser(admin.id, { role: 'user' });
|
||||
assert.equal(store.getUserById(admin.id).role, 'user');
|
||||
store.deleteUser(admin.id, admin2.id);
|
||||
assert.equal(store.getUserById(admin.id), null);
|
||||
});
|
||||
185
test/backend-service.test.ts
Normal file
185
test/backend-service.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
'use strict';
|
||||
|
||||
import type { EventEmitter as EventEmitterType } from 'node:events';
|
||||
import type { Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { TestContext } from 'node:test';
|
||||
import type { WebSocket as WsConnection } from 'ws';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const { createAuthStore } = require('../src/auth/store');
|
||||
const { createSessionManager } = require('../src/auth/session');
|
||||
const { createChatService } = require('../src/server/chat-service');
|
||||
|
||||
type TestSteamUser = EventEmitterType & {
|
||||
chat: {
|
||||
sendFriendMessage: (id: unknown, msg: unknown, callback: (error: Error | null) => void) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ChatServiceRuntime = {
|
||||
server: Server;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SteamStatus = {
|
||||
status: string;
|
||||
requiresGuard: boolean;
|
||||
guardType: null;
|
||||
domain: null;
|
||||
lastCodeWrong: boolean;
|
||||
error: null;
|
||||
steamId: null;
|
||||
};
|
||||
|
||||
type WsConstructor = new (url: string, options?: Record<string, unknown>) => WsConnection;
|
||||
const WsClient = WebSocket as WsConstructor;
|
||||
|
||||
function listen(server: Server): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve((server.address() as AddressInfo).port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cookiePair(setCookie: string | null) {
|
||||
assert.ok(setCookie);
|
||||
return setCookie.split(';')[0];
|
||||
}
|
||||
|
||||
function tempPath(name: string) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `steam-chat-${name}-`));
|
||||
return { dir, dbPath: path.join(dir, 'auth.sqlite'), logPath: path.join(dir, 'chat.jsonl') };
|
||||
}
|
||||
|
||||
function offlineSteamService() {
|
||||
const status: SteamStatus = {
|
||||
status: 'logged_out',
|
||||
requiresGuard: false,
|
||||
guardType: null,
|
||||
domain: null,
|
||||
lastCodeWrong: false,
|
||||
error: null,
|
||||
steamId: null
|
||||
};
|
||||
return {
|
||||
getStatus() {
|
||||
return status;
|
||||
},
|
||||
ensureOnline() {
|
||||
throw Object.assign(new Error('Steam is not logged in'), { statusCode: 503, steamStatus: status.status });
|
||||
},
|
||||
login() {
|
||||
return status;
|
||||
},
|
||||
logout() {
|
||||
return status;
|
||||
},
|
||||
submitGuard() {
|
||||
return status;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function wsOpen(url: string, cookie: string): Promise<WsConnection> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WsClient(url, { headers: { Cookie: cookie } });
|
||||
ws.once('open', () => resolve(ws));
|
||||
ws.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function wsRejects(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WsClient(url);
|
||||
ws.once('open', () => {
|
||||
ws.close();
|
||||
reject(new Error('WebSocket unexpectedly opened'));
|
||||
});
|
||||
ws.once('error', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
test('backend auth gates chat HTTP APIs and reports Steam offline as 503', async (t: TestContext) => {
|
||||
const paths = tempPath('backend-http');
|
||||
const store = createAuthStore({ dbPath: paths.dbPath });
|
||||
t.after(() => store.close());
|
||||
const sessions = createSessionManager({ store });
|
||||
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
|
||||
const cookie = cookiePair(sessions.createSetCookie(admin));
|
||||
const steamUser = new EventEmitter() as TestSteamUser;
|
||||
steamUser.chat = {
|
||||
sendFriendMessage(_id: unknown, _msg: unknown, callback: (error: Error | null) => void) {
|
||||
callback(null);
|
||||
}
|
||||
};
|
||||
const service = createChatService({
|
||||
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
|
||||
steamUser,
|
||||
logPath: paths.logPath,
|
||||
authStore: store,
|
||||
sessionManager: sessions,
|
||||
steamLoginService: offlineSteamService(),
|
||||
logger: { info() {}, warn() {}, error() {} }
|
||||
}) as ChatServiceRuntime;
|
||||
t.after(() => service.stop().catch(() => {}));
|
||||
const port = await listen(service.server);
|
||||
|
||||
assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200);
|
||||
assert.equal((await fetch(`http://127.0.0.1:${port}/history`)).status, 401);
|
||||
|
||||
const configResponse = await fetch(`http://127.0.0.1:${port}/api/config`, {
|
||||
headers: { Cookie: cookie }
|
||||
});
|
||||
assert.equal(configResponse.status, 200);
|
||||
|
||||
const sendResponse = await fetch(`http://127.0.0.1:${port}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ id: '42', msg: 'hello' })
|
||||
});
|
||||
assert.equal(sendResponse.status, 503);
|
||||
assert.deepEqual(await sendResponse.json(), {
|
||||
error: 'Steam is not logged in',
|
||||
steamStatus: 'logged_out'
|
||||
});
|
||||
});
|
||||
|
||||
test('backend WebSocket rejects missing cookies and accepts authenticated users', async (t: TestContext) => {
|
||||
const paths = tempPath('backend-ws');
|
||||
const store = createAuthStore({ dbPath: paths.dbPath });
|
||||
t.after(() => store.close());
|
||||
const sessions = createSessionManager({ store });
|
||||
const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' });
|
||||
const cookie = cookiePair(sessions.createSetCookie(admin));
|
||||
const steamUser = new EventEmitter() as TestSteamUser;
|
||||
steamUser.chat = {
|
||||
sendFriendMessage(_id: unknown, _msg: unknown, callback: (error: Error | null) => void) {
|
||||
callback(null);
|
||||
}
|
||||
};
|
||||
const service = createChatService({
|
||||
config: { host: '127.0.0.1', port: 0, wsPath: '/ws' },
|
||||
steamUser,
|
||||
logPath: paths.logPath,
|
||||
authStore: store,
|
||||
sessionManager: sessions,
|
||||
steamLoginService: offlineSteamService(),
|
||||
logger: { info() {}, warn() {}, error() {} }
|
||||
}) as ChatServiceRuntime;
|
||||
t.after(() => service.stop().catch(() => {}));
|
||||
const port = await listen(service.server);
|
||||
|
||||
await wsRejects(`ws://127.0.0.1:${port}/ws`);
|
||||
const ws = await wsOpen(`ws://127.0.0.1:${port}/ws`, cookie);
|
||||
t.after(() => ws.close());
|
||||
assert.equal(ws.readyState, WebSocket.OPEN);
|
||||
});
|
||||
110
test/steam-login-service.test.ts
Normal file
110
test/steam-login-service.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
import type { EventEmitter as EventEmitterType } from 'node:events';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const { createSteamLoginService } = require('../src/steam/lifecycle');
|
||||
|
||||
type LogOnOptions = {
|
||||
accountName?: string;
|
||||
password?: string;
|
||||
refreshToken?: string;
|
||||
logonID?: number;
|
||||
};
|
||||
|
||||
type LoginTestUser = EventEmitterType & {
|
||||
steamID?: unknown;
|
||||
logOn: (options: LogOnOptions) => void;
|
||||
webLogOn: () => void;
|
||||
logOff: () => void;
|
||||
};
|
||||
|
||||
function tempTokenPath() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'steam-chat-login-'));
|
||||
return path.join(dir, 'refresh.token');
|
||||
}
|
||||
|
||||
function createUser(logOnCalls: LogOnOptions[]): LoginTestUser {
|
||||
const user = new EventEmitter() as LoginTestUser;
|
||||
user.logOn = (options: LogOnOptions) => logOnCalls.push(options);
|
||||
user.webLogOn = () => user.emit('webSession', 'session-id', ['a=b']);
|
||||
user.logOff = () => {};
|
||||
return user;
|
||||
}
|
||||
|
||||
test('SteamLoginService starts without token as logged_out and token login does not require account credentials', async () => {
|
||||
const tokenPath = tempTokenPath();
|
||||
const calls: LogOnOptions[] = [];
|
||||
const user = createUser(calls);
|
||||
const service = createSteamLoginService({
|
||||
steamUser: user,
|
||||
refreshTokenPath: tokenPath,
|
||||
logger: { info() {}, warn() {}, error() {} }
|
||||
});
|
||||
|
||||
assert.equal(await service.start(), false);
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(service.getStatus().status, 'logged_out');
|
||||
|
||||
fs.writeFileSync(tokenPath, 'refresh-value\n');
|
||||
const tokenLogin = service.start();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0], { refreshToken: 'refresh-value' });
|
||||
user.emit('loggedOn');
|
||||
await tokenLogin;
|
||||
assert.equal(service.getStatus().status, 'online');
|
||||
});
|
||||
|
||||
test('SteamLoginService exposes Guard state and submits the pending callback', () => {
|
||||
const calls: LogOnOptions[] = [];
|
||||
const user = createUser(calls);
|
||||
const service = createSteamLoginService({
|
||||
steamUser: user,
|
||||
refreshTokenPath: tempTokenPath(),
|
||||
getDefaultLogonID: () => 12345,
|
||||
logger: { info() {}, warn() {}, error() {} }
|
||||
});
|
||||
|
||||
const status = service.login({ accountName: 'name', password: 'secret' });
|
||||
assert.equal(status.status, 'logging_in');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].accountName, 'name');
|
||||
assert.equal(calls[0].password, 'secret');
|
||||
assert.equal(calls[0].logonID, 12345);
|
||||
|
||||
let submitted = '';
|
||||
user.emit('steamGuard', null, (code: string) => {
|
||||
submitted = code;
|
||||
}, false);
|
||||
const waiting = service.getStatus();
|
||||
assert.equal(waiting.status, 'waiting_guard');
|
||||
assert.equal(waiting.guardType, 'device');
|
||||
assert.equal(waiting.requiresGuard, true);
|
||||
|
||||
service.submitGuard('ABCDE');
|
||||
assert.equal(submitted, 'ABCDE');
|
||||
assert.equal(service.getStatus().status, 'logging_in');
|
||||
});
|
||||
|
||||
test('SteamLoginService saves refresh tokens and logout deletes the persisted token', () => {
|
||||
const tokenPath = tempTokenPath();
|
||||
const calls: LogOnOptions[] = [];
|
||||
const user = createUser(calls);
|
||||
const service = createSteamLoginService({
|
||||
steamUser: user,
|
||||
refreshTokenPath: tokenPath,
|
||||
logger: { info() {}, warn() {}, error() {} }
|
||||
});
|
||||
|
||||
user.emit('refreshToken', 'next-token');
|
||||
assert.equal(fs.readFileSync(tokenPath, 'utf8'), 'next-token\n');
|
||||
service.logout();
|
||||
assert.equal(fs.existsSync(tokenPath), false);
|
||||
assert.equal(service.getStatus().status, 'logged_out');
|
||||
});
|
||||
1835
web/app.ts
1835
web/app.ts
File diff suppressed because it is too large
Load Diff
@@ -3,98 +3,16 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Steam Chat</title>
|
||||
<title>Steam Chat Admin</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<header class="side-head">
|
||||
<div>
|
||||
<h1>Steam Chat</h1>
|
||||
<p id="connectionText">连接中</p>
|
||||
</div>
|
||||
<button id="closeSidebar" class="icon-btn" type="button" title="关闭侧栏">×</button>
|
||||
</header>
|
||||
|
||||
<form id="openForm" class="open-form">
|
||||
<input id="targetInput" inputmode="numeric" autocomplete="off" placeholder="SteamID64">
|
||||
<button type="submit">打开</button>
|
||||
</form>
|
||||
|
||||
<div class="side-tools">
|
||||
<label>历史 <input id="historyLimit" type="number" min="1" max="500" step="10"></label>
|
||||
<button id="refreshAll" type="button">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="is-active" type="button" data-tab="conversations">最近</button>
|
||||
<button type="button" data-tab="friends">好友</button>
|
||||
<button type="button" data-tab="groups">群组</button>
|
||||
</div>
|
||||
|
||||
<section class="panel is-active" data-panel="conversations">
|
||||
<div id="conversationList" class="list"></div>
|
||||
</section>
|
||||
<section class="panel" data-panel="friends">
|
||||
<div id="friendList" class="list"></div>
|
||||
</section>
|
||||
<section class="panel" data-panel="groups">
|
||||
<div id="groupList" class="list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div id="backdrop" class="backdrop"></div>
|
||||
|
||||
<main class="chat">
|
||||
<header class="chat-head">
|
||||
<button id="openSidebar" class="icon-btn menu-btn" type="button" title="打开侧栏">☰</button>
|
||||
<div class="chat-title">
|
||||
<h2 id="chatTitle">未选择会话</h2>
|
||||
<p id="chatSubtitle">选择好友、群组或输入 SteamID64</p>
|
||||
</div>
|
||||
<div id="feedback" class="feedback">就绪</div>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="messages" aria-live="polite"></section>
|
||||
|
||||
<footer class="composer">
|
||||
<div id="uploadTray" class="upload-tray"></div>
|
||||
<div id="picker" class="picker" hidden>
|
||||
<div class="picker-head">
|
||||
<button class="is-active" type="button" data-picker="emoticons">表情</button>
|
||||
<button type="button" data-picker="stickers">贴纸</button>
|
||||
<input id="pickerSearch" type="search" placeholder="搜索">
|
||||
</div>
|
||||
<div id="pickerGrid" class="picker-grid"></div>
|
||||
</div>
|
||||
<div id="autocomplete" class="autocomplete" hidden></div>
|
||||
<div class="compose-row">
|
||||
<button id="pickerToggle" class="icon-btn" type="button" title="表情和贴纸">☺</button>
|
||||
<button id="fileButton" class="icon-btn" type="button" title="选择图片">+</button>
|
||||
<input id="fileInput" type="file" accept="image/*" multiple hidden>
|
||||
<textarea id="messageInput" rows="1" placeholder="发送消息"></textarea>
|
||||
<button id="sendButton" type="button">发送</button>
|
||||
</div>
|
||||
<form id="imageUrlForm" class="url-row">
|
||||
<input id="imageUrlInput" type="url" placeholder="图片 URL">
|
||||
<button type="submit">发送图片</button>
|
||||
</form>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="app"></div>
|
||||
<div id="dropOverlay" class="drop-overlay">释放发送图片</div>
|
||||
<div id="lightbox" class="lightbox" hidden>
|
||||
<button id="lightboxClose" class="icon-btn lightbox-close" type="button" title="关闭">×</button>
|
||||
<div class="lightbox-tools">
|
||||
<button id="zoomOut" type="button">-</button>
|
||||
<button id="zoomReset" type="button">100%</button>
|
||||
<button id="zoomIn" type="button">+</button>
|
||||
</div>
|
||||
<button id="lightboxClose" class="icon-btn lightbox-close" type="button" title="关闭">x</button>
|
||||
<img id="lightboxImage" alt="">
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
898
web/style.css
898
web/style.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user