From 19e1ccf855632fca57ae702a34ef5df8d7d3784c Mon Sep 17 00:00:00 2001 From: tursom Date: Wed, 24 Jun 2026 13:40:33 +0800 Subject: [PATCH] feat: add admin backend and compose deployment --- .dockerignore | 2 + .env.example | 4 + .gitignore | 2 + Dockerfile | 7 +- compose.yaml | 32 + docs/backend-redesign.md | 300 +++++ docs/docker-compose-deployment.md | 312 +++++ src/auth/session.ts | 194 +++ src/auth/store.ts | 326 +++++ src/config/load.ts | 18 +- src/index.ts | 60 +- src/paths.ts | 8 +- src/server/chat-service.ts | 275 ++++- src/steam/lifecycle.ts | 341 ++++++ test/backend-auth.test.ts | 83 ++ test/backend-service.test.ts | 185 +++ test/steam-login-service.test.ts | 110 ++ web/app.ts | 1835 ++++++++++++++--------------- web/index.html | 88 +- web/style.css | 898 +++++++------- 20 files changed, 3569 insertions(+), 1511 deletions(-) create mode 100644 .env.example create mode 100644 compose.yaml create mode 100644 docs/backend-redesign.md create mode 100644 docs/docker-compose-deployment.md create mode 100644 src/auth/session.ts create mode 100644 src/auth/store.ts create mode 100644 test/backend-auth.test.ts create mode 100644 test/backend-service.test.ts create mode 100644 test/steam-login-service.test.ts diff --git a/.dockerignore b/.dockerignore index 89bb223..f22c4b4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,8 @@ config.js .env session.json refresh.token +auth.sqlite +data/ logs/ *.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5849335 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 0d366c1..8019e54 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,12 @@ config.js # Logs logs/ *.log +data/ # Auth / Secrets session.json refresh.token +auth.sqlite # Build dist/ diff --git a/Dockerfile b/Dockerfile index 25e0efd..148938e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2de187b --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/docs/backend-redesign.md b/docs/backend-redesign.md new file mode 100644 index 0000000..9548efb --- /dev/null +++ b/docs/backend-redesign.md @@ -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::`。 +- 登录、重置密码、禁用、启用、角色变更时不记录明文密码。 + +首次访问时,如果 `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 +``` diff --git a/docs/docker-compose-deployment.md b/docs/docker-compose-deployment.md new file mode 100644 index 0000000..4a1cbec --- /dev/null +++ b/docs/docker-compose-deployment.md @@ -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 +``` + diff --git a/src/auth/session.ts b/src/auth/session.ts new file mode 100644 index 0000000..dd1d322 --- /dev/null +++ b/src/auth/session.ts @@ -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 { + const cookies: Record = {}; + 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 +}; diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 0000000..ff83ee8 --- /dev/null +++ b/src/auth/store.ts @@ -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; + +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 +}; diff --git a/src/config/load.ts b/src/config/load.ts index c77812a..62a3a11 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -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) { diff --git a/src/index.ts b/src/index.ts index 6173974..907d36d 100644 --- a/src/index.ts +++ b/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 = {}; +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, diff --git a/src/paths.ts b/src/paths.ts index 803f69b..22ad782 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -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, diff --git a/src/server/chat-service.ts b/src/server/chat-service.ts index bc752cd..191d120 100644 --- a/src/server/chat-service.ts +++ b/src/server/chat-service.ts @@ -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; 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 = {}) { 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(); const recentSentText = new Map(); const recentSentImages = new Map(); @@ -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(operation: () => Promise | T, needsWebSession = false): Promise { + 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) => { diff --git a/src/steam/lifecycle.ts b/src/steam/lifecycle.ts index d67dc0d..551bcff 100644 --- a/src/steam/lifecycle.ts +++ b/src/steam/lifecycle.ts @@ -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(): Deferred { let resolve: (value: T | PromiseLike) => 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(): Deferred { + const deferred = createDeferred(); + 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(); + let webDeferred = createHandledDeferred(); + 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(); + webDeferred = createHandledDeferred(); + } + + 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 { + 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 { + if (stopped) return Promise.reject(new Error('Steam login service is stopped')); + webDeferred = createHandledDeferred(); + 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 diff --git a/test/backend-auth.test.ts b/test/backend-auth.test.ts new file mode 100644 index 0000000..f981f21 --- /dev/null +++ b/test/backend-auth.test.ts @@ -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); +}); diff --git a/test/backend-service.test.ts b/test/backend-service.test.ts new file mode 100644 index 0000000..fee2860 --- /dev/null +++ b/test/backend-service.test.ts @@ -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; +}; + +type SteamStatus = { + status: string; + requiresGuard: boolean; + guardType: null; + domain: null; + lastCodeWrong: boolean; + error: null; + steamId: null; +}; + +type WsConstructor = new (url: string, options?: Record) => WsConnection; +const WsClient = WebSocket as WsConstructor; + +function listen(server: Server): Promise { + 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 { + 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 { + 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); +}); diff --git a/test/steam-login-service.test.ts b/test/steam-login-service.test.ts new file mode 100644 index 0000000..94dbdf7 --- /dev/null +++ b/test/steam-login-service.test.ts @@ -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'); +}); diff --git a/web/app.ts b/web/app.ts index 389f1b8..7e72e8e 100644 --- a/web/app.ts +++ b/web/app.ts @@ -1,5 +1,33 @@ +type Role = 'admin' | 'user'; +type View = 'steam' | 'chat' | 'users' | 'account'; type Tone = 'muted' | 'ok' | 'warn' | 'error'; -type PickerTab = 'emoticons' | 'stickers'; + +type User = { + id: number; + username: string; + role: Role; + disabled: boolean; + sessionVersion: number; + createdAt: string; + updatedAt: string; + lastLoginAt: string | null; +}; + +type SteamStatus = { + status: string; + requiresGuard: boolean; + guardType: 'email' | 'device' | null; + domain: string | null; + lastCodeWrong: boolean; + error: string | null; + steamId: string | null; +}; + +type MeResponse = { + needsSetup: boolean; + user: User | null; + steam: SteamStatus; +}; type ListEntry = Record & { id: string; @@ -8,11 +36,9 @@ type ListEntry = Record & { preview?: string; updatedAt?: string; gameName?: string; - game?: string; online?: boolean; clanId?: string; clanid?: string; - messageCount?: number; }; type MessageItem = ListEntry & { @@ -22,8 +48,6 @@ type MessageItem = ListEntry & { sentAt?: string; message?: string; imageUrl?: string | null; - lastType?: string; - lastEcho?: boolean; }; type InventoryItem = Record & { @@ -32,739 +56,781 @@ type InventoryItem = Record & { }; type WsPayload = Record & { - requestId?: string; type?: string; - error?: string; id?: string; - msg?: string; + name?: string; message?: string; - wsPath?: string; - items?: MessageItem[]; - history?: MessageItem[]; - conversations?: ListEntry[]; - friends?: ListEntry[]; - groups?: ListEntry[]; - emoticons?: InventoryItem[]; - stickers?: InventoryItem[]; -}; - -type PendingRequest = { - resolve: (value: WsPayload) => void; - reject: (error: Error) => void; - timer: ReturnType; -}; - -type MessageRow = HTMLElement & { - _item?: MessageItem; -}; - -type TextPart = { type: 'text'; text: string }; -type LinkPart = { type: 'link'; href: string; label: string }; -type EmoticonPart = { type: 'emoticon'; name: string }; -type StickerPart = { type: 'sticker'; stickerType: string }; -type ImagePart = { type: 'image'; url: string }; -type OgPart = { type: 'og'; url: string; image: string; title: string; fallback: string }; -type RichPart = TextPart | LinkPart | EmoticonPart | StickerPart | ImagePart | OgPart; - -type Els = { - sidebar: HTMLElement; - backdrop: HTMLElement; - openSidebar: HTMLButtonElement; - closeSidebar: HTMLButtonElement; - connectionText: HTMLElement; - feedback: HTMLElement; - openForm: HTMLFormElement; - targetInput: HTMLInputElement; - historyLimit: HTMLInputElement; - refreshAll: HTMLButtonElement; - conversationList: HTMLElement; - friendList: HTMLElement; - groupList: HTMLElement; - chatTitle: HTMLElement; - chatSubtitle: HTMLElement; - messages: HTMLElement; - uploadTray: HTMLElement; - picker: HTMLElement; - pickerGrid: HTMLElement; - pickerSearch: HTMLInputElement; - pickerToggle: HTMLButtonElement; - autocomplete: HTMLElement; - fileButton: HTMLButtonElement; - fileInput: HTMLInputElement; - messageInput: HTMLTextAreaElement; - sendButton: HTMLButtonElement; - imageUrlForm: HTMLFormElement; - imageUrlInput: HTMLInputElement; - dropOverlay: HTMLElement; - lightbox: HTMLElement; - lightboxImage: HTMLImageElement; - lightboxClose: HTMLButtonElement; - zoomIn: HTMLButtonElement; - zoomOut: HTMLButtonElement; - zoomReset: HTMLButtonElement; + imageUrl?: string | null; + echo?: boolean; + date?: string; + sentAt?: string; + error?: string; }; type AppState = { - activeId: string; - activeName: string; - historyLimit: number; + me: User | null; + needsSetup: boolean; + steam: SteamStatus; + view: View; + users: User[]; conversations: ListEntry[]; friends: ListEntry[]; groups: ListEntry[]; emoticons: InventoryItem[]; stickers: InventoryItem[]; - knownEmoticons: Set; + activeId: string; + activeName: string; + historyLimit: number; + wsPath: string; ws: WebSocket | null; - pending: Map; reconnectTimer: ReturnType | null; - pickerTab: PickerTab; - autocompleteIndex: number; - unread: number; - objectUrls: Set; - imageRequests: Set; + statusTimer: ReturnType | null; + feedback: string; + feedbackTone: Tone; }; -const $ = (selector: string, root: ParentNode = document): T => { - const node = root.querySelector(selector); - if (!node) throw new Error(`Missing element: ${selector}`); - return node; +const defaultSteamStatus: SteamStatus = { + status: 'logged_out', + requiresGuard: false, + guardType: null, + domain: null, + lastCodeWrong: false, + error: null, + steamId: null }; -const $$ = (selector: string, root: ParentNode = document): T[] => [...root.querySelectorAll(selector)]; -const els: Els = { - sidebar: $('#sidebar'), - backdrop: $('#backdrop'), - openSidebar: $('#openSidebar'), - closeSidebar: $('#closeSidebar'), - connectionText: $('#connectionText'), - feedback: $('#feedback'), - openForm: $('#openForm'), - targetInput: $('#targetInput'), - historyLimit: $('#historyLimit'), - refreshAll: $('#refreshAll'), - conversationList: $('#conversationList'), - friendList: $('#friendList'), - groupList: $('#groupList'), - chatTitle: $('#chatTitle'), - chatSubtitle: $('#chatSubtitle'), - messages: $('#messages'), - uploadTray: $('#uploadTray'), - picker: $('#picker'), - pickerGrid: $('#pickerGrid'), - pickerSearch: $('#pickerSearch'), - pickerToggle: $('#pickerToggle'), - autocomplete: $('#autocomplete'), - fileButton: $('#fileButton'), - fileInput: $('#fileInput'), - messageInput: $('#messageInput'), - sendButton: $('#sendButton'), - imageUrlForm: $('#imageUrlForm'), - imageUrlInput: $('#imageUrlInput'), - dropOverlay: $('#dropOverlay'), - lightbox: $('#lightbox'), - lightboxImage: $('#lightboxImage'), - lightboxClose: $('#lightboxClose'), - zoomIn: $('#zoomIn'), - zoomOut: $('#zoomOut'), - zoomReset: $('#zoomReset') -}; +const root = document.querySelector('#app'); +if (!root) throw new Error('Missing app root'); const state: AppState = { - activeId: localStorage.getItem('steam-chat.target') || '', - activeName: '', - historyLimit: clampLimit(localStorage.getItem('steam-chat.history-limit') || 100), + me: null, + needsSetup: false, + steam: defaultSteamStatus, + view: (localStorage.getItem('steam-chat.view') as View) || 'steam', + users: [], conversations: [], friends: [], groups: [], emoticons: [], stickers: [], - knownEmoticons: new Set(), + activeId: localStorage.getItem('steam-chat.target') || '', + activeName: '', + historyLimit: clampLimit(localStorage.getItem('steam-chat.history-limit') || 100), + wsPath: '/ws', ws: null, - pending: new Map(), reconnectTimer: null, - pickerTab: 'emoticons', - autocompleteIndex: 0, - unread: 0, - objectUrls: new Set(), - imageRequests: new Set() + statusTimer: null, + feedback: '就绪', + feedbackTone: 'muted' }; -const baseTitle = document.title; - function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } +function create(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +function clear(node: HTMLElement) { + node.replaceChildren(); +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error || ''); } -function asListEntries(value: unknown): ListEntry[] { - return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; -} - -function asMessageItems(value: unknown): MessageItem[] { - return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; -} - -function messageItemFromPayload(payload: WsPayload): MessageItem { - return { - ...payload, - id: String(payload.id || ''), - name: typeof payload.name === 'string' ? payload.name : undefined, - type: typeof payload.type === 'string' ? payload.type : undefined, - echo: Boolean(payload.echo), - date: typeof payload.date === 'string' ? payload.date : undefined, - sentAt: typeof payload.sentAt === 'string' ? payload.sentAt : undefined, - message: typeof payload.message === 'string' ? payload.message : undefined, - imageUrl: typeof payload.imageUrl === 'string' ? payload.imageUrl : null - }; -} - -function asInventoryItems(value: unknown): InventoryItem[] { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - function clampLimit(value: unknown): number { const parsed = Number.parseInt(String(value), 10); if (!Number.isFinite(parsed) || parsed <= 0) return 100; return Math.min(parsed, 500); } +function asListEntries(value: unknown): ListEntry[] { + return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; +} + +function asMessages(value: unknown): MessageItem[] { + return Array.isArray(value) ? value.filter(isRecord).map((item) => ({ ...item, id: String(item.id || '') })) : []; +} + +function asInventory(value: unknown): InventoryItem[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + function setFeedback(text: string, tone: Tone = 'muted') { - els.feedback.textContent = text; - els.feedback.dataset.tone = tone; - els.feedback.style.color = { - muted: 'var(--muted)', - ok: 'var(--green)', - warn: 'var(--orange)', - error: 'var(--danger)' - }[tone] || 'var(--muted)'; + state.feedback = text; + state.feedbackTone = tone; + const node = document.querySelector('#feedback'); + if (node) { + node.textContent = text; + node.dataset.tone = tone; + } } -function setConnection(text: string, online = false) { - els.connectionText.textContent = text; - els.connectionText.style.color = online ? 'var(--green)' : 'var(--muted)'; +function steamLabel(status = state.steam.status): string { + const labels: Record = { + logged_out: '未连接', + logging_in: '登录中', + waiting_guard: '等待验证', + online: '在线', + error: '异常', + reconnecting: '重连中' + }; + return labels[status] || status; } -async function apiGet(path: string): Promise { - const response = await fetch(path, { headers: { Accept: 'application/json' } }); - const payload: unknown = await response.json().catch((): null => null); - if (!response.ok) throw new Error(isRecord(payload) && typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`); - return payload; +function steamOnline() { + return state.steam.status === 'online'; } -async function apiPost(path: string, body: Record): Promise { +function updateSteamStatus(status: SteamStatus) { + state.steam = { ...defaultSteamStatus, ...status }; + const badge = document.querySelector('#steamBadge'); + if (badge) { + badge.textContent = steamLabel(); + badge.dataset.status = state.steam.status; + } + const hint = document.querySelector('#steamHint'); + if (hint) hint.textContent = state.steam.error || (state.steam.steamId ? `SteamID ${state.steam.steamId}` : '后台服务已启动'); + updateChatAvailability(); +} + +async function api(path: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers || {}); + headers.set('Accept', 'application/json'); + if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); const response = await fetch(path, { - method: 'POST', - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + ...options, + headers, + credentials: 'same-origin' }); const payload: unknown = await response.json().catch((): null => null); - if (!response.ok) throw new Error(isRecord(payload) && typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`); + if (!response.ok) { + if (response.status === 401 && !path.startsWith('/api/auth/me') && !path.startsWith('/api/auth/login')) { + stopWebSocket(); + state.me = null; + state.needsSetup = false; + renderLogin(); + } + throw new Error(isRecord(payload) && typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`); + } return payload; } -function requestId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +function jsonBody(body: Record): RequestInit { + return { + method: 'POST', + body: JSON.stringify(body) + }; } -function wsIsOpen(): boolean { - return Boolean(state.ws && state.ws.readyState === WebSocket.OPEN); +function formValue(form: HTMLFormElement, name: string): string { + const value = new FormData(form).get(name); + return typeof value === 'string' ? value : ''; } -function wsRequest(payload: WsPayload, timeoutMs = 20000): Promise { - if (!wsIsOpen()) return Promise.reject(new Error('WebSocket 未连接')); - const id = payload.requestId || requestId(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - state.pending.delete(id); - reject(new Error('请求超时')); - }, timeoutMs); - state.pending.set(id, { resolve, reject, timer }); - state.ws?.send(JSON.stringify({ ...payload, requestId: id })); - }); +function authPage(title: string, subtitle: string, form: HTMLElement) { + stopStatusPolling(); + stopWebSocket(); + clear(root); + const page = create('main', 'auth-page'); + const panel = create('section', 'auth-panel'); + panel.append(create('h1', '', title), create('p', 'muted', subtitle), form); + page.append(panel); + root.append(page); } -function settlePending(payload: WsPayload): boolean { - if (!payload.requestId || !state.pending.has(payload.requestId)) return false; - const pending = state.pending.get(payload.requestId); - if (!pending) return false; - clearTimeout(pending.timer); - state.pending.delete(payload.requestId); - if (payload.type === 'error') pending.reject(new Error(payload.error || '请求失败')); - else pending.resolve(payload); - return true; -} - -async function wsOrHttp(wsPayload: WsPayload, httpPath: string, fallback: unknown): Promise { - if (wsIsOpen()) return wsRequest(wsPayload); - if (!httpPath) return fallback; - return apiGet(httpPath); -} - -function connectWebSocket(wsPath: string) { - if (state.reconnectTimer) clearTimeout(state.reconnectTimer); - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${location.host}${wsPath || '/ws'}`); - state.ws = ws; - ws.addEventListener('open', () => setConnection('已连接', true)); - ws.addEventListener('message', (event) => { - let payload: WsPayload; +function renderSetup() { + const form = create('form', 'stack-form') as HTMLFormElement; + form.innerHTML = ` + + + +

+ `; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const errorNode = form.querySelector('#setupError'); try { - const parsed: unknown = JSON.parse(String(event.data)); - payload = isRecord(parsed) ? parsed : {}; - } catch { - return; + await api('/api/auth/setup', jsonBody({ + username: formValue(form, 'username'), + password: formValue(form, 'password') + })); + await bootstrap(); + } catch (error) { + if (errorNode) errorNode.textContent = errorMessage(error); } - if (settlePending(payload)) return; - handleRealtime(payload); }); - ws.addEventListener('close', () => { - setConnection('正在重连', false); - for (const pending of state.pending.values()) { - clearTimeout(pending.timer); - pending.reject(new Error('WebSocket 已断开')); - } - state.pending.clear(); - state.reconnectTimer = setTimeout(() => connectWebSocket(wsPath), 1500); - markUploadsFailed('连接断开'); - }); - ws.addEventListener('error', () => setConnection('连接异常', false)); + authPage('初始化 Steam Chat 后台', '创建第一个管理员后,再进入工作台连接 Steam。', form); } -function handleRealtime(payload: WsPayload) { - if (payload.type === 'ready') { - refreshInitialData(); +function renderLogin() { + const form = create('form', 'stack-form') as HTMLFormElement; + form.innerHTML = ` + + + +

+ `; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const errorNode = form.querySelector('#loginError'); + try { + await api('/api/auth/login', jsonBody({ + username: formValue(form, 'username'), + password: formValue(form, 'password') + })); + await bootstrap(); + } catch (error) { + if (errorNode) errorNode.textContent = errorMessage(error); + } + }); + authPage('Steam Chat 后台登录', '登录后可使用已连接的 Steam 会话聊天,管理员可管理用户和 Steam 登录。', form); +} + +function navButton(view: View, label: string) { + const button = create('button', state.view === view ? 'is-active' : '', label); + button.type = 'button'; + button.addEventListener('click', () => { + state.view = view; + localStorage.setItem('steam-chat.view', view); + renderShell(); + }); + return button; +} + +function renderShell() { + if (!state.me) { + renderLogin(); return; } - if (payload.type === 'message' || payload.type === 'image') { - const item = messageItemFromPayload(payload); - mergeConversation(item); - if (item.id === state.activeId) appendMessage(item); - notify(item); - renderLists(); + if (state.me.role !== 'admin' && state.view === 'users') state.view = 'steam'; + clear(root); + const shell = create('div', 'admin-shell'); + const sidebar = create('aside', 'admin-sidebar'); + const brand = create('div', 'brand'); + brand.append(create('strong', '', 'Steam Chat'), create('span', '', state.me.username)); + const nav = create('nav'); + nav.append(navButton('steam', 'Steam 连接'), navButton('chat', '聊天')); + if (state.me.role === 'admin') nav.append(navButton('users', '用户管理')); + nav.append(navButton('account', '账号')); + sidebar.append(brand, nav); + + const main = create('main', 'workspace'); + const top = create('header', 'topbar'); + const title = create('div'); + title.append(create('h1', '', pageTitle()), create('p', 'muted', pageSubtitle())); + const status = create('div', 'top-actions'); + const badge = create('span', 'steam-badge', steamLabel()); + badge.id = 'steamBadge'; + badge.dataset.status = state.steam.status; + const hint = create('span', 'top-hint', state.steam.error || (state.steam.steamId ? `SteamID ${state.steam.steamId}` : '后台服务已启动')); + hint.id = 'steamHint'; + const logout = create('button', 'ghost-btn', '退出后台'); + logout.type = 'button'; + logout.addEventListener('click', () => logoutApp()); + status.append(badge, hint, logout); + top.append(title, status); + const content = create('section', 'content'); + content.append(renderCurrentView()); + const feedback = create('div', 'feedback', state.feedback); + feedback.id = 'feedback'; + feedback.dataset.tone = state.feedbackTone; + main.append(top, content, feedback); + shell.append(sidebar, main); + root.append(shell); + if (state.view === 'chat') void refreshChatData(); + if (state.view === 'users' && state.me.role === 'admin') void loadUsers(); + ensureWebSocket(); + startStatusPolling(); +} + +function pageTitle() { + if (state.view === 'chat') return '聊天'; + if (state.view === 'users') return '用户管理'; + if (state.view === 'account') return '账号'; + return 'Steam 连接'; +} + +function pageSubtitle() { + if (state.view === 'chat') return steamOnline() ? '好友、群组、历史和实时消息' : 'Steam 未在线,聊天操作已禁用'; + if (state.view === 'users') return '后台用户、角色和启用状态'; + if (state.view === 'account') return '修改当前后台账号密码'; + return state.me?.role === 'admin' ? '管理员在这里完成 Steam 登录和 Guard 验证' : '等待管理员连接 Steam'; +} + +function renderCurrentView() { + if (state.view === 'chat') return renderChatView(); + if (state.view === 'users' && state.me?.role === 'admin') return renderUsersView(); + if (state.view === 'account') return renderAccountView(); + return renderSteamView(); +} + +function renderSteamView() { + const view = create('div', 'steam-view'); + const summary = create('section', 'status-panel'); + const statusText = create('strong', '', steamLabel()); + const detail = create('p', 'muted', state.steam.error || (state.steam.steamId ? `当前 SteamID:${state.steam.steamId}` : '当前没有可用的 Steam 会话')); + summary.append(statusText, detail); + view.append(summary); + + if (state.me?.role !== 'admin') { + view.append(create('p', 'muted', '普通用户只能在 Steam 在线后使用聊天。')); + return view; + } + + if (state.steam.requiresGuard) { + const guardForm = create('form', 'inline-form') as HTMLFormElement; + const guardText = state.steam.guardType === 'email' + ? `邮箱验证码${state.steam.domain ? `:${state.steam.domain}` : ''}` + : '手机 2FA 验证码'; + guardForm.innerHTML = ` + + + `; + if (state.steam.lastCodeWrong) guardForm.prepend(create('p', 'warn-text', '上一次验证码被拒绝,请等待新的验证码后再提交。')); + guardForm.addEventListener('submit', async (event) => { + event.preventDefault(); + try { + const result = await api('/api/steam/guard', jsonBody({ code: formValue(guardForm, 'code') })); + updateSteamStatus(result as SteamStatus); + renderShell(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } + }); + view.append(guardForm); + } else if (!['logging_in', 'online', 'reconnecting'].includes(state.steam.status)) { + const loginForm = create('form', 'stack-form narrow-form') as HTMLFormElement; + loginForm.innerHTML = ` + + + + + `; + loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const rawLogonID = formValue(loginForm, 'logonID').trim(); + try { + const result = await api('/api/steam/login', jsonBody({ + accountName: formValue(loginForm, 'accountName'), + password: formValue(loginForm, 'password'), + ...(rawLogonID ? { logonID: Number(rawLogonID) } : {}) + })); + updateSteamStatus(result as SteamStatus); + renderShell(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } + }); + view.append(loginForm); + } + + const logout = create('button', 'danger-btn', '退出 Steam 并删除 token'); + logout.type = 'button'; + logout.disabled = state.steam.status === 'logged_out'; + logout.addEventListener('click', async () => { + try { + const result = await api('/api/steam/logout', { method: 'POST' }); + updateSteamStatus(result as SteamStatus); + renderShell(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } + }); + view.append(logout); + return view; +} + +function renderUsersView() { + const view = create('div', 'users-view'); + const form = create('form', 'inline-form user-create') as HTMLFormElement; + form.innerHTML = ` + + + + + `; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + try { + await api('/api/users', jsonBody({ + username: formValue(form, 'username'), + password: formValue(form, 'password'), + role: formValue(form, 'role') || 'user' + })); + form.reset(); + await loadUsers(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } + }); + const table = create('div', 'user-table'); + table.id = 'userTable'; + view.append(form, table); + renderUserTable(table); + return view; +} + +function renderUserTable(container: HTMLElement) { + clear(container); + if (!state.users.length) { + container.append(create('div', 'empty', '暂无用户')); return; } - if (payload.type === 'error') setFeedback(payload.error || '请求失败', 'error'); -} - -function create(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); - if (className) node.className = className; - if (text !== '') node.textContent = text; - return node; -} - -function formatShortTime(value: unknown): string { - const date = parseDate(value); - if (!date) return ''; - if (date.toDateString() === new Date().toDateString()) { - return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + for (const user of state.users) { + const row = create('article', 'user-row'); + const info = create('div'); + info.append(create('strong', '', user.username), create('span', 'muted', `${user.role === 'admin' ? '管理员' : '普通用户'} · ${user.disabled ? '已禁用' : '启用'}`)); + const actions = create('div', 'row-actions'); + const role = create('button', 'ghost-btn', user.role === 'admin' ? '降为用户' : '设为管理员'); + role.type = 'button'; + role.disabled = user.id === state.me?.id; + role.addEventListener('click', () => patchUser(user.id, { role: user.role === 'admin' ? 'user' : 'admin' })); + const disabled = create('button', 'ghost-btn', user.disabled ? '启用' : '禁用'); + disabled.type = 'button'; + disabled.disabled = user.id === state.me?.id; + disabled.addEventListener('click', () => patchUser(user.id, { disabled: !user.disabled })); + const password = create('button', 'ghost-btn', '重置密码'); + password.type = 'button'; + password.addEventListener('click', () => resetUserPassword(user.id)); + const remove = create('button', 'danger-btn', '删除'); + remove.type = 'button'; + remove.disabled = user.id === state.me?.id; + remove.addEventListener('click', () => deleteUser(user.id)); + actions.append(role, disabled, password, remove); + row.append(info, actions); + container.append(row); } - return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }); } -function parseDate(value: unknown): Date | null { - if (!value) return null; - const parsed = new Date(String(value).replace(' ', 'T')); - return Number.isNaN(parsed.getTime()) ? null : parsed; +async function loadUsers() { + if (state.me?.role !== 'admin') return; + try { + const payload = await api('/api/users'); + state.users = isRecord(payload) && Array.isArray(payload.users) ? payload.users as User[] : []; + const table = document.querySelector('#userTable'); + if (table) renderUserTable(table); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } } -function avatar(item: ListEntry) { - const node = create('span', 'avatar'); +async function patchUser(id: number, patch: Record) { + try { + await api(`/api/users/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch) + }); + await loadUsers(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function resetUserPassword(id: number) { + const password = window.prompt('输入新密码,至少 8 位'); + if (!password) return; + try { + await api(`/api/users/${id}/password`, jsonBody({ password })); + setFeedback('密码已重置', 'ok'); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function deleteUser(id: number) { + if (!window.confirm('确认删除该后台用户?')) return; + try { + await api(`/api/users/${id}`, { method: 'DELETE' }); + await loadUsers(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +function renderAccountView() { + const form = create('form', 'stack-form narrow-form') as HTMLFormElement; + form.innerHTML = ` + + + + `; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + try { + await api('/api/auth/password', jsonBody({ + oldPassword: formValue(form, 'oldPassword'), + newPassword: formValue(form, 'newPassword') + })); + form.reset(); + setFeedback('密码已修改', 'ok'); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } + }); + return form; +} + +function renderChatView() { + const view = create('div', 'chat-layout'); + const lists = create('aside', 'chat-lists'); + const openForm = create('form', 'open-chat-form') as HTMLFormElement; + openForm.innerHTML = ` + + + `; + openForm.addEventListener('submit', (event) => { + event.preventDefault(); + openConversation(formValue(openForm, 'target')); + }); + const tabs = create('div', 'list-sections'); + tabs.append(renderListSection('最近', state.conversations), renderListSection('好友', state.friends), renderListSection('群组', state.groups)); + lists.append(openForm, tabs); + + const thread = create('section', 'thread'); + const head = create('header', 'thread-head'); + head.append(create('div', '', state.activeName || state.activeId || '未选择会话'), create('span', 'muted', state.activeId || '选择好友、群组或输入 SteamID64')); + const messages = create('div', 'messages'); + messages.id = 'messages'; + const composer = renderComposer(); + thread.append(head, messages, composer); + view.append(lists, thread); + setTimeout(() => { + if (state.activeId) void loadHistory(); + else renderHistory([]); + updateChatAvailability(); + }, 0); + return view; +} + +function renderListSection(title: string, items: ListEntry[]) { + const section = create('section', 'list-section'); + section.append(create('h2', '', title)); + const body = create('div'); + if (!items.length) { + body.append(create('div', 'empty small', '暂无数据')); + } else { + for (const item of items) body.append(renderListItem(item)); + } + section.append(body); + return section; +} + +function renderListItem(item: ListEntry) { + const button = create('button', `list-item${item.id === state.activeId ? ' is-active' : ''}`); + button.type = 'button'; + const avatar = create('span', 'avatar', String(item.name || item.id || '?').slice(0, 1).toUpperCase()); if (item.avatar) { const image = document.createElement('img'); image.src = item.avatar; image.alt = ''; - node.append(image); - } else { - node.textContent = String(item.name || item.id || '?').slice(0, 1).toUpperCase(); + avatar.replaceChildren(image); } - return node; -} - -type ListItemOptions = { - preview?: string; - meta?: string; - topRight?: string; - online?: boolean; - avatar?: string; -}; - -function listItem(item: ListEntry, { preview = '', meta = '', topRight = '', online = false }: ListItemOptions = {}) { - const button = create('button', `list-item${item.id === state.activeId ? ' is-active' : ''}`); - button.type = 'button'; - button.dataset.id = item.id; - button.append(avatar(item)); - const body = create('span', 'item-body'); - const top = create('span', 'item-top'); - const presence = create('span', `presence${online ? ' is-online' : ''}`); - top.append(presence, create('span', 'item-name', item.name || item.id)); - if (topRight) top.append(create('span', 'item-time', topRight)); - body.append(top); - if (preview) body.append(create('span', 'item-preview', preview)); - if (meta) body.append(create('span', 'item-meta', meta)); - button.append(body); + body.append(create('strong', '', item.name || item.id), create('span', 'muted', item.preview || item.gameName || item.clanId || item.clanid || item.id)); + button.append(avatar, body); button.addEventListener('click', () => openConversation(item.id, item.name || item.id)); return button; } -function renderList(container: HTMLElement, items: T[], mapItem: (item: T) => HTMLElement) { - if (!items.length) { - container.replaceChildren(create('div', 'empty', '暂无数据')); - return; +function renderComposer() { + const shell = create('footer', 'composer'); + const disabledNote = create('div', 'offline-note', 'Steam 未在线,聊天发送和素材操作不可用。'); + disabledNote.id = 'offlineNote'; + const picker = create('div', 'picker'); + picker.id = 'picker'; + picker.hidden = true; + const row = create('div', 'compose-row'); + const pickerButton = create('button', 'icon-btn', '素材'); + pickerButton.id = 'pickerToggle'; + pickerButton.type = 'button'; + pickerButton.addEventListener('click', () => { + picker.hidden = !picker.hidden; + renderPicker(picker); + }); + const fileButton = create('button', 'icon-btn', '图片'); + fileButton.id = 'fileButton'; + fileButton.type = 'button'; + const fileInput = create('input') as HTMLInputElement; + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.multiple = true; + fileInput.hidden = true; + fileButton.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + sendFiles(fileInput.files); + fileInput.value = ''; + }); + const input = create('textarea') as HTMLTextAreaElement; + input.id = 'messageInput'; + input.rows = 1; + input.placeholder = '发送消息'; + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void sendText(); + } + }); + const send = create('button', 'primary-btn', '发送'); + send.id = 'sendButton'; + send.type = 'button'; + send.addEventListener('click', () => sendText()); + row.append(pickerButton, fileButton, fileInput, input, send); + const imageForm = create('form', 'image-url-form') as HTMLFormElement; + imageForm.innerHTML = ''; + imageForm.addEventListener('submit', (event) => { + event.preventDefault(); + const url = formValue(imageForm, 'url').trim(); + if (!url) return; + imageForm.reset(); + void sendImage({ url }); + }); + shell.append(disabledNote, picker, row, imageForm); + return shell; +} + +function updateChatAvailability() { + const disabled = !steamOnline(); + for (const selector of ['#messageInput', '#sendButton', '#pickerToggle', '#fileButton', '.image-url-form input', '.image-url-form button']) { + document.querySelectorAll(selector).forEach((node) => { + node.disabled = disabled; + }); } - container.replaceChildren(...items.map(mapItem)); + const note = document.querySelector('#offlineNote'); + if (note) note.hidden = !disabled; } -function renderLists() { - renderList(els.conversationList, state.conversations, (item) => listItem(item, { - preview: item.preview, - meta: item.id, - topRight: formatShortTime(item.updatedAt) - })); - renderList(els.friendList, state.friends, (item) => listItem(item, { - avatar: item.avatar, - preview: item.gameName || item.game || item.id, - meta: item.online ? '在线' : '离线', - online: item.online - })); - renderList(els.groupList, state.groups, (item) => listItem(item, { - preview: item.clanId || item.clanid || item.id - })); -} - -function setActiveConversation(id: unknown, name = '') { - state.activeId = String(id || '').trim(); - state.activeName = name || state.activeId; - localStorage.setItem('steam-chat.target', state.activeId); - els.targetInput.value = state.activeId; - els.chatTitle.textContent = state.activeName || '未选择会话'; - els.chatSubtitle.textContent = state.activeId || '选择好友、群组或输入 SteamID64'; - renderLists(); -} - -async function loadHistory() { - if (!state.activeId) { - renderHistory([]); - return; +async function refreshChatData() { + if (!state.me) return; + try { + const conversations = await api(`/conversations?limit=${state.historyLimit}`); + state.conversations = asListEntries(conversations); + if (steamOnline()) { + const [friends, groups, inventory] = await Promise.all([ + api('/api/friends'), + api('/api/groups'), + api('/api/emoticons') + ]); + state.friends = asListEntries(friends); + state.groups = asListEntries(groups); + state.emoticons = isRecord(inventory) ? asInventory(inventory.emoticons) : []; + state.stickers = isRecord(inventory) ? asInventory(inventory.stickers) : []; + } else { + state.friends = []; + state.groups = []; + state.emoticons = []; + state.stickers = []; + } + if (state.view === 'chat') renderShell(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); } - const result = await wsOrHttp( - { type: 'get_history', id: state.activeId, limit: state.historyLimit }, - `/history?id=${encodeURIComponent(state.activeId)}&limit=${state.historyLimit}`, - [] - ); - const items = isRecord(result) ? result.items || result.history : result; - renderHistory(asMessageItems(items)); - setFeedback('历史已更新', 'ok'); } -async function loadConversations() { - const result = await wsOrHttp( - { type: 'get_conversations', limit: state.historyLimit }, - `/conversations?limit=${state.historyLimit}`, - { conversations: [] } - ); - const items = isRecord(result) ? result.conversations : result; - state.conversations = asListEntries(items); - renderLists(); - return state.conversations; -} - -async function loadFriends() { - const result = await wsOrHttp({ type: 'get_friends' }, '/api/friends', { friends: [] }); - const items = isRecord(result) ? result.friends : result; - state.friends = asListEntries(items); - renderLists(); -} - -async function loadGroups() { - const result = await wsOrHttp({ type: 'get_groups' }, '/api/groups', { groups: [] }); - const items = isRecord(result) ? result.groups : result; - state.groups = asListEntries(items); - renderLists(); -} - -async function loadInventory() { - const result = await wsOrHttp({ type: 'get_emoticons' }, '/api/emoticons', { emoticons: [], stickers: [] }); - state.emoticons = isRecord(result) ? asInventoryItems(result.emoticons) : []; - state.stickers = isRecord(result) ? asInventoryItems(result.stickers) : []; -} - -async function openConversation(id: unknown, name = '') { +function openConversation(id: unknown, name = '') { const target = String(id || '').trim(); if (!target) { setFeedback('请输入 SteamID64', 'warn'); return; } - setActiveConversation(target, name || target); - closeSidebar(); + state.activeId = target; + state.activeName = name || target; + localStorage.setItem('steam-chat.target', state.activeId); + if (state.view === 'chat') renderShell(); +} + +async function loadHistory() { + const messages = document.querySelector('#messages'); + if (!messages) return; + if (!state.activeId) { + renderHistory([]); + return; + } try { - await loadHistory(); + const history = await api(`/history?id=${encodeURIComponent(state.activeId)}&limit=${state.historyLimit}`); + renderHistory(asMessages(history)); } catch (error) { setFeedback(errorMessage(error), 'error'); } } -async function refreshInitialData() { - try { - const [conversations] = await Promise.all([ - loadConversations(), - loadFriends(), - loadGroups(), - loadInventory() - ]); - if (state.activeId) await openConversation(state.activeId, state.activeName || state.activeId); - else if (conversations[0]) await openConversation(conversations[0].id, conversations[0].name); - } catch (error) { - setFeedback(errorMessage(error), 'error'); - } -} - -function mergeConversation(item: MessageItem) { - const preview = item.imageUrl || item.type === 'image' ? '[图片]' : item.message || '[消息]'; - const previous = state.conversations.find((conversation) => conversation.id === item.id); - const next = { - id: item.id, - name: item.echo ? (previous?.name || state.activeName || item.id) : (item.name || item.id), - updatedAt: item.sentAt || item.date, - preview, - lastType: item.type, - lastEcho: item.echo, - messageCount: (previous?.messageCount || 0) + 1 - }; - state.conversations = [next, ...state.conversations.filter((conversation) => conversation.id !== item.id)]; -} - -function releaseImages() { - for (const xhr of state.imageRequests) xhr.abort(); - state.imageRequests.clear(); - for (const url of state.objectUrls) URL.revokeObjectURL(url); - state.objectUrls.clear(); -} - function renderHistory(items: MessageItem[]) { - releaseImages(); - els.messages.replaceChildren(); + const messages = document.querySelector('#messages'); + if (!messages) return; + clear(messages); if (!items.length) { - els.messages.append(create('div', 'empty', '暂无消息')); + messages.append(create('div', 'empty', '暂无消息')); return; } - let previous: MessageItem | null = null; - for (const item of items) { - appendSeparator(previous, item); - appendMessage(item, false); - previous = item; - } - scrollMessages(); + for (const item of items) messages.append(renderMessage(item)); + messages.scrollTop = messages.scrollHeight; } -function itemDate(item: MessageItem): Date | null { - return parseDate(item.sentAt || item.date); -} - -function appendSeparator(previous: MessageItem | null, current: MessageItem) { - const now = itemDate(current); - if (!now) return; - const prev = previous ? itemDate(previous) : null; - let label = ''; - if (!prev || prev.toDateString() !== now.toDateString()) { - label = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' }); - } else if (now.getTime() - prev.getTime() > 10 * 60 * 1000) { - label = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); - } - if (label) els.messages.append(create('div', 'separator', label)); -} - -function appendMessage(item: MessageItem, withSeparator = true) { - const empty = $('.empty', els.messages); - if (empty) empty.remove(); - if (withSeparator) { - const rows = $$('.msg-row', els.messages); - const previous = rows.length ? rows[rows.length - 1]._item : null; - appendSeparator(previous, item); - } - rememberEmoticons(item.message); - const row = create('article', `msg-row${item.echo ? ' is-self' : ''}`) as MessageRow; - row._item = item; +function renderMessage(item: MessageItem) { + const row = create('article', `msg-row${item.echo ? ' is-self' : ''}`); const bubble = create('div', 'bubble'); const meta = create('div', 'meta'); - const date = itemDate(item); - meta.append( - create('span', '', item.name || (item.echo ? '我' : item.id || 'Unknown')), - create('span', '', date ? date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : item.date || '') - ); - const content = create('div', 'content'); - const parts: RichPart[] = item.imageUrl ? [{ type: 'image', url: item.imageUrl }] : parseRichParts(item.message || ''); - const meaningful = parts.filter((part) => part.type !== 'text' || part.text.trim()); - if (meaningful.length === 1 && ['image', 'sticker', 'og'].includes(meaningful[0].type)) bubble.classList.add('is-visual'); - for (const part of parts) appendRichPart(content, part); + meta.append(create('span', '', item.name || (item.echo ? '我' : item.id)), create('span', '', formatTime(item.sentAt || item.date))); + const content = create('div', 'message-content'); + if (item.imageUrl) content.append(imageNode(item.imageUrl)); + else appendMessageText(content, item.message || ''); bubble.append(meta, content); row.append(bubble); - els.messages.append(row); - scrollMessages(); + return row; } -function scrollMessages() { - requestAnimationFrame(() => { - els.messages.scrollTop = els.messages.scrollHeight; - }); -} - -function rememberEmoticons(text: unknown) { - for (const match of String(text || '').matchAll(/:([A-Za-z0-9_+\-.]+):/g)) { - state.knownEmoticons.add(match[1]); - } -} - -const tokenPattern = /\[sticker\s+type=["']?([^"'\]\s]+)["']?[^\]]*\]\s*\[\/sticker\]|\[img\]([^\[]+)\[\/img\]|\[img\s+src=["']?([^"'\]\s]+)["']?[^\]]*\][\s\S]*?\[\/img\]|]+src=["']([^"']+)["'][^>]*>|\[og\b([^\]]*)\]([\s\S]*?)\[\/og\]|\[url=([^\]]+)\]([^\[]+)\[\/url\]|\[url\]([^\[]+)\[\/url\]|\[emoticon\s+name=["']?([^"'\]\s]+)["']?\]\s*\[\/emoticon\]|\[emoticon\]([^\[]+)\[\/emoticon\]|:([A-Za-z0-9_+\-.]+):|(https?:\/\/[^\s<>"']+)/gi; - -function parseAttrs(text: unknown): Record { - const attrs: Record = {}; - for (const match of String(text || '').matchAll(/([A-Za-z0-9_-]+)=["']([^"']*)["']/g)) { - attrs[match[1]] = match[2]; - } - return attrs; -} - -function parseRichParts(message: unknown): RichPart[] { - const text = String(message || ''); - const parts: RichPart[] = []; +function appendMessageText(container: HTMLElement, text: string) { + const pattern = /\[sticker\s+type=["']?([^"'\]\s]+)["']?[^\]]*\]\s*\[\/sticker\]|:([A-Za-z0-9_+\-.]+):|(https?:\/\/[^\s<>"']+)/gi; let lastIndex = 0; - tokenPattern.lastIndex = 0; - for (const match of text.matchAll(tokenPattern)) { - if (match.index > lastIndex) parts.push({ type: 'text', text: text.slice(lastIndex, match.index) }); - if (match[1]) parts.push({ type: 'sticker', stickerType: match[1] }); - else if (match[2] || match[3] || match[4]) parts.push({ type: 'image', url: (match[2] || match[3] || match[4]).trim() }); - else if (match[5] !== undefined) { - const attrs = parseAttrs(match[5]); - parts.push({ type: 'og', url: attrs.url || '', image: attrs.img || attrs.image || '', title: attrs.title || match[6] || '', fallback: match[6] || attrs.url || '' }); - } else if (match[7]) parts.push({ type: 'link', href: match[7], label: match[8] }); - else if (match[9]) parts.push({ type: 'link', href: match[9], label: match[9] }); - else if (match[10] || match[11] || match[12]) parts.push({ type: 'emoticon', name: match[10] || match[11] || match[12] }); - else if (match[13]) { - if (/^https?:\/\/\S+\.(png|jpe?g|gif|webp)(\?\S*)?$/i.test(match[13])) { - parts.push({ type: 'image', url: match[13] }); - } else { - parts.push({ type: 'link', href: match[13], label: match[13] }); - } + for (const match of text.matchAll(pattern)) { + if (match.index > lastIndex) container.append(document.createTextNode(text.slice(lastIndex, match.index))); + if (match[1]) { + const image = document.createElement('img'); + image.className = 'sticker'; + image.src = `/proxy/sticker/${encodeURIComponent(match[1])}`; + image.alt = match[1]; + container.append(image); + } else if (match[2]) { + const image = document.createElement('img'); + image.className = 'emoticon'; + image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(match[2])}`; + image.alt = `:${match[2]}:`; + container.append(image); + } else if (match[3]) { + const link = create('a', '', match[3]); + link.href = match[3]; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + container.append(link); } lastIndex = match.index + match[0].length; } - if (lastIndex < text.length) parts.push({ type: 'text', text: text.slice(lastIndex) }); - return parts.length ? parts : [{ type: 'text', text }]; + if (lastIndex < text.length) container.append(document.createTextNode(text.slice(lastIndex))); } -function appendRichPart(container: HTMLElement, part: RichPart) { - if (part.type === 'text') { - container.append(document.createTextNode(part.text)); - } else if (part.type === 'link') { - const link = create('a', '', part.label || part.href); - link.href = part.href; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - container.append(link); - } else if (part.type === 'emoticon') { - const image = document.createElement('img'); - image.className = 'emoticon'; - image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(part.name)}`; - image.alt = `:${part.name}:`; - image.title = `:${part.name}:`; - container.append(image); - } else if (part.type === 'sticker') { - const image = document.createElement('img'); - image.className = 'sticker'; - image.src = `/proxy/sticker/${encodeURIComponent(part.stickerType)}`; - image.alt = part.stickerType; - image.addEventListener('click', () => openLightbox(image.src)); - container.append(image); - } else if (part.type === 'image') { - container.append(managedImage(part.url)); - } else if (part.type === 'og') { - const card = create('a', 'og-card'); - card.href = part.url || '#'; - card.target = '_blank'; - card.rel = 'noopener noreferrer'; - if (part.image) card.append(managedImage(part.image)); - const body = create('div'); - body.append(create('strong', '', part.title || part.fallback || part.url || 'OpenGraph'), create('span', '', part.url || part.fallback || '')); - card.append(body); - container.append(card); - } -} - -function managedImage(sourceUrl: string) { - const shell = create('div', 'image-shell', '加载中'); - const xhr = new XMLHttpRequest(); - state.imageRequests.add(xhr); - xhr.open('GET', `/proxy/image?url=${encodeURIComponent(sourceUrl)}`); - xhr.responseType = 'blob'; - xhr.onprogress = (event) => { - if (event.lengthComputable) shell.textContent = `${Math.round((event.loaded / event.total) * 100)}%`; - }; - xhr.onload = () => { - state.imageRequests.delete(xhr); - if (xhr.status < 200 || xhr.status >= 300) { - shell.textContent = '加载失败'; - return; - } - const objectUrl = URL.createObjectURL(xhr.response); - state.objectUrls.add(objectUrl); - const image = document.createElement('img'); - image.className = 'message-image'; - image.src = objectUrl; - image.alt = '图片'; - image.addEventListener('click', () => openLightbox(objectUrl)); - shell.replaceWith(image); - }; - xhr.onerror = () => { - state.imageRequests.delete(xhr); - shell.textContent = '加载失败'; - }; - xhr.onabort = () => state.imageRequests.delete(xhr); - xhr.send(); +function imageNode(sourceUrl: string) { + const shell = create('button', 'image-button', '加载图片'); + shell.type = 'button'; + const image = document.createElement('img'); + image.src = `/proxy/image?url=${encodeURIComponent(sourceUrl)}`; + image.alt = '图片'; + image.onload = () => shell.replaceChildren(image); + image.onerror = () => shell.textContent = '图片加载失败'; + shell.addEventListener('click', () => openLightbox(image.src)); return shell; } -let lightboxScale = 1; -let lightboxX = 0; -let lightboxY = 0; -let lightboxDragging = false; -let lightboxStartX = 0; -let lightboxStartY = 0; -let lastFocus: HTMLElement | null = null; - -function applyLightboxTransform() { - els.lightboxImage.style.transform = `translate(${lightboxX}px, ${lightboxY}px) scale(${lightboxScale})`; -} - -function openLightbox(src: string) { - lastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; - lightboxScale = 1; - lightboxX = 0; - lightboxY = 0; - els.lightboxImage.src = src; - els.lightbox.hidden = false; - applyLightboxTransform(); - els.lightboxClose.focus(); -} - -function closeLightbox() { - els.lightbox.hidden = true; - els.lightboxImage.src = ''; - lastFocus?.focus?.(); -} - -function zoomLightbox(delta: number) { - lightboxScale = Math.min(6, Math.max(0.2, lightboxScale + delta)); - applyLightboxTransform(); -} - -function autoSizeInput() { - els.messageInput.style.height = 'auto'; - els.messageInput.style.height = `${Math.min(els.messageInput.scrollHeight, window.innerWidth <= 900 ? 108 : 160)}px`; +function formatTime(value: unknown): string { + if (!value) return ''; + const date = new Date(String(value).replace(' ', 'T')); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } function activeIdOrWarn() { @@ -775,400 +841,207 @@ function activeIdOrWarn() { async function sendText() { const id = activeIdOrWarn(); - const msg = els.messageInput.value.trim(); - if (!id || !msg) { - if (!msg) setFeedback('请输入消息', 'warn'); - return; - } - els.sendButton.disabled = true; + const input = document.querySelector('#messageInput'); + const msg = input?.value.trim() || ''; + if (!id || !msg) return; try { - if (wsIsOpen()) await wsRequest({ type: 'send_message', id, msg }); - else await apiPost('/message', { id, msg }); - els.messageInput.value = ''; - autoSizeInput(); + await api('/message', jsonBody({ id, msg })); + if (input) input.value = ''; + await loadHistory(); setFeedback('已发送', 'ok'); } catch (error) { setFeedback(errorMessage(error), 'error'); - } finally { - els.sendButton.disabled = false; } } -function addUpload(label: string): HTMLElement { - const row = create('div', 'upload-item'); - row.append(create('strong', '', label), create('span', '', '等待')); - els.uploadTray.append(row); - return row; -} - -function setUpload(row: HTMLElement, text: string) { - const status = row.querySelector('span'); - if (status) status.textContent = text; -} - -function markUploadsFailed(reason: string) { - for (const row of $$('.upload-item', els.uploadTray)) { - if (!/成功|失败/.test(row.textContent)) setUpload(row, `失败:${reason}`); +async function sendImage(payload: Record) { + const id = activeIdOrWarn(); + if (!id) return; + try { + await api('/image', jsonBody({ id, ...payload })); + await loadHistory(); + setFeedback('图片已发送', 'ok'); + } catch (error) { + setFeedback(errorMessage(error), 'error'); } } -function fileToDataUrl(file: File, progress: (percent: number) => void): Promise { - return new Promise((resolve, reject) => { +function sendFiles(files: FileList | null) { + if (!files) return; + [...files].filter((file) => file.type.startsWith('image/')).forEach((file) => { const reader = new FileReader(); - reader.onerror = () => reject(reader.error || new Error('读取图片失败')); - reader.onprogress = (event) => { - if (event.lengthComputable) progress(Math.round((event.loaded / event.total) * 100)); - }; reader.onload = () => { - if (typeof reader.result === 'string') resolve(reader.result); - else reject(new Error('读取图片失败')); + if (typeof reader.result === 'string') void sendImage({ img: reader.result }); }; + reader.onerror = () => setFeedback('读取图片失败', 'error'); reader.readAsDataURL(file); }); } -async function sendImagePayload(label: string, payload: Record) { - const id = activeIdOrWarn(); - if (!id) return; - const row = addUpload(label); - setUpload(row, '发送中'); - try { - if (wsIsOpen()) await wsRequest({ type: 'send_image', id, ...payload }); - else await apiPost('/image', { id, ...payload }); - setUpload(row, '成功'); - setFeedback('图片已发送', 'ok'); - setTimeout(() => row.remove(), 1800); - } catch (error) { - setUpload(row, `失败:${errorMessage(error)}`); - setFeedback(errorMessage(error), 'error'); - } -} - -async function sendFiles(files: FileList | File[] | null) { - if (!files) return; - for (const file of [...files].filter((item) => item.type.startsWith('image/'))) { - const row = addUpload(file.name); - try { - const dataUrl = await fileToDataUrl(file, (progress) => setUpload(row, `${progress}%`)); - row.remove(); - await sendImagePayload(file.name, { img: dataUrl }); - } catch (error) { - setUpload(row, `失败:${errorMessage(error)}`); - } - } -} - -function sortInventory(items: InventoryItem[]): InventoryItem[] { - return [...items].sort((left, right) => { - const usage = Number(right.use_count || 0) - Number(left.use_count || 0); - return usage || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); - }); -} - -function inventoryNames(): string[] { - return [...new Set([...state.emoticons.map((item) => item.name).filter((name): name is string => Boolean(name)), ...state.knownEmoticons])].sort(); -} - -function renderPicker() { - $$('.picker-head button').forEach((button) => button.classList.toggle('is-active', button.dataset.picker === state.pickerTab)); - const query = els.pickerSearch.value.trim().toLowerCase(); - const source = state.pickerTab === 'emoticons' ? state.emoticons : state.stickers; - const items = sortInventory(source).filter((item) => String(item.name || '').toLowerCase().includes(query)).slice(0, 240); - if (!items.length) { - els.pickerGrid.replaceChildren(create('div', 'empty', '暂无内容')); - return; - } - els.pickerGrid.replaceChildren(...items.map((item) => { - const name = item.name || ''; - const button = create('button'); - button.type = 'button'; - const image = document.createElement('img'); - image.src = state.pickerTab === 'emoticons' - ? `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}` - : `/proxy/sticker/${encodeURIComponent(name)}`; - image.alt = ''; - button.append(image, create('span', '', name)); - button.addEventListener('click', () => { - if (state.pickerTab === 'emoticons') insertAtCursor(`:${name}:`); - else { - els.messageInput.value = `[sticker type="${name}" limit="0"][/sticker]`; - sendText(); - } - els.picker.hidden = true; - }); - return button; - })); -} - -function insertAtCursor(value: string) { - const start = els.messageInput.selectionStart; - const end = els.messageInput.selectionEnd; - els.messageInput.value = `${els.messageInput.value.slice(0, start)}${value}${els.messageInput.value.slice(end)}`; - const next = start + value.length; - els.messageInput.setSelectionRange(next, next); - autoSizeInput(); - els.messageInput.focus(); -} - -function autocompletePrefix(): string { - const before = els.messageInput.value.slice(0, els.messageInput.selectionStart); - const match = before.match(/:([A-Za-z0-9_+\-.]{1,32})$/); - return match ? match[1] : ''; -} - -function renderAutocomplete() { - const prefix = autocompletePrefix(); - if (!prefix) { - els.autocomplete.hidden = true; - return; - } - const matches = inventoryNames().filter((name) => name.toLowerCase().includes(prefix.toLowerCase())).slice(0, 8); - if (!matches.length) { - els.autocomplete.hidden = true; - return; - } - state.autocompleteIndex = Math.min(state.autocompleteIndex, matches.length - 1); - els.autocomplete.replaceChildren(...matches.map((name, index) => { - const button = create('button', index === state.autocompleteIndex ? 'is-active' : ''); - button.type = 'button'; - const image = document.createElement('img'); - image.className = 'emoticon'; - image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}`; - image.alt = ''; - button.append(image, create('span', '', `:${name}:`)); - button.addEventListener('mousedown', (event: MouseEvent) => { - event.preventDefault(); - applyAutocomplete(name); - }); - return button; - })); - els.autocomplete.hidden = false; -} - -function applyAutocomplete(name: string) { - const start = els.messageInput.selectionStart; - const before = els.messageInput.value.slice(0, start).replace(/:([A-Za-z0-9_+\-.]{1,32})$/, `:${name}:`); - els.messageInput.value = `${before}${els.messageInput.value.slice(start)}`; - els.messageInput.setSelectionRange(before.length, before.length); - els.autocomplete.hidden = true; - autoSizeInput(); -} - -function updateTitle() { - document.title = state.unread ? `(${state.unread}) ${baseTitle}` : baseTitle; -} - -function requestNotificationPermission() { - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission().catch(() => {}); - } -} - -function notify(item: MessageItem) { - if (!item || item.echo) return; - const inactive = item.id !== state.activeId || document.hidden || !document.hasFocus(); - if (!inactive) return; - state.unread += 1; - updateTitle(); - if ('Notification' in window && Notification.permission === 'granted') { - const notification = new Notification(item.name || 'Steam Chat', { - body: item.message || (item.imageUrl ? '[图片]' : '[消息]'), - tag: item.id - }); - notification.onclick = () => { - window.focus(); - openConversation(item.id, item.name || item.id); - notification.close(); - }; - } -} - -function clearUnread() { - state.unread = 0; - updateTitle(); -} - -function openSidebar() { - document.body.classList.add('sidebar-open'); -} - -function closeSidebar() { - document.body.classList.remove('sidebar-open'); -} - -function setupEvents() { - els.openSidebar.addEventListener('click', openSidebar); - els.closeSidebar.addEventListener('click', closeSidebar); - els.backdrop.addEventListener('click', closeSidebar); - window.addEventListener('resize', () => { - if (window.innerWidth > 900) closeSidebar(); - }); - $$('.tabs button').forEach((tab, index, tabs) => { - tab.addEventListener('click', () => { - tabs.forEach((item) => item.classList.toggle('is-active', item === tab)); - $$('.panel').forEach((panel) => panel.classList.toggle('is-active', panel.dataset.panel === tab.dataset.tab)); - }); - tab.addEventListener('keydown', (event) => { - const keyMap = { ArrowRight: (index + 1) % tabs.length, ArrowLeft: (index - 1 + tabs.length) % tabs.length, Home: 0, End: tabs.length - 1 }; - const key = event.key as keyof typeof keyMap; - if (key in keyMap) { - event.preventDefault(); - tabs[keyMap[key]].click(); - tabs[keyMap[key]].focus(); - } - }); - }); - els.openForm.addEventListener('submit', (event) => { - event.preventDefault(); - openConversation(els.targetInput.value.trim()); - }); - els.refreshAll.addEventListener('click', () => refreshInitialData()); - els.historyLimit.addEventListener('change', () => { - state.historyLimit = clampLimit(els.historyLimit.value); - els.historyLimit.value = String(state.historyLimit); - localStorage.setItem('steam-chat.history-limit', String(state.historyLimit)); - loadHistory().catch((error: unknown) => setFeedback(errorMessage(error), 'error')); - loadConversations().catch((error: unknown) => setFeedback(errorMessage(error), 'error')); - }); - els.sendButton.addEventListener('click', sendText); - els.messageInput.addEventListener('input', () => { - autoSizeInput(); - renderAutocomplete(); - }); - els.messageInput.addEventListener('keydown', (event) => { - if (!els.autocomplete.hidden && ['ArrowDown', 'ArrowUp', 'Tab', 'Enter', 'Escape'].includes(event.key)) { - const buttons = $$('.autocomplete button'); - if (event.key === 'Escape') { - els.autocomplete.hidden = true; - return; - } - event.preventDefault(); - if (event.key === 'ArrowDown') state.autocompleteIndex = (state.autocompleteIndex + 1) % buttons.length; - if (event.key === 'ArrowUp') state.autocompleteIndex = (state.autocompleteIndex - 1 + buttons.length) % buttons.length; - if (event.key === 'Tab' || event.key === 'Enter') { - buttons[state.autocompleteIndex]?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - return; - } - renderAutocomplete(); +function renderPicker(container: HTMLElement) { + clear(container); + const tabs = create('div', 'picker-tabs'); + const emoticons = create('button', '', '表情'); + const stickers = create('button', '', '贴纸'); + const grid = create('div', 'picker-grid'); + function fill(type: 'emoticons' | 'stickers') { + clear(grid); + const source = (type === 'emoticons' ? state.emoticons : state.stickers).slice(0, 160); + if (!source.length) { + grid.append(create('div', 'empty small', '暂无素材')); return; } - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - sendText(); + for (const item of source) { + const name = String(item.name || ''); + if (!name) continue; + const button = create('button'); + button.type = 'button'; + const image = document.createElement('img'); + image.src = type === 'emoticons' + ? `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}` + : `/proxy/sticker/${encodeURIComponent(name)}`; + image.alt = name; + button.append(image, create('span', '', name)); + button.addEventListener('click', () => { + const input = document.querySelector('#messageInput'); + if (!input) return; + input.value += type === 'emoticons' ? `:${name}:` : `[sticker type="${name}" limit="0"][/sticker]`; + input.focus(); + }); + grid.append(button); + } + } + emoticons.type = 'button'; + stickers.type = 'button'; + emoticons.addEventListener('click', () => fill('emoticons')); + stickers.addEventListener('click', () => fill('stickers')); + tabs.append(emoticons, stickers); + container.append(tabs, grid); + fill('emoticons'); +} + +function ensureWebSocket() { + if (!state.me || state.ws || state.reconnectTimer) return; + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${location.host}${state.wsPath}`); + state.ws = ws; + ws.addEventListener('message', (event) => { + let payload: WsPayload; + try { + const parsed: unknown = JSON.parse(String(event.data)); + payload = isRecord(parsed) ? parsed : {}; + } catch { + return; + } + if (payload.type === 'message' || payload.type === 'image') { + const item: MessageItem = { + ...payload, + id: String(payload.id || ''), + name: typeof payload.name === 'string' ? payload.name : undefined, + message: typeof payload.message === 'string' ? payload.message : undefined, + imageUrl: typeof payload.imageUrl === 'string' ? payload.imageUrl : null, + echo: Boolean(payload.echo), + date: typeof payload.date === 'string' ? payload.date : undefined, + sentAt: typeof payload.sentAt === 'string' ? payload.sentAt : undefined + }; + if (item.id === state.activeId) { + const messages = document.querySelector('#messages'); + messages?.append(renderMessage(item)); + if (messages) messages.scrollTop = messages.scrollHeight; + } } }); - els.fileButton.addEventListener('click', () => els.fileInput.click()); - els.fileInput.addEventListener('change', () => { - sendFiles(els.fileInput.files); - els.fileInput.value = ''; - }); - els.imageUrlForm.addEventListener('submit', (event) => { - event.preventDefault(); - const url = els.imageUrlInput.value.trim(); - if (!url) return; - els.imageUrlInput.value = ''; - sendImagePayload(url, { url }); - }); - els.pickerToggle.addEventListener('click', () => { - els.picker.hidden = !els.picker.hidden; - renderPicker(); - }); - $$('.picker-head button').forEach((button) => { - button.addEventListener('click', () => { - state.pickerTab = button.dataset.picker === 'stickers' ? 'stickers' : 'emoticons'; - renderPicker(); - }); - }); - els.pickerSearch.addEventListener('input', renderPicker); - document.addEventListener('paste', (event) => { - const files = [...(event.clipboardData?.files || [])].filter((file) => file.type.startsWith('image/')); - if (files.length) { - event.preventDefault(); - sendFiles(files); + ws.addEventListener('close', () => { + state.ws = null; + if (state.me) { + state.reconnectTimer = setTimeout(() => { + state.reconnectTimer = null; + ensureWebSocket(); + }, 1500); } }); - document.addEventListener('dragenter', (event) => { - if ([...(event.dataTransfer?.items || [])].some((item) => item.type.startsWith('image/'))) { - els.dropOverlay.classList.add('is-visible'); +} + +function stopWebSocket() { + if (state.reconnectTimer) clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + if (state.ws) state.ws.close(); + state.ws = null; +} + +function startStatusPolling() { + if (state.statusTimer || !state.me) return; + state.statusTimer = setInterval(async () => { + try { + const status = await api('/api/steam/status'); + const wasOnline = steamOnline(); + updateSteamStatus(status as SteamStatus); + if (state.view === 'chat' && steamOnline() !== wasOnline) await refreshChatData(); + } catch (_) { + // Authentication errors are handled by api(). } - }); - document.addEventListener('dragover', (event) => { - if (els.dropOverlay.classList.contains('is-visible')) event.preventDefault(); - }); - document.addEventListener('dragleave', (event) => { - if (!event.relatedTarget) els.dropOverlay.classList.remove('is-visible'); - }); - document.addEventListener('drop', (event) => { - const files = [...(event.dataTransfer?.files || [])].filter((file) => file.type.startsWith('image/')); - els.dropOverlay.classList.remove('is-visible'); - if (files.length) { - event.preventDefault(); - sendFiles(files); - } - }); - window.addEventListener('pointerdown', requestNotificationPermission, { once: true }); - window.addEventListener('keydown', requestNotificationPermission, { once: true }); - window.addEventListener('focus', clearUnread); - document.addEventListener('visibilitychange', () => { - if (!document.hidden) clearUnread(); - }); - els.lightboxClose.addEventListener('click', closeLightbox); - els.lightbox.addEventListener('click', (event) => { - if (event.target === els.lightbox) closeLightbox(); - }); - els.zoomIn.addEventListener('click', () => zoomLightbox(0.25)); - els.zoomOut.addEventListener('click', () => zoomLightbox(-0.25)); - els.zoomReset.addEventListener('click', () => { - lightboxScale = 1; - lightboxX = 0; - lightboxY = 0; - applyLightboxTransform(); - }); - els.lightbox.addEventListener('wheel', (event) => { - event.preventDefault(); - zoomLightbox(event.deltaY > 0 ? -0.12 : 0.12); - }, { passive: false }); - els.lightboxImage.addEventListener('dblclick', () => { - lightboxScale = lightboxScale === 1 ? 2 : 1; - applyLightboxTransform(); - }); - els.lightboxImage.addEventListener('pointerdown', (event) => { - lightboxDragging = true; - lightboxStartX = event.clientX - lightboxX; - lightboxStartY = event.clientY - lightboxY; - els.lightboxImage.setPointerCapture(event.pointerId); - }); - els.lightboxImage.addEventListener('pointermove', (event) => { - if (!lightboxDragging) return; - lightboxX = event.clientX - lightboxStartX; - lightboxY = event.clientY - lightboxStartY; - applyLightboxTransform(); - }); - els.lightboxImage.addEventListener('pointerup', () => { - lightboxDragging = false; - }); - window.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && !els.lightbox.hidden) closeLightbox(); - }); + }, 3000); +} + +function stopStatusPolling() { + if (state.statusTimer) clearInterval(state.statusTimer); + state.statusTimer = null; +} + +async function logoutApp() { + await api('/api/auth/logout', { method: 'POST' }).catch((): null => null); + stopStatusPolling(); + stopWebSocket(); + state.me = null; + renderLogin(); } async function bootstrap() { - els.historyLimit.value = String(state.historyLimit); - setActiveConversation(state.activeId, state.activeId); - renderHistory([]); - renderLists(); - setupEvents(); - let wsPath = '/ws'; - try { - const config = await apiGet('/api/config'); - if (isRecord(config) && typeof config.wsPath === 'string') wsPath = config.wsPath; - } catch { - // Keep the default WebSocket path when the config endpoint is temporarily unavailable. + const mePayload = await api('/api/auth/me') as MeResponse; + state.needsSetup = Boolean(mePayload.needsSetup); + state.me = mePayload.user || null; + updateSteamStatus(mePayload.steam || defaultSteamStatus); + if (state.needsSetup) { + renderSetup(); + return; } - connectWebSocket(wsPath); + if (!state.me) { + renderLogin(); + return; + } + const config = await api('/api/config').catch(() => ({ wsPath: '/ws' })); + if (isRecord(config) && typeof config.wsPath === 'string') state.wsPath = config.wsPath; + renderShell(); } -bootstrap().catch((error: unknown) => { - console.error(error); - setFeedback(errorMessage(error) || '启动失败', 'error'); +function openLightbox(src: string) { + const box = document.querySelector('#lightbox'); + const image = document.querySelector('#lightboxImage'); + if (!box || !image) return; + image.src = src; + box.hidden = false; +} + +document.querySelector('#lightboxClose')?.addEventListener('click', () => { + const box = document.querySelector('#lightbox'); + const image = document.querySelector('#lightboxImage'); + if (box) box.hidden = true; + if (image) image.src = ''; +}); + +document.addEventListener('dragover', (event) => { + if (state.view === 'chat' && steamOnline()) event.preventDefault(); +}); + +document.addEventListener('drop', (event) => { + if (state.view !== 'chat' || !steamOnline()) return; + const files = event.dataTransfer?.files || null; + if (files && files.length) { + event.preventDefault(); + sendFiles(files); + } +}); + +bootstrap().catch((error) => { + authPage('启动失败', errorMessage(error), create('div')); }); diff --git a/web/index.html b/web/index.html index 3f2480a..a060d56 100644 --- a/web/index.html +++ b/web/index.html @@ -3,98 +3,16 @@ - Steam Chat + Steam Chat Admin -
- - -
- -
-
- -
-

未选择会话

-

选择好友、群组或输入 SteamID64

-
- -
- -
- -
-
- - -
- - - - - -
-
- - -
-
-
-
- +
释放发送图片
- diff --git a/web/style.css b/web/style.css index 17e3e7f..1a2b40a 100644 --- a/web/style.css +++ b/web/style.css @@ -1,16 +1,17 @@ :root { - --bg: #f6f8f5; - --panel: #ffffff; - --soft: #edf3ee; - --line: #d8dfd8; - --text: #1c2421; - --muted: #66736d; - --green: #197653; - --green-strong: #11593e; - --green-soft: #ddefe6; - --orange: #a26016; - --danger: #a33a35; - --shadow: 0 18px 42px rgba(20, 32, 27, 0.16); + --bg: #f4f6f8; + --surface: #ffffff; + --soft: #eef2f5; + --line: #d8dee5; + --text: #17202a; + --muted: #657283; + --brand: #176b87; + --brand-soft: #d9ecf2; + --ok: #1f7a4d; + --warn: #a45d14; + --danger: #b33a3a; + --danger-soft: #f5dddd; + --shadow: 0 16px 34px rgba(23, 32, 42, 0.14); } * { @@ -18,8 +19,9 @@ } html, -body { - height: 100%; +body, +#app { + min-height: 100%; margin: 0; } @@ -31,6 +33,7 @@ body { button, input, +select, textarea { font: inherit; } @@ -40,8 +43,17 @@ button { cursor: pointer; } +button:disabled, +input:disabled, +textarea:disabled { + cursor: not-allowed; + opacity: 0.58; +} + input, +select, textarea { + width: 100%; min-width: 0; border: 1px solid var(--line); border-radius: 6px; @@ -50,138 +62,376 @@ textarea { outline: none; } -input:focus, -textarea:focus { - border-color: var(--green); - box-shadow: 0 0 0 3px var(--green-soft); -} - -.shell { - display: grid; - grid-template-columns: 330px minmax(0, 1fr); - min-height: 100dvh; -} - -.sidebar { - display: grid; - grid-template-rows: auto auto auto auto minmax(0, 1fr); - min-height: 100dvh; - border-right: 1px solid var(--line); - background: var(--panel); -} - -.side-head, -.chat-head { - display: flex; - align-items: center; - gap: 12px; - border-bottom: 1px solid var(--line); -} - -.side-head { - justify-content: space-between; - padding: 16px; -} - -.side-head h1, -.chat-title h2 { - margin: 0; - font-size: 18px; - letter-spacing: 0; -} - -.side-head p, -.chat-title p { - margin: 2px 0 0; - color: var(--muted); - font-size: 12px; -} - -.icon-btn { - display: inline-grid; - place-items: center; - width: 36px; - height: 36px; - border-radius: 6px; - background: var(--soft); - color: var(--text); -} - -.open-form, -.side-tools { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - padding: 12px; - border-bottom: 1px solid var(--line); -} - -.open-form input, -.side-tools input { - height: 36px; +input, +select { + height: 38px; padding: 0 10px; } -.open-form button, -.side-tools button, -#sendButton, -.url-row button { - min-height: 36px; +textarea { + min-height: 42px; + max-height: 160px; + resize: vertical; + padding: 10px; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-soft); +} + +.muted { + color: var(--muted); +} + +.auth-page { + display: grid; + min-height: 100dvh; + place-items: center; + padding: 24px; +} + +.auth-panel { + width: min(420px, 100%); + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); + padding: 24px; +} + +.auth-panel h1, +.topbar h1 { + margin: 0; + font-size: 22px; + letter-spacing: 0; +} + +.auth-panel p, +.topbar p { + margin: 6px 0 0; +} + +.stack-form, +.inline-form { + display: grid; + gap: 12px; +} + +.stack-form { + margin-top: 20px; +} + +.narrow-form { + max-width: 460px; +} + +.inline-form { + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)) auto; + align-items: end; +} + +.stack-form label, +.inline-form label { + display: grid; + gap: 6px; + color: var(--muted); +} + +.stack-form button, +.inline-form button, +.primary-btn { + min-height: 38px; border-radius: 6px; - background: var(--green); + background: var(--brand); color: #fff; padding: 0 14px; } -.side-tools label { +.ghost-btn, +.icon-btn { + min-height: 36px; + border-radius: 6px; + background: var(--soft); + color: var(--text); + padding: 0 12px; +} + +.danger-btn { + min-height: 36px; + border-radius: 6px; + background: var(--danger-soft); + color: var(--danger); + padding: 0 12px; +} + +.form-error, +.warn-text { + margin: 0; + color: var(--danger); +} + +.admin-shell { display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: 8px; + grid-template-columns: 240px minmax(0, 1fr); + min-height: 100dvh; +} + +.admin-sidebar { + display: grid; + grid-template-rows: auto 1fr; + gap: 16px; + border-right: 1px solid var(--line); + background: var(--surface); + padding: 18px 14px; +} + +.brand { + display: grid; + gap: 2px; +} + +.brand strong { + font-size: 18px; +} + +.brand span { color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.tabs { +nav { display: grid; - grid-template-columns: repeat(3, 1fr); + align-content: start; gap: 6px; - padding: 10px; - border-bottom: 1px solid var(--line); } -.tabs button { - height: 34px; +nav button { + min-height: 38px; border-radius: 6px; background: transparent; color: var(--muted); + text-align: left; + padding: 0 10px; } -.tabs button.is-active { - background: var(--green-soft); - color: var(--green-strong); +nav button:hover, +nav button.is-active { + background: var(--brand-soft); + color: var(--brand); } -.panel { - display: none; +.workspace { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-width: 0; + min-height: 100dvh; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.94); + padding: 16px 20px; +} + +.topbar > div:first-child { + min-width: 0; +} + +.top-actions { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.top-hint { + max-width: 240px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.steam-badge { + display: inline-flex; + align-items: center; + min-height: 28px; + border-radius: 999px; + background: var(--soft); + color: var(--muted); + padding: 0 10px; + white-space: nowrap; +} + +.steam-badge[data-status="online"] { + background: #dff1e8; + color: var(--ok); +} + +.steam-badge[data-status="waiting_guard"], +.steam-badge[data-status="logging_in"], +.steam-badge[data-status="reconnecting"] { + background: #f6ead8; + color: var(--warn); +} + +.steam-badge[data-status="error"] { + background: var(--danger-soft); + color: var(--danger); +} + +.content { + min-height: 0; + overflow: auto; + padding: 20px; +} + +.feedback { + border-top: 1px solid var(--line); + background: var(--surface); + color: var(--muted); + padding: 9px 20px; +} + +.feedback[data-tone="ok"] { + color: var(--ok); +} + +.feedback[data-tone="warn"] { + color: var(--warn); +} + +.feedback[data-tone="error"] { + color: var(--danger); +} + +.steam-view, +.users-view { + display: grid; + gap: 16px; +} + +.status-panel { + display: grid; + gap: 4px; + border-bottom: 1px solid var(--line); + padding-bottom: 16px; +} + +.status-panel strong { + font-size: 20px; +} + +.user-table { + display: grid; + gap: 8px; +} + +.user-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 12px; +} + +.user-row div:first-child { + display: grid; + gap: 2px; + min-width: 0; +} + +.user-row strong, +.user-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.chat-layout { + display: grid; + grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); + gap: 16px; + min-height: calc(100dvh - 132px); +} + +.chat-lists, +.thread { + min-height: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.chat-lists { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; +} + +.open-chat-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + border-bottom: 1px solid var(--line); + padding: 10px; +} + +.open-chat-form button, +.image-url-form button { + border-radius: 6px; + background: var(--brand); + color: #fff; + padding: 0 12px; +} + +.list-sections { min-height: 0; overflow: auto; padding: 8px; } -.panel.is-active { - display: block; +.list-section { + margin-bottom: 14px; +} + +.list-section h2 { + margin: 8px 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; } .list-item { display: grid; - grid-template-columns: 40px minmax(0, 1fr); - gap: 10px; + grid-template-columns: 38px minmax(0, 1fr); + gap: 9px; width: 100%; margin-bottom: 4px; - padding: 9px; border-radius: 8px; background: transparent; color: var(--text); + padding: 8px; text-align: left; } @@ -193,12 +443,12 @@ textarea:focus { .avatar { display: grid; place-items: center; - width: 40px; - height: 40px; + width: 38px; + height: 38px; overflow: hidden; border-radius: 8px; - background: var(--green-soft); - color: var(--green-strong); + background: var(--brand-soft); + color: var(--brand); font-weight: 700; } @@ -209,109 +459,56 @@ textarea:focus { } .item-body { + display: grid; + gap: 2px; min-width: 0; } -.item-top { - display: flex; - align-items: center; - gap: 6px; -} - -.item-name, -.item-preview, -.item-meta, -.chat-title h2, -.chat-title p { +.item-body strong, +.item-body span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.item-name { - min-width: 0; - font-weight: 650; -} - -.item-preview, -.item-meta, -.item-time { - color: var(--muted); - font-size: 12px; -} - -.item-time { - margin-left: auto; - white-space: nowrap; -} - -.presence { - width: 8px; - height: 8px; - border-radius: 50%; - background: #aeb8b2; -} - -.presence.is-online { - background: var(--green); -} - -.chat { +.thread { display: grid; - grid-template-rows: 64px minmax(0, 1fr) auto; - min-height: 100dvh; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.thread-head { + display: grid; + gap: 2px; + border-bottom: 1px solid var(--line); + padding: 12px 14px; +} + +.thread-head div, +.thread-head span { min-width: 0; -} - -.chat-head { - padding: 0 18px; - background: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(12px); -} - -.menu-btn { - display: none; -} - -.chat-title { - min-width: 0; -} - -.feedback { - margin-left: auto; - color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } .messages { min-height: 0; overflow: auto; - padding: 18px 22px 24px; + padding: 16px; } .empty { - padding: 18px; + padding: 16px; color: var(--muted); text-align: center; } -.separator { - display: flex; - align-items: center; - gap: 10px; - margin: 18px 0; - color: var(--muted); +.empty.small { + padding: 10px; font-size: 12px; } -.separator::before, -.separator::after { - content: ""; - flex: 1; - height: 1px; - background: var(--line); -} - .msg-row { display: grid; margin: 10px 0; @@ -322,20 +519,16 @@ textarea:focus { } .bubble { - max-width: min(720px, 78%); + max-width: min(720px, 82%); border: 1px solid var(--line); border-radius: 8px; - background: var(--panel); + background: #fff; overflow: hidden; } .msg-row.is-self .bubble { - border-color: #c4ddce; - background: #effaf4; -} - -.bubble.is-visual { - max-width: min(480px, 86%); + border-color: #bcdde8; + background: #eef8fb; } .meta { @@ -346,14 +539,14 @@ textarea:focus { font-size: 12px; } -.content { - padding: 8px 10px 10px; +.message-content { white-space: pre-wrap; overflow-wrap: anywhere; + padding: 8px 10px 10px; } -.content a { - color: var(--green-strong); +.message-content a { + color: var(--brand); } .emoticon { @@ -363,58 +556,40 @@ textarea:focus { } .sticker { - width: 128px; - height: 128px; + width: 118px; + height: 118px; object-fit: contain; - cursor: zoom-in; + vertical-align: middle; } -.image-shell { - min-width: 220px; - padding: 28px 18px; - border-radius: 6px; - background: var(--soft); - color: var(--muted); - text-align: center; -} - -.message-image { +.image-button { display: block; max-width: min(420px, 100%); - border-radius: 6px; - cursor: zoom-in; -} - -.og-card { - display: grid; - grid-template-columns: 90px minmax(0, 1fr); - gap: 10px; - margin-top: 8px; - padding: 8px; - border: 1px solid var(--line); border-radius: 8px; - background: #fff; - text-decoration: none; -} - -.og-card strong, -.og-card span { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.og-card span { + background: var(--soft); color: var(--muted); - font-size: 12px; + padding: 10px; +} + +.image-button img { + display: block; + max-width: 100%; + border-radius: 6px; } .composer { position: relative; + display: grid; + gap: 8px; border-top: 1px solid var(--line); - background: rgba(255, 255, 255, 0.96); - padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); + padding: 10px; +} + +.offline-note { + border-radius: 6px; + background: #f6ead8; + color: var(--warn); + padding: 8px 10px; } .compose-row { @@ -424,92 +599,50 @@ textarea:focus { align-items: end; } -textarea { - width: 100%; - min-height: 38px; - max-height: 160px; - resize: none; - padding: 9px 10px; -} - -.url-row { +.image-url-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; - margin-top: 8px; -} - -.url-row input { - height: 36px; - padding: 0 10px; -} - -.upload-tray { - display: grid; - gap: 6px; - margin-bottom: 8px; -} - -.upload-item { - display: flex; - justify-content: space-between; - gap: 8px; - border-radius: 6px; - background: var(--soft); - padding: 7px 9px; - color: var(--muted); - font-size: 12px; -} - -.picker, -.autocomplete { - position: absolute; - z-index: 8; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: var(--shadow); } .picker { - left: 16px; - bottom: calc(100% - 6px); - width: min(480px, calc(100vw - 32px)); + position: absolute; + left: 10px; + bottom: calc(100% - 2px); + z-index: 6; + width: min(520px, calc(100vw - 40px)); + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); overflow: hidden; } -.picker-head { - display: grid; - grid-template-columns: auto auto minmax(0, 1fr); - gap: 6px; - padding: 8px; - border-bottom: 1px solid var(--line); +.picker[hidden] { + display: none; } -.picker-head button, -.picker-grid button, -.autocomplete button { +.picker-tabs { + display: flex; + gap: 6px; + border-bottom: 1px solid var(--line); + padding: 8px; +} + +.picker-tabs button, +.picker-grid button { border-radius: 6px; background: var(--soft); color: var(--text); } -.picker-head button { +.picker-tabs button { padding: 7px 10px; } -.picker-head button.is-active { - background: var(--green-soft); - color: var(--green-strong); -} - -.picker-head input { - padding: 0 9px; -} - .picker-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(82px, 1fr)); gap: 6px; max-height: 280px; overflow: auto; @@ -538,54 +671,27 @@ textarea { font-size: 12px; } -.autocomplete { - left: 104px; - bottom: calc(100% + 8px); - width: 240px; - max-height: 220px; - overflow: auto; -} - -.autocomplete button { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 8px 10px; - background: transparent; - text-align: left; -} - -.autocomplete button.is-active, -.autocomplete button:hover { - background: var(--soft); -} - .drop-overlay { position: fixed; - inset: 18px; + inset: 20px; display: none; place-items: center; - z-index: 30; - border: 2px dashed var(--green); + z-index: 20; + border: 2px dashed var(--brand); border-radius: 8px; - background: rgba(246, 248, 245, 0.9); - color: var(--green-strong); - font-size: 24px; + background: rgba(244, 246, 248, 0.92); + color: var(--brand); + font-size: 22px; font-weight: 700; } -.drop-overlay.is-visible { - display: grid; -} - .lightbox { position: fixed; inset: 0; display: grid; place-items: center; - z-index: 40; - background: rgba(12, 18, 15, 0.88); + z-index: 30; + background: rgba(18, 25, 32, 0.88); } .lightbox[hidden] { @@ -595,97 +701,73 @@ textarea { .lightbox img { max-width: 92vw; max-height: 88vh; - transform-origin: center; - cursor: grab; - user-select: none; -} - -.lightbox-close, -.lightbox-tools { - position: fixed; } .lightbox-close { + position: fixed; top: 18px; right: 18px; background: rgba(255, 255, 255, 0.18); color: #fff; } -.lightbox-tools { - right: 18px; - bottom: 24px; - display: flex; - gap: 6px; -} - -.lightbox-tools button { - height: 34px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.18); - color: #fff; - padding: 0 12px; -} - -.backdrop { - display: none; -} - -@media (max-width: 900px) { - .shell { - display: block; +@media (max-width: 920px) { + .admin-shell { + grid-template-columns: 1fr; } - .sidebar { - position: fixed; - inset: 0 auto 0 0; - width: min(88vw, 340px); - z-index: 20; - transform: translateX(-102%); - transition: transform 160ms ease; - box-shadow: var(--shadow); + .admin-sidebar { + position: sticky; + top: 0; + z-index: 10; + display: grid; + grid-template-rows: auto auto; + border-right: 0; + border-bottom: 1px solid var(--line); + padding: 12px; } - body.sidebar-open .sidebar { - transform: translateX(0); + nav { + display: flex; + gap: 6px; + overflow: auto; } - .backdrop { - position: fixed; - inset: 0; - z-index: 19; - background: rgba(13, 19, 16, 0.32); + nav button { + flex: 0 0 auto; + white-space: nowrap; } - body.sidebar-open .backdrop { - display: block; + .topbar { + display: grid; + align-items: start; } - .menu-btn { - display: inline-grid; + .top-actions { + flex-wrap: wrap; } - .chat-head { - padding: 0 12px; + .chat-layout { + grid-template-columns: 1fr; + min-height: auto; } - .messages { - padding: 14px 12px 18px; + .chat-lists { + max-height: 45dvh; + } + + .thread { + min-height: 70dvh; + } + + .inline-form, + .user-row, + .compose-row, + .image-url-form { + grid-template-columns: 1fr; } .bubble { - max-width: 92%; - } - - .composer { - padding: 10px 10px calc(10px + env(safe-area-inset-bottom)); - } - - textarea { - max-height: 108px; - } - - .url-row { - grid-template-columns: 1fr; + max-width: 94%; } }