From 3e8b8543f76b2497dc185bfa4db95d2e0253a25e Mon Sep 17 00:00:00 2001 From: Mazha0309 Date: Wed, 22 Jul 2026 14:43:46 +0800 Subject: [PATCH 1/6] docs: align API documentation with current behavior --- README.md | 24 +-- ...1.md => personal-cloud-snapshot-api-v1.md} | 4 +- ...=> personal-dictionary-snapshot-api-v1.md} | 2 +- docs/public-liveshare-statistics-api-v1.md | 168 ++++++++++++++++++ .../plans/2026-07-09-server-redesign-plan.md | 4 + .../specs/2026-07-09-server-redesign.md | 5 + .../2026-07-11-collaboration-v1-design.md | 19 +- live/README.md | 11 +- 8 files changed, 215 insertions(+), 22 deletions(-) rename docs/{personal-cloud-snapshot-v1.md => personal-cloud-snapshot-api-v1.md} (98%) rename docs/{personal-dictionary-snapshot-v1.md => personal-dictionary-snapshot-api-v1.md} (98%) create mode 100644 docs/public-liveshare-statistics-api-v1.md diff --git a/README.md b/README.md index f69877a..fc528c5 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,11 @@ OpenLogTool 配套服务端,提供用户认证、Session/日志持久化、管理后台,以及协作 v1 的发布、成员和实时事件协议。 完整协作协议见 [Session 协作 v1 设计](docs/superpowers/specs/2026-07-11-collaboration-v1-design.md)。 -账户级本地记录云快照协议见 [Personal cloud snapshot v1](docs/personal-cloud-snapshot-v1.md)。 -账户级词库用户改动协议见 [Personal dictionary snapshot v1](docs/personal-dictionary-snapshot-v1.md)。 +当前专项 API 文档采用“主题 + `api-v1`”的 kebab-case 文件名: + +- [Personal Cloud Snapshot API v1](docs/personal-cloud-snapshot-api-v1.md) +- [Personal Dictionary Snapshot API v1](docs/personal-dictionary-snapshot-api-v1.md) +- [Public Live Share Statistics API v1](docs/public-liveshare-statistics-api-v1.md) ## 技术栈 @@ -165,8 +168,8 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ | PATCH | `/api/v1/admin/users/:userId/login-expiration` | 管理员为其他账户开启或关闭“登录永不过期”策略 | | GET | `/api/v1/admin/audit-events?...` | 按稳定 cursor 查询运行时管理审计 | | GET | `/api/v1/admin/collaboration-metrics` | 管理员读取进程 CPU/内存、运行环境资源、请求、连接和数据库聚合指标 | -| GET | `/api/v1/admin/public-liveshare-stats?limit=` | 管理员读取 Live Share 当前连接与匿名有效打开统计 | -| GET | `/api/v1/admin/public-liveshare-stats/:publicShareId` | 管理员读取单个 Live Share 的独立统计详情 | +| GET | `/api/v1/admin/public-liveshare-stats?limit=` | 管理员读取 Live Share 当前连接与有效打开统计;列表响应 `schemaVersion: 1` | +| GET | `/api/v1/admin/public-liveshare-stats/:publicShareId` | 管理员读取单个 Live Share 的统计及访客 IP 详情;详情响应 `schemaVersion: 2` | | GET | `/api/v1/admin/session-event-retention/preview` | 管理员只读预演 Session 事件裁剪 | | POST | `/api/v1/admin/session-event-retention/prune` | 管理员显式、幂等执行有界 Session 事件裁剪 | | POST | `/api/v1/admin/elevate` | 当前密码复核,签发 5 分钟危险操作 elevation | @@ -208,11 +211,11 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ 公开 snapshot 和 event 使用逐字段白名单 DTO:保留 Session 标题、状态及 Log 业务字段(包括电台设备字段 `device`),删除 actor、user/account ID、actor deviceId/sourceDeviceId、mutationId、entityVersion、成员、邀请和内部审计数据。同一 share 最多存在 8 张、同一 public JWT `jti` 最多存在 4 张未消费 ticket;签发前立即清理已过期 ticket,成功消费后在同一事务中删除 ticket 行。公开链接被 Owner 撤销、自然到期或所属 Session 删除后,exchange、REST、未消费 ticket 和现有 `/ws/public` 连接都会停止授权;Session 删除时,已连接页面先收到裁剪后的最终 `session.deleted` 再关闭。 -管理员统计把 Live Share 的“当前观看连接”定义为当前进程内活动的公开 WebSocket 数,近似表示打开的页面/标签页,不代表可识别的独立人数;断网连接最多会在心跳检测后移除。“累计有效打开”由公开页面每次生命周期生成仅存内存的随机 ID,并在 secret 验证成功时登记,同一页面的 5 分钟 access token 续签不会重复累计。服务器只保存由 `PUBLIC_SHARE_HMAC_KEY` 派生的 HMAC 去重值,不保存原始页面 ID、IP 或 User-Agent;分享撤销、过期或 Session 删除后清理去重明细,但保留聚合计数。去重明细硬限制为每个分享 10,000 条、当前数据库 100,000 条;达到任一限制时计入触发限制的那次打开,随后停止增加该分享的累计数并在管理端标成下限值,避免公开链接造成无限数据库增长。该统计从迁移 v23 部署后开始,不回填历史访问,也不能用于识别访客或计费。 +管理员统计把 Live Share 的“当前观看连接”定义为当前进程内活动的公开 WebSocket 数,近似表示打开的页面/标签页,不代表可识别的独立人数;断网连接最多会在心跳检测后移除。“累计有效打开”由公开页面每次生命周期生成仅存内存的随机 ID,并在 secret 验证成功时登记,同一页面的 5 分钟 access token 续签不会重复累计。服务器只保存由 `PUBLIC_SHARE_HMAC_KEY` 派生的 HMAC 去重值,不保存原始页面 ID 或 User-Agent;迁移 v24 起还会为每个去重会话保存最近一次可信请求 IP,并在管理员详情接口中返回,IP 的准确性和安全边界取决于 `TRUST_PROXY`。分享撤销、过期或 Session 删除后清理去重和 IP 明细,但保留聚合计数。去重明细硬限制为每个分享 10,000 条、当前数据库 100,000 条;达到任一限制时计入触发限制的那次打开,随后停止增加该分享的累计数并在管理端标成下限值,避免公开链接造成无限数据库增长。聚合统计从迁移 v23 部署后开始,IP 明细从迁移 v24 部署后开始,均不回填历史访问;旧明细的 IP 可为 `null`。这些数据不能可靠识别自然人,也不应用于计费。完整响应、范围和隐私约束见 [Public Live Share Statistics API v1](docs/public-liveshare-statistics-api-v1.md)。 生产默认启用实例内存限流:公开链接管理按 actor/IP/Session 为 60 次/分钟,并另按 actor/Session 限制为 120 次/分钟;exchange 按 IP 为 30 次/分钟、按 IP+share 为 10 次/分钟;snapshot 与 public WS ticket 分别按 IP+Session 为 30 次/分钟、按 share 为 60 次/分钟。这些限流桶、snapshot 并发计数与实时 hub 都是单进程内状态,生产环境必须保持单 Node.js 进程;多副本部署前需实现共享限流状态和跨实例 pub/sub。 -Mutation 单批最多 100 个操作和 1 MiB。每个操作使用独立 UUID `mutationId`,重试必须复用;服务端把首次 accepted/conflict/rejected 结果持久化。Log 支持 create/update/delete/restore,Session Owner 支持 title update/close/reopen/delete,全部使用严格 `baseVersion`。普通 Owner/Editor 只能修改自己创建的 Log,Viewer 只读,历史 `created_by=NULL` 的记录对所有普通成员只读;只有管理员治理接口可跨作者修订,并继续写入同一规范 Session 事件流。Session 删除要求先关闭活动 Session;未完成发布的 `initializing` Session 可直接取消。成功删除会原子撤销邀请和 WS ticket、生成唯一最终 `session.deleted` 事件,并在广播终止事件后关闭该 Session 的实时连接。 +Mutation 单批最多 100 个操作和 1 MiB。每个操作使用独立 UUID `mutationId`,重试必须复用;服务端把首次 accepted/conflict/rejected 结果持久化。Log 支持 create/update/delete/restore,Session Owner 支持 title update/close/reopen/delete,全部使用严格 `baseVersion`。活动 Session 中的 Owner/Editor 可以修改、删除和恢复会话内任意 Log,Viewer 只读;`ownedByCurrentUser` 仅描述创建者关系,实际写权限以 `canMutate`、当前成员角色和 Session 状态为准。`created_by=NULL` 的历史记录不再被特殊锁定。Session 删除要求先关闭活动 Session;未完成发布的 `initializing` Session 可直接取消。成功删除会原子撤销邀请和 WS ticket、生成唯一最终 `session.deleted` 事件,并在广播终止事件后关闭该 Session 的实时连接。 `server-info.features` 包含 `collaborationSecurityAudit` 时,服务端支持 Session 级协作安全审计。审计记录成员、所有权、邀请、公开链接和 Session 删除的九种实际安全状态变化;公开链接对应 `public_share.created`、`public_share.revoked`。`GET /api/v1/sessions/:id/audit-events` 仅允许该 Session 的当前 Owner 调用;Session 软删除后,最终 Owner 仍可读取包含删除事件的审计记录。服务器全局 `admin` 身份不会旁路对象级 membership,未加入该 Session 时仍返回 `404 NOT_FOUND`。 @@ -224,7 +227,7 @@ Mutation 单批最多 100 个操作和 1 MiB。每个操作使用独立 UUID `mu Access token 默认 15 分钟有效,refresh token 默认 30 天有效并在刷新时轮换。 -原有 overview、账户分页、指标和事件裁剪接口仍保持最小 control-plane DTO,不泄露业务内容。新增的治理接口则显式授予当前全局管理员跨 Session 的调查和纠错能力:敏感详情读取会去重记入治理审计,业务修改复用规范 mutation/event 流,危险操作还必须提供原因、`Idempotency-Key` 和 5 分钟 elevation。普通成员 API 不会因为账户 `role=admin` 而绕过 membership 或作者校验。 +原有 overview、账户分页、指标和事件裁剪接口仍保持最小 control-plane DTO,不泄露业务内容。新增的治理接口则显式授予当前全局管理员跨 Session 的调查和纠错能力:敏感详情读取会去重记入治理审计,业务修改复用规范 mutation/event 流,危险操作还必须提供原因、`Idempotency-Key` 和 5 分钟 elevation。普通成员 API 不会因为账户 `role=admin` 而绕过 membership 或 Session 状态规则。 活动 Session 若被未提交实时草稿或字段租约阻塞,管理员可调用 `POST /api/v1/admin/sessions/:id/close-discarding-live-draft`,提供 `expectedVersion`、审计原因、`Idempotency-Key` 和 elevation,在一个事务中丢弃草稿及设备重放状态并关闭 Session;成功后服务端清除内存字段锁并广播关闭事件。普通删除仍只接受 `closed` 或未完成发布的 `initializing` Session。 @@ -242,7 +245,7 @@ Access token 默认 15 分钟有效,refresh token 默认 30 天有效并在刷 运行时管理审计记录注册开关、账户角色、refresh token 撤销和实际事件裁剪。只有 prune 确实删除事件时才写入 `session_events.pruned`,只记录删除数量、受影响 Session 数和策略,不记录 Session ID 或内容。`GET /api/v1/admin/audit-events` 支持 `action`、`actorUserId`、`targetUserId`、`from`、`to`、`cursor` 和 `limit`;时间窗口是 `[from,to)`,cursor 使用服务器密钥签名并与过滤条件、分页边界绑定,响应只返回管理事件白名单字段,不包含密码、token、IP、User-Agent 或协作数据。 -旧 `/api/auth`、`/api/admin`、`/api/sessions`、`/api/shares`、旧 Liveshare 数据接口与无鉴权 `/ws` 均不再挂载。所有账户、管理和协作流量只走 `/api/v1`;迁移 v6 会统一撤销历史 `shares`,防止绕过 v1 成员权限、作者校验、幂等与副本序列。 +旧 `/api/auth`、`/api/admin`、`/api/sessions`、`/api/shares`、旧 Liveshare 数据接口与无鉴权 `/ws` 均不再挂载。所有账户、管理和协作流量只走 `/api/v1`;迁移 v6 会统一撤销历史 `shares`,防止绕过 v1 成员权限、Session 状态校验、幂等与副本序列。 ## 页面 @@ -253,7 +256,7 @@ Access token 默认 15 分钟有效,refresh token 默认 30 天有效并在刷 成员门户与管理后台是同一个响应式 React 应用,支持简体中文/英文、system/light/dark 主题、可折叠桌面侧栏和移动抽屉。公开 Liveshare 是独立最小 bundle:启动时立即清除 URL fragment,只在内存保存 secret/access/ticket,严格执行 exchange → snapshot → 单次 WS ticket → `hello/backlog/ready/live`,遇到序列缺口、过期、撤销或断线会重新同步。 -管理员“运行与维护”页面每 10 秒在前台可见时刷新,展示服务进程 CPU/RSS/堆内存、Node 可见的运行环境 CPU/内存与负载、可用时的 cgroup v2 内存、请求错误和成员/公开连接,并列出逐 Live Share 的当前连接和累计有效打开。进程计数和当前连接在服务重启后归零;累计打开保存在当前数据库。容器和宿主资源边界会随部署运行时而异,页面会明确标注统计范围,不会把它描述为跨实例监控。 +管理员“运行与维护”页面每 10 秒在前台可见时刷新,展示服务进程 CPU/RSS/堆内存、Node 可见的运行环境 CPU/内存与负载、可用时的 cgroup v2 内存、请求错误和成员/公开连接,并列出逐 Live Share 的当前连接和累计有效打开;进入单分享详情后还会显示访客最近可信请求 IP、首末访问时间及当前在线提示。进程计数和当前连接在服务重启后归零;累计打开保存在当前数据库。容器和宿主资源边界会随部署运行时而异,页面会明确标注统计范围,不会把它描述为跨实例监控。 ## 数据库迁移 @@ -281,6 +284,7 @@ Access token 默认 15 分钟有效,refresh token 默认 30 天有效并在刷 - 创建账户级个人记录云快照和用户词库改动快照(迁移 v19、v20); - 规范用户名的 Unicode 不区分大小写身份,并增加账户登录有效期策略(迁移 v21、v22); - 创建隐私安全的公开分享打开聚合与短期 HMAC 去重明细(迁移 v23); +- 为公开分享访问明细记录最近可信请求 IP,并把页面会话关联到公开 WebSocket ticket(迁移 v24); - 将邀请码 HMAC 密钥指纹绑定到服务器数据库,阻止静默错换密钥; - 启用 WAL、外键和 5 秒 busy timeout。 @@ -322,6 +326,6 @@ Docker Compose 默认只将服务发布到 `127.0.0.1:3000`,并启用非 root ## 当前实施状态 -协作 v1、账户安全、成员门户、管理员治理和安全公开 Liveshare 已形成完整单实例闭环:包含发布/快照、成员与邀请、作者级写权限、持久 mutation 去重、连续事件、REST/WS 追赶、共享草稿、公开分享、账户/设备管理、强制改密、治理审计、导出、备份与可控运行参数。快照接口支持 `includeDeleted=true`,供游标过期重装时在同一读事务返回活动 Log、tombstone 和 high watermark。 +协作 v1、账户安全、成员门户、管理员治理和安全公开 Liveshare 已形成完整单实例闭环:包含发布/快照、成员与邀请、角色及会话状态写权限、持久 mutation 去重、连续事件、REST/WS 追赶、共享草稿、公开分享、账户/设备管理、强制改密、治理审计、导出、备份与可控运行参数。快照接口支持 `includeDeleted=true`,供游标过期重装时在同一读事务返回活动 Log、tombstone 和 high watermark。 成员/公开实时 hub、字段租约、限流、并发计数和运行时指标仍是进程内状态,生产环境必须保持单 Node.js 实例。启用 cluster 或多副本前需要加入共享租约/限流状态、跨实例 pub/sub 与指标汇聚。数据库恢复刻意不提供在线 Web 操作:先停止服务并备份现库,再使用受控 CLI 替换和校验数据库。 diff --git a/docs/personal-cloud-snapshot-v1.md b/docs/personal-cloud-snapshot-api-v1.md similarity index 98% rename from docs/personal-cloud-snapshot-v1.md rename to docs/personal-cloud-snapshot-api-v1.md index acfc601..73bc3dd 100644 --- a/docs/personal-cloud-snapshot-v1.md +++ b/docs/personal-cloud-snapshot-api-v1.md @@ -1,4 +1,4 @@ -# Personal cloud snapshot v1 +# Personal Cloud Snapshot API v1 This API stores one private, account-scoped snapshot of a user's local-only Sessions and Logs. It is deliberately independent from collaboration Sessions: @@ -7,7 +7,7 @@ deletes rows in the collaboration `sessions`, `logs`, membership, event, draft, invite, or Live Share tables. Dictionary changes deliberately use the separate -[`personalDictionarySnapshots`](personal-dictionary-snapshot-v1.md) capability, +[`personalDictionarySnapshots`](personal-dictionary-snapshot-api-v1.md) capability, table, revision, and endpoints. Keeping the protocols independent prevents an older records-only client from erasing dictionary data with a v1 replacement. diff --git a/docs/personal-dictionary-snapshot-v1.md b/docs/personal-dictionary-snapshot-api-v1.md similarity index 98% rename from docs/personal-dictionary-snapshot-v1.md rename to docs/personal-dictionary-snapshot-api-v1.md index a2a2d52..00ee2c0 100644 --- a/docs/personal-dictionary-snapshot-v1.md +++ b/docs/personal-dictionary-snapshot-api-v1.md @@ -1,4 +1,4 @@ -# Personal dictionary snapshot v1 +# Personal Dictionary Snapshot API v1 This API stores one private, account-scoped snapshot of dictionary changes. It is independent from the personal records snapshot and from collaboration diff --git a/docs/public-liveshare-statistics-api-v1.md b/docs/public-liveshare-statistics-api-v1.md new file mode 100644 index 0000000..a0fd155 --- /dev/null +++ b/docs/public-liveshare-statistics-api-v1.md @@ -0,0 +1,168 @@ +# Public Live Share Statistics API v1 + +This administrator-only API reports aggregate opens, current public WebSocket +connections, and bounded visitor details for Live Share links. It is separate +from `/api/v1/admin/collaboration-metrics`: the general metrics endpoint never +returns Session identity or IP addresses, while this API intentionally returns +per-share operational details to a current server administrator. + +Both endpoints require a Bearer access token whose claim and current database +role are `admin`. Responses use `Cache-Control: no-store`. When instance rate +limiting is enabled, the statistics endpoints share a limit of 30 requests per +minute for each administrator and request IP pair. + +## List statistics + +`GET /api/v1/admin/public-liveshare-stats?limit=50` + +`limit` defaults to 50 and must be an integer from 1 through 100. Unknown query +parameters are rejected. The endpoint returns currently connected shares plus +recently active shares, sorts current connections first, and truncates the +result to `limit` items. + +~~~json +{ + "schemaVersion": 1, + "generatedAt": "2026-07-22T12:00:00.000Z", + "scope": { + "currentConnections": "current-process", + "openCounts": "current-database", + "singleProcessOnly": true, + "anonymousPageSessions": true, + "trackingStartedAt": "2026-07-20T10:00:00.000Z", + "viewSessionDetailLimits": { + "perShare": 10000, + "total": 100000 + }, + "visitorDetailLimit": 200, + "visitorIpSource": "trusted-request-ip" + }, + "totals": { + "activeShares": 2, + "currentConnections": 3, + "totalOpens": 120, + "sharesWithOpens": 8, + "saturatedShares": 0 + }, + "items": [ + { + "publicShareId": "7aa4de16-a849-4fde-8574-dae76fd57f4c", + "sessionId": "weekly-net", + "sessionTitle": "Weekly net", + "sessionStatus": "active", + "state": "active", + "createdAt": "2026-07-22T10:00:00.000Z", + "expiresAt": "2026-07-23T10:00:00.000Z", + "revokedAt": null, + "currentConnections": 2, + "totalOpens": 18, + "openCountSaturated": false, + "openCountSaturatedAt": null, + "firstOpenedAt": "2026-07-22T10:01:00.000Z", + "lastOpenedAt": "2026-07-22T11:58:00.000Z", + "lastAccessedAt": "2026-07-22T11:59:00.000Z" + } + ] +} +~~~ + +`state` is `active`, `revoked`, `expired`, or `sessionDeleted`. +`currentConnections` counts active public WebSocket connections in the current +Node.js process. It approximates open pages or tabs, not unique people, and is +cleared when the process restarts. `totalOpens` and the remaining aggregate +fields come from the current database. + +`openCountSaturated: true` means the deduplication-detail storage limit was +reached. In that state `totalOpens` is a lower bound rather than an exact count. + +## Read one share + +`GET /api/v1/admin/public-liveshare-stats/:publicShareId` + +The path uses the stable public share ID and accepts no query parameters. The +endpoint returns the same item shape plus at most 200 visitor rows. + +~~~json +{ + "schemaVersion": 2, + "generatedAt": "2026-07-22T12:00:00.000Z", + "scope": { + "currentConnections": "current-process", + "openCounts": "current-database", + "singleProcessOnly": true, + "anonymousPageSessions": true, + "trackingStartedAt": "2026-07-20T10:00:00.000Z", + "viewSessionDetailLimits": { + "perShare": 10000, + "total": 100000 + }, + "visitorDetailLimit": 200, + "visitorIpSource": "trusted-request-ip" + }, + "item": { + "publicShareId": "7aa4de16-a849-4fde-8574-dae76fd57f4c", + "sessionId": "weekly-net", + "sessionTitle": "Weekly net", + "sessionStatus": "active", + "state": "active", + "createdAt": "2026-07-22T10:00:00.000Z", + "expiresAt": "2026-07-23T10:00:00.000Z", + "revokedAt": null, + "currentConnections": 2, + "totalOpens": 18, + "openCountSaturated": false, + "openCountSaturatedAt": null, + "firstOpenedAt": "2026-07-22T10:01:00.000Z", + "lastOpenedAt": "2026-07-22T11:58:00.000Z", + "lastAccessedAt": "2026-07-22T11:59:00.000Z" + }, + "visitors": [ + { + "ipAddress": "203.0.113.42", + "firstSeenAt": "2026-07-22T10:01:00.000Z", + "lastSeenAt": "2026-07-22T11:59:00.000Z", + "currentConnections": 1 + } + ] +} +~~~ + +Visitor rows represent anonymous page-lifetime sessions, not accounts or +verified people. Multiple rows may have the same IP, and one person can produce +multiple rows. `firstSeenAt`, `lastSeenAt`, or `ipAddress` may be `null` for +legacy or currently connected rows that have no matching stored detail. + +## Tracking and retention + +The public page creates a random page-lifetime `viewSessionId`. After successful +secret verification, the server stores only a share-scoped HMAC of that ID; it +never stores or returns the raw value. Reissuing the five-minute public access +token for the same page does not increase `totalOpens`. + +Aggregate open tracking begins with database migration v23. Migration v24 adds +the most recently observed trusted request IP to each stored page session and +links that session to its public WebSocket ticket. Neither migration backfills +older traffic, so `trackingStartedAt` describes the start of aggregate tracking +and older detail rows can have a null IP. + +The server retains at most 10,000 page-session details per share and 100,000 in +the database. Revoking or expiring a share, or deleting its Session, permits the +detail rows and IP addresses to be cleaned up while aggregate totals remain. +The server does not store a User-Agent for these statistics. + +The IP comes from Express's trusted request IP. Configure `TRUST_PROXY` to the +actual reverse-proxy topology and prevent untrusted clients from reaching the +Node.js port directly. Otherwise a proxy address may be recorded, or a forged +forwarded address may be trusted. IP addresses are personal data in many +deployments; restrict administrator access and apply an appropriate retention +policy. These statistics are not suitable for identifying people or billing. + +## Errors + +Errors use the standard API v1 error envelope. Relevant responses include: + +- `401 AUTH_REQUIRED` or `401 TOKEN_INVALID` for missing or invalid access; +- `403 ADMIN_REQUIRED` when the current account is not an administrator; +- `404 PUBLIC_SHARE_NOT_FOUND` for an unknown detail ID; +- `422 VALIDATION_FAILED` for an invalid ID, limit, or unknown parameter; +- `429 RATE_LIMITED` when the enabled instance limiter is exceeded. diff --git a/docs/superpowers/plans/2026-07-09-server-redesign-plan.md b/docs/superpowers/plans/2026-07-09-server-redesign-plan.md index 7e245bc..d5bd2ea 100644 --- a/docs/superpowers/plans/2026-07-09-server-redesign-plan.md +++ b/docs/superpowers/plans/2026-07-09-server-redesign-plan.md @@ -1,5 +1,9 @@ # OpenLogTool 服务端重构实施计划 +> **归档计划(2026-07-09)**:本文仅用于保留早期实施过程,不是当前 API、 +> 依赖版本或部署说明。当前行为以仓库根目录 `README.md`、 +> `docs/*-api-v1.md` 和 `../specs/2026-07-11-collaboration-v1-design.md` 为准。 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. **Goal:** 将 OpenLogToolServer 完全重写为 TypeScript + Express + SQLite,提供用户认证、session/日志 CRUD、liveshare Web 实时展示。 diff --git a/docs/superpowers/specs/2026-07-09-server-redesign.md b/docs/superpowers/specs/2026-07-09-server-redesign.md index 0b9d2d3..c7c7266 100644 --- a/docs/superpowers/specs/2026-07-09-server-redesign.md +++ b/docs/superpowers/specs/2026-07-09-server-redesign.md @@ -1,5 +1,10 @@ # OpenLogTool 服务端重建设计 +> **归档文档(2026-07-09)**:本文记录早期重建方向,不是当前 API 合同。 +> 其中未版本化路由、永久 JWT、首位注册者自动成为管理员等内容均已废弃。 +> 当前行为以仓库根目录 `README.md`、`docs/*-api-v1.md` 和 +> `2026-07-11-collaboration-v1-design.md` 为准。 + ## 目标 将现有 Node.js 服务端(OpenLogToolServer)完全重写为 TypeScript + Express,简化功能:用户认证、session 持久化、liveshare 实时协作。 diff --git a/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md b/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md index d2611d6..ffb9bec 100644 --- a/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md +++ b/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md @@ -1,6 +1,6 @@ # OpenLogTool Session 协作 v1 设计 -> 状态:单实例协作 v1 服务端 API(阶段 0-4)已实现并验证;高级逐字段冲突 UI、公开安全页面客户端与跨实例运维仍待实施 +> 状态:单实例协作 v1 服务端 API、公开安全页面和共享草稿客户端已实现并验证;高级逐字段冲突 UI 与跨实例运维仍待实施 > 日期:2026-07-12 > 适用仓库:`openlogtool`、`OpenLogToolServer` > 协议主版本:`1` @@ -743,17 +743,21 @@ WS /ws/public?ticket=... Owner 主动撤销时,公开链接、未消费 ticket、`public_share.revoked` 审计和幂等响应在同一个事务中提交,之后立即关闭该 share 的现有连接。自然到期由每次 REST/握手复查和连接到期 timer 强制执行。Session 删除事务会同时撤销全部公开链接和 ticket,并在提交后先向现有公开连接投递裁剪后的最终 `session.deleted`,再关闭连接。 -### 9.8 协作运维指标与事件保留 +### 9.8 协作运维指标、Live Share 统计与事件保留 ~~~text GET /api/v1/admin/collaboration-metrics +GET /api/v1/admin/public-liveshare-stats?limit=50 +GET /api/v1/admin/public-liveshare-stats/{publicShareId} GET /api/v1/admin/session-event-retention/preview POST /api/v1/admin/session-event-retention/prune ~~~ -三个接口都要求成员 access token 的 admin claim 与数据库中的当前 admin 角色同时成立,并使用严格 query/body 白名单和 `Cache-Control: no-store`。指标与 preview 每名管理员/IP 各限 12 次/分钟,prune 限 6 次/分钟;这些桶与其他运行时限流一样只存在当前进程。 +这些接口都要求成员 access token 的 admin claim 与数据库中的当前 admin 角色同时成立,并使用严格 query/body 白名单和 `Cache-Control: no-store`。协作指标及两个 Live Share 统计接口共享每名管理员/IP 30 次/分钟的限流,preview 限 12 次/分钟,prune 限 6 次/分钟;这些桶与其他运行时限流一样只存在当前进程。 -指标响应带 `schemaVersion=1`,明确区分从当前进程启动时累计的 runtime counter 与从当前数据库一致读取的 gauge。runtime 固定覆盖 HTTP surface/结果/累计 `le*` 延迟桶、mutation accepted/conflict/rejected/replay、已提交事件的 REST/成员 WS/公开 WS 投递,以及成员/公开 WebSocket 尝试、拒绝、活动、关闭、非零 cursor 恢复、重同步、撤权和控制帧失败;gauge 固定覆盖 Session/Log/membership 数量、活动 invite/public share、仍可授权的 ticket、持久事件/幂等行和事件保留下界。维度集合固定,禁止把 Session ID、用户 ID、路径参数、IP 或内容作为动态 label,也不返回标题、Log、membership 关联或 secret。进程重启后 runtime counter 从零开始,多实例部署必须由外部系统汇聚。 +协作指标响应带 `schemaVersion=2`,明确区分从当前进程启动时累计的 runtime counter 与从当前数据库一致读取的 gauge。runtime 固定覆盖进程/系统 CPU 与内存、HTTP surface/结果/累计 `le*` 延迟桶、mutation accepted/conflict/rejected/replay、已提交事件的 REST/成员 WS/公开 WS 投递,以及成员/公开 WebSocket 尝试、拒绝、活动、关闭、非零 cursor 恢复、重同步、撤权和控制帧失败;gauge 固定覆盖 Session/Log/membership 数量、活动 invite/public share、仍可授权的 ticket、持久事件/幂等行和事件保留下界。维度集合固定,禁止把 Session ID、用户 ID、路径参数、IP 或内容作为动态 label,也不返回标题、Log、membership 关联或 secret。进程重启后 runtime counter 从零开始,多实例部署必须由外部系统汇聚。 + +Live Share 列表响应为 `schemaVersion=1`,返回当前进程连接数和当前数据库的有效打开聚合;单分享详情响应为 `schemaVersion=2`,额外返回最多 200 条匿名页面会话的最近可信请求 IP、首末访问时间和当前连接数。页面原始随机 ID 与 User-Agent 不存储;IP 从迁移 v24 起保存且不回填历史数据,受 `TRUST_PROXY` 安全边界影响。完整字段、计数饱和语义和隐私限制以 [Public Live Share Statistics API v1](../../public-liveshare-statistics-api-v1.md) 为准。 preview 在一致读事务中执行与 prune 相同的计划器但零写入;prune 要求符合上述安全标识合同的 `Idempotency-Key`,并在 `BEGIN IMMEDIATE` 中再次确认当前 admin、精确重放或执行裁剪、写入管理审计及保存响应。策略字段与边界如下: @@ -1380,11 +1384,11 @@ Liveshare 页面流程: - mutation processor、版本、seq 和 session_events。 - events API、WS ticket、catch-up/live 握手。 - Rust event applier 和 Dart coordinator。 -- 公开 Liveshare 快照 + 实时事件(服务端已完成,安全页面客户端仍暂停;不复用旧通道)。 +- 公开 Liveshare 快照 + 实时事件及安全页面客户端(不复用旧通道)。 验收:在线 Owner/Editor/Viewer 和公开页面实时收敛,断开再连不丢事件。 -实施结果(2026-07-13):服务端迁移 v8、Log/Session mutation、连续事件、补拉与鉴权 WebSocket 已落地;迁移 v11 完成公开 Liveshare,迁移 v12 完成显式事件裁剪和固定维度指标,迁移 v13 又完成持久共享点名草稿、字段租约、设备序列重放、原子 commit/discard 和成员控制消息。安全页面与共享草稿客户端仍待完成,旧 share/WS 通道不会恢复。当前 realtime hub、字段租约、限流、snapshot 并发计数与 runtime 指标都只在进程内实现,要求单 Node 实例部署。 +实施结果(更新至 2026-07-22):服务端迁移 v8、Log/Session mutation、连续事件、补拉与鉴权 WebSocket 已落地;迁移 v11 完成公开 Liveshare,迁移 v12 完成显式事件裁剪和固定维度指标,迁移 v13 完成持久共享点名草稿、字段租约、设备序列重放、原子 commit/discard 和成员控制消息,迁移 v23/v24 完成有效打开聚合、访客 IP 和在线连接关联。安全页面与共享草稿客户端均已接入,旧 share/WS 通道不会恢复。当前 realtime hub、字段租约、限流、snapshot 并发计数与 runtime 指标都只在进程内实现,要求单 Node 实例部署。 ### 阶段 3:离线、重试和冲突 @@ -1403,7 +1407,8 @@ Liveshare 页面流程: - 已完成:迁移 v12、管理员 preview/prune、连续前缀与最低保留量约束、幂等聚合审计,并通过 `sessionEventRetention` capability 协商。 - 已完成:服务端 request、mutation、event 和成员/公开 WebSocket 固定维度计数及数据库 gauge,并通过 `collaborationOperationalMetrics` capability 协商。 - 已完成:迁移 v13、持久共享点名草稿、字段租约、设备序列重放和原子 commit/discard,并通过 `collaborationLiveDraft` capability 协商。 -- 待完成:安全 Liveshare 页面客户端和跨实例实时 pub/sub。 +- 已完成:安全 Liveshare 页面客户端、共享草稿客户端及按分享查看的访问统计。 +- 待完成:跨实例实时 pub/sub、共享租约/限流与指标汇聚。 - 客户端 outbox、重连和高级冲突交互的可观测性可在后续 UI/遥测设计中补充,不作为服务端 v1 API 缺口。 ## 23. 必须保持的不变量 diff --git a/live/README.md b/live/README.md index 95ee9c9..3b5d6b6 100644 --- a/live/README.md +++ b/live/README.md @@ -1,6 +1,6 @@ -# OpenLogTool secure Liveshare +# OpenLogTool Secure Live Share -The public, read-only Liveshare client for collaboration protocol v1. +The public, read-only Live Share client for collaboration protocol v1. It expects to be mounted as an SPA at `/live` and opened with a capability URL: @@ -10,6 +10,13 @@ It expects to be mounted as an SPA at `/live` and opened with a capability URL: The fragment secret is removed from browser history immediately and stays only in memory. The client checks `server-info`, exchanges the secret for a short-lived public token, fetches a full public snapshot, then connects to `/ws/public` with a one-time ticket. WebSocket data is accepted only in `hello -> backlog -> ready -> live` order; a sequence gap triggers a new snapshot. +Each page lifetime also sends a random anonymous view-session ID during exchange. +The server stores only its share-scoped HMAC and, from database migration v24, +the latest trusted request IP for administrator statistics. It does not store the +raw page ID or User-Agent. See +[`Public Live Share Statistics API v1`](../docs/public-liveshare-statistics-api-v1.md) +for the response schema, retention limits, and `TRUST_PROXY` requirements. + ```bash npm run dev npm run lint From 7f58983627f5bb3067ec1161bc37b415c2bcb3bd Mon Sep 17 00:00:00 2001 From: Mazha0309 Date: Wed, 22 Jul 2026 15:04:22 +0800 Subject: [PATCH 2/6] feat: export personal snapshots as v7 backups --- README.md | 2 + docs/personal-cloud-snapshot-api-v1.md | 34 ++- src/api/admin-personal-snapshots-v1.ts | 67 ++++++ src/api/personal-snapshot-v1.ts | 54 +++++ src/personal-snapshot/database-backup-v7.ts | 193 ++++++++++++++++++ test/admin-personal-snapshots-v1.test.ts | 126 ++++++++++++ test/personal-snapshot-v1.test.ts | 139 +++++++++++++ web/src/api.ts | 65 ++++-- web/src/components/PersonalSnapshotViewer.tsx | 36 +++- web/src/i18n.ts | 8 + .../admin/AdminPersonalSnapshotDetailPage.tsx | 7 +- web/src/pages/app/PersonalCloudPage.tsx | 7 +- 12 files changed, 719 insertions(+), 19 deletions(-) create mode 100644 src/personal-snapshot/database-backup-v7.ts diff --git a/README.md b/README.md index fc528c5..67782b5 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ | GET/DELETE | `/api/v1/account/devices...` | 查看并撤销自己的设备会话 | | GET/PUT | `/api/v1/account/personal-snapshot` | 读取元数据或按 revision 原子替换个人记录云快照 | | GET | `/api/v1/account/personal-snapshot/download` | 下载个人记录云快照;与协作 Session 完全分离 | +| GET | `/api/v1/account/personal-snapshot/database-backup-v7` | 将自己的记录快照及可用词库改动导出为客户端可恢复的数据库备份 v7 JSON | | GET/PUT | `/api/v1/account/personal-dictionary-snapshot` | 读取元数据或按 revision 原子替换词库用户改动快照 | | GET | `/api/v1/account/personal-dictionary-snapshot/download` | 下载用户词条及默认词条删除覆盖;不传输完整内置词库 | | GET | `/api/v1/admin/overview` | 管理员读取服务器与用户、Session 的非识别聚合概览 | @@ -161,6 +162,7 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ | GET | `/api/v1/admin/users?q=&role=&page=&pageSize=` | 管理员分页搜索账户 | | GET | `/api/v1/admin/personal-snapshots?q=&page=&pageSize=` | 管理员分页查看各账户个人云快照元数据;不属于协作 Session | | GET | `/api/v1/admin/personal-snapshots/:userId` | 管理员只读查看并审计某账户的完整个人云快照 | +| GET | `/api/v1/admin/personal-snapshots/:userId/database-backup-v7` | 管理员审计后导出指定账户的客户端数据库备份 v7 JSON | | GET | `/api/v1/admin/personal-dictionary-snapshots?q=&page=&pageSize=` | 管理员分页查看账户词库改动快照元数据 | | GET | `/api/v1/admin/personal-dictionary-snapshots/:userId` | 管理员只读查看并审计账户词库改动快照 | | PATCH | `/api/v1/admin/users/:userId/role` | 幂等变更账户角色并撤销其活动 refresh token | diff --git a/docs/personal-cloud-snapshot-api-v1.md b/docs/personal-cloud-snapshot-api-v1.md index 73bc3dd..c666cdc 100644 --- a/docs/personal-cloud-snapshot-api-v1.md +++ b/docs/personal-cloud-snapshot-api-v1.md @@ -116,6 +116,31 @@ the stored `snapshot` inside `personalSnapshot`. It returns `404 PERSONAL_SNAPSHOT_NOT_FOUND` before the first upload. The response includes the revision `ETag` and an attachment filename. +## Export a client database backup v7 + +`GET /api/v1/account/personal-snapshot/database-backup-v7` converts the current +account's record snapshot into the complete top-level JSON shape accepted by +the OpenLogTool client's local database import. The response body is the raw +backup rather than a `personalSnapshot` envelope, declares `version: 7`, and is +downloaded as `openlogtool-personal-r{recordRevision}-d{dictionaryRevision}-v7.json`. + +The export preserves Sessions, Logs, tombstones, complete timestamps, remarks, +and source device IDs. If the same account has a valid personal dictionary +snapshot, user additions and built-in deletion overrides are converted into v7 +`dictionary_items`; the client re-seeds current built-in entries after import +while respecting those tombstones. Settings, oplog rows, collaboration +bindings, shadows, outbox, applied events, conflicts, live-draft caches, and +offline records are emitted as empty arrays. Consequently, restored Sessions +are editable local data and never retain a server collaboration binding. + +The response includes `X-OpenLogTool-Backup-Format-Version`, +`X-Personal-Snapshot-Revision`, and +`X-Personal-Dictionary-Snapshot-Revision`. A missing record snapshot returns +`404 PERSONAL_SNAPSHOT_NOT_FOUND`; a corrupt record or dictionary snapshot +returns a 500 integrity error instead of producing a partially recoverable +file. Importing this file through the client replaces its current local +database, so the Web portal labels that consequence explicitly. + ## Atomic dangerous replacement `PUT /api/v1/account/personal-snapshot` replaces the entire account snapshot: @@ -186,7 +211,8 @@ account or snapshot returns `404 PERSONAL_SNAPSHOT_NOT_FOUND`; invalid stored JSON or metadata that no longer matches the validated content returns `500 PERSONAL_SNAPSHOT_CORRUPT`. -Both endpoints require a current server administrator access token and return +All administrator endpoints in this section require a current server +administrator access token and return `Cache-Control: no-store`. Because detail responses expose personal Log content, each detail visit is written to the append-only governance audit as `personal_snapshot.detail.viewed`. A UI may send one stable @@ -194,3 +220,9 @@ content, each detail visit is written to the append-only governance audit as administrator, target account, access ID, and 15-minute bucket produce one audit row. The audit stores the access ID only: it never copies snapshot content, checksums, titles, callsigns, or remarks into audit details. + +`GET /api/v1/admin/personal-snapshots/:userId/database-backup-v7` returns the +same client-compatible raw v7 backup for a selected account. It performs the +same integrity validation and records +`personal_snapshot.database_v7.exported` in the governance audit without +copying snapshot content into the audit row. diff --git a/src/api/admin-personal-snapshots-v1.ts b/src/api/admin-personal-snapshots-v1.ts index b82fcd6..bec9b37 100644 --- a/src/api/admin-personal-snapshots-v1.ts +++ b/src/api/admin-personal-snapshots-v1.ts @@ -8,6 +8,15 @@ import { getDb } from '../db/database'; import { AppError } from '../errors/app-error'; import { createAccessTokenMiddleware, V1AuthRequest } from '../middleware/auth-v1'; import { usernameIdentity } from '../auth/username-identity'; +import { + CLIENT_DATABASE_BACKUP_FORMAT_VERSION, + StoredPersonalDictionarySnapshotExportRow, + StoredPersonalSnapshotExportRow, + clientDatabaseBackupV7Filename, + createClientDatabaseBackupV7, + validatedStoredPersonalDictionarySnapshotForExport, + validatedStoredPersonalSnapshotForExport, +} from '../personal-snapshot/database-backup-v7'; import { PersonalSnapshot, validatePersonalSnapshot, @@ -280,5 +289,63 @@ export function createAdminPersonalSnapshotsV1Router( } }); + router.get( + '/personal-snapshots/:userId/database-backup-v7', + (req: V1AuthRequest, res, next) => { + try { + const userId = normalizeStableId(req.params.userId, 'userId'); + const db = database(); + const stored = db.transaction(() => ({ + records: db.prepare(` + SELECT + p.user_id, u.username, p.revision, p.format_version, + p.snapshot_json, p.session_count, p.log_count, p.byte_size, + p.checksum, p.created_at, p.updated_at + FROM personal_cloud_snapshots p + INNER JOIN users u ON u.id = p.user_id + WHERE p.user_id = ? + `).get(userId) as (AdminPersonalSnapshotRow & StoredPersonalSnapshotExportRow) | undefined, + dictionary: db.prepare(` + SELECT + revision, format_version, snapshot_json, + item_count, active_count, deleted_count, + byte_size, checksum + FROM personal_dictionary_snapshots + WHERE user_id = ? + `).get(userId) as StoredPersonalDictionarySnapshotExportRow | undefined, + }))(); + if (!stored.records) throw snapshotNotFound(); + const records = validatedStoredPersonalSnapshotForExport(stored.records); + const dictionary = stored.dictionary + ? validatedStoredPersonalDictionarySnapshotForExport(stored.dictionary) + : undefined; + const backup = createClientDatabaseBackupV7(records, dictionary); + const dictionaryRevision = Number(stored.dictionary?.revision ?? 0); + auditSensitiveUserRead( + db, + req, + userId, + 'personal_snapshot.database_v7.exported', + ); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${clientDatabaseBackupV7Filename( + Number(stored.records.revision), + dictionaryRevision, + )}"`, + ); + res.setHeader( + 'X-OpenLogTool-Backup-Format-Version', + String(CLIENT_DATABASE_BACKUP_FORMAT_VERSION), + ); + res.setHeader('X-Personal-Snapshot-Revision', String(stored.records.revision)); + res.setHeader('X-Personal-Dictionary-Snapshot-Revision', String(dictionaryRevision)); + res.json(backup); + } catch (error) { + next(error); + } + }, + ); + return router; } diff --git a/src/api/personal-snapshot-v1.ts b/src/api/personal-snapshot-v1.ts index fc16e47..5cc7b3b 100644 --- a/src/api/personal-snapshot-v1.ts +++ b/src/api/personal-snapshot-v1.ts @@ -5,6 +5,14 @@ import { getDb } from '../db/database'; import { AppError } from '../errors/app-error'; import { createAccessTokenMiddleware, V1AuthRequest } from '../middleware/auth-v1'; import { createMemoryRateLimiter } from '../middleware/rate-limit'; +import { + CLIENT_DATABASE_BACKUP_FORMAT_VERSION, + StoredPersonalDictionarySnapshotExportRow, + clientDatabaseBackupV7Filename, + createClientDatabaseBackupV7, + validatedStoredPersonalDictionarySnapshotForExport, + validatedStoredPersonalSnapshotForExport, +} from '../personal-snapshot/database-backup-v7'; import { PERSONAL_SNAPSHOT_FORMAT_VERSION, PERSONAL_SNAPSHOT_REPLACE_CONFIRMATION, @@ -288,6 +296,52 @@ export function createPersonalSnapshotV1Router( } }); + router.get('/personal-snapshot/database-backup-v7', (req: V1AuthRequest, res, next) => { + try { + const db = database(); + const stored = db.transaction(() => ({ + records: readSnapshotRow(db, req.auth!.userId), + dictionary: db.prepare(` + SELECT + revision, format_version, snapshot_json, + item_count, active_count, deleted_count, + byte_size, checksum + FROM personal_dictionary_snapshots + WHERE user_id = ? + `).get(req.auth!.userId) as StoredPersonalDictionarySnapshotExportRow | undefined, + }))(); + if (!stored.records) { + throw new AppError( + 404, + 'PERSONAL_SNAPSHOT_NOT_FOUND', + 'No personal cloud snapshot has been uploaded for this account', + ); + } + const records = validatedStoredPersonalSnapshotForExport(stored.records); + const dictionary = stored.dictionary + ? validatedStoredPersonalDictionarySnapshotForExport(stored.dictionary) + : undefined; + const backup = createClientDatabaseBackupV7(records, dictionary); + const dictionaryRevision = Number(stored.dictionary?.revision ?? 0); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${clientDatabaseBackupV7Filename( + Number(stored.records.revision), + dictionaryRevision, + )}"`, + ); + res.setHeader( + 'X-OpenLogTool-Backup-Format-Version', + String(CLIENT_DATABASE_BACKUP_FORMAT_VERSION), + ); + res.setHeader('X-Personal-Snapshot-Revision', String(stored.records.revision)); + res.setHeader('X-Personal-Dictionary-Snapshot-Revision', String(dictionaryRevision)); + res.json(backup); + } catch (error) { + next(error); + } + }); + router.put( '/personal-snapshot', ...(runtimeConfig.rateLimitEnabled ? [replaceLimiter] : []), diff --git a/src/personal-snapshot/database-backup-v7.ts b/src/personal-snapshot/database-backup-v7.ts new file mode 100644 index 0000000..ceb9248 --- /dev/null +++ b/src/personal-snapshot/database-backup-v7.ts @@ -0,0 +1,193 @@ +import { createHash } from 'crypto'; +import { AppError } from '../errors/app-error'; +import { + PersonalDictionarySnapshot, + validatePersonalDictionarySnapshot, +} from '../personal-dictionary-snapshot/model'; +import { + PersonalSnapshot, + validatePersonalSnapshot, +} from './model'; + +export const CLIENT_DATABASE_BACKUP_FORMAT_VERSION = 7; + +export interface StoredPersonalSnapshotExportRow { + revision: number; + format_version: number; + snapshot_json: string; + session_count: number; + log_count: number; + byte_size: number; + checksum: string; +} + +export interface StoredPersonalDictionarySnapshotExportRow { + revision: number; + format_version: number; + snapshot_json: string; + item_count: number; + active_count: number; + deleted_count: number; + byte_size: number; + checksum: string; +} + +export interface ClientDatabaseBackupV7 { + version: 7; + exportedAt: string; + logs: PersonalSnapshot['logs']; + sessions: PersonalSnapshot['sessions']; + dictionary_items: Array<{ + dict_type: string; + raw: string; + pinyin: string | null; + abbreviation: string | null; + sync_id: string; + created_at: string; + updated_at: string; + deleted_at: string | null; + origin: 'user' | 'builtin'; + }>; + settings: []; + oplog: []; + collaboration_bindings: []; + entity_shadows: []; + sync_outbox: []; + applied_events: []; + sync_conflicts: []; + collaboration_live_drafts: []; + collaboration_offline_records: []; +} + +function corruptSnapshot(code: string, message: string, cause?: unknown): AppError { + return new AppError( + 500, + code, + message, + undefined, + cause === undefined ? undefined : { cause }, + ); +} + +export function validatedStoredPersonalSnapshotForExport( + row: StoredPersonalSnapshotExportRow, +): PersonalSnapshot { + try { + const validated = validatePersonalSnapshot(JSON.parse(row.snapshot_json) as unknown); + if ( + validated.snapshot.version !== Number(row.format_version) || + validated.sessionCount !== Number(row.session_count) || + validated.logCount !== Number(row.log_count) || + validated.byteSize !== Number(row.byte_size) || + validated.checksum !== row.checksum + ) { + throw corruptSnapshot( + 'PERSONAL_SNAPSHOT_CORRUPT', + 'The stored personal cloud snapshot failed integrity validation', + ); + } + return validated.snapshot; + } catch (error) { + if (error instanceof AppError && error.code === 'PERSONAL_SNAPSHOT_CORRUPT') { + throw error; + } + throw corruptSnapshot( + 'PERSONAL_SNAPSHOT_CORRUPT', + 'The stored personal cloud snapshot failed integrity validation', + error, + ); + } +} + +export function validatedStoredPersonalDictionarySnapshotForExport( + row: StoredPersonalDictionarySnapshotExportRow, +): PersonalDictionarySnapshot { + try { + const validated = validatePersonalDictionarySnapshot( + JSON.parse(row.snapshot_json) as unknown, + ); + if ( + validated.snapshot.version !== Number(row.format_version) || + validated.itemCount !== Number(row.item_count) || + validated.activeCount !== Number(row.active_count) || + validated.deletedCount !== Number(row.deleted_count) || + validated.byteSize !== Number(row.byte_size) || + validated.checksum !== row.checksum + ) { + throw corruptSnapshot( + 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT', + 'The stored personal dictionary snapshot failed integrity validation', + ); + } + return validated.snapshot; + } catch (error) { + if ( + error instanceof AppError && + error.code === 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT' + ) { + throw error; + } + throw corruptSnapshot( + 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT', + 'The stored personal dictionary snapshot failed integrity validation', + error, + ); + } +} + +function clientDictionaryType(value: string): string { + return `${value}_dictionary`; +} + +function dictionarySyncId(dictType: string, raw: string): string { + const digest = createHash('sha256') + .update(`openlogtool/personal-cloud-dictionary/v1\0${dictType}\0${raw}`) + .digest('hex'); + return `dict-cloud-${digest.slice(0, 32)}`; +} + +export function createClientDatabaseBackupV7( + records: PersonalSnapshot, + dictionary: PersonalDictionarySnapshot | undefined, + exportedAt = new Date().toISOString(), +): ClientDatabaseBackupV7 { + const dictionaryItems = (dictionary?.items ?? []).map((item) => { + const dictType = clientDictionaryType(item.dictType); + const timestamp = dictionary?.exportedAt ?? exportedAt; + return { + dict_type: dictType, + raw: item.raw, + pinyin: item.pinyin, + abbreviation: item.abbreviation, + sync_id: dictionarySyncId(dictType, item.raw), + created_at: timestamp, + updated_at: timestamp, + deleted_at: item.state === 'deleted' ? timestamp : null, + origin: item.origin, + }; + }); + + return { + version: CLIENT_DATABASE_BACKUP_FORMAT_VERSION, + exportedAt, + logs: records.logs, + sessions: records.sessions, + dictionary_items: dictionaryItems, + settings: [], + oplog: [], + collaboration_bindings: [], + entity_shadows: [], + sync_outbox: [], + applied_events: [], + sync_conflicts: [], + collaboration_live_drafts: [], + collaboration_offline_records: [], + }; +} + +export function clientDatabaseBackupV7Filename( + recordRevision: number, + dictionaryRevision: number, +): string { + return `openlogtool-personal-r${recordRevision}-d${dictionaryRevision}-v7.json`; +} diff --git a/test/admin-personal-snapshots-v1.test.ts b/test/admin-personal-snapshots-v1.test.ts index 3ab6246..caa24be 100644 --- a/test/admin-personal-snapshots-v1.test.ts +++ b/test/admin-personal-snapshots-v1.test.ts @@ -11,6 +11,7 @@ import jwt from 'jsonwebtoken'; import { createApp } from '../src/app'; import type { AppConfig } from '../src/config'; import { openDatabase } from '../src/db/database'; +import { validatePersonalDictionarySnapshot } from '../src/personal-dictionary-snapshot/model'; import { type PersonalSnapshot, validatePersonalSnapshot, @@ -188,6 +189,39 @@ describe('administrator personal cloud snapshot read API', { concurrency: false ); } + const dictionary = validatePersonalDictionarySnapshot({ + version: 1, + exportedAt: '2026-07-03T01:02:03.456Z', + items: [ + { + dictType: 'qth', + raw: 'Cloud QTH', + origin: 'user', + state: 'active', + pinyin: 'cloud qth', + abbreviation: 'CQ', + }, + ], + }); + db.prepare(` + INSERT INTO personal_dictionary_snapshots ( + user_id, revision, format_version, snapshot_json, + item_count, active_count, deleted_count, + byte_size, checksum, created_at, updated_at + ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + 'user-a', + dictionary.snapshot.version, + dictionary.serialized, + dictionary.itemCount, + dictionary.activeCount, + dictionary.deletedCount, + dictionary.byteSize, + dictionary.checksum, + '2026-07-03T01:02:03.456Z', + '2026-07-03T01:02:03.456Z', + ); + const app = createApp({ db, config }); server = createServer(app); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); @@ -214,6 +248,13 @@ describe('administrator personal cloud snapshot read API', { concurrency: false 403, 'ADMIN_REQUIRED', ); + assertError( + await request('/api/v1/admin/personal-snapshots/user-a/database-backup-v7', { + token: accessToken('member', 'user'), + }), + 403, + 'ADMIN_REQUIRED', + ); const result = await request('/api/v1/admin/personal-snapshots', { token: accessToken('admin-root', 'admin'), @@ -387,6 +428,91 @@ describe('administrator personal cloud snapshot read API', { concurrency: false `).pluck().get()), 2); }); + test('exports an audited client-compatible v7 database backup', async () => { + const accessId = `personal-v7-export-${randomUUID()}`; + const result = await request( + '/api/v1/admin/personal-snapshots/user-a/database-backup-v7', + { + token: accessToken('admin-root', 'admin'), + headers: { 'x-admin-access-id': accessId }, + }, + ); + assert.equal(result.status, 200, result.text); + assert.equal(result.headers.get('cache-control'), 'no-store'); + assert.equal(result.headers.get('x-openlogtool-backup-format-version'), '7'); + assert.equal(result.headers.get('x-personal-snapshot-revision'), '1'); + assert.equal(result.headers.get('x-personal-dictionary-snapshot-revision'), '1'); + assert.match( + result.headers.get('content-disposition') ?? '', + /openlogtool-personal-r1-d1-v7\.json/, + ); + exactKeys(result.body, [ + 'version', + 'exportedAt', + 'logs', + 'sessions', + 'dictionary_items', + 'settings', + 'oplog', + 'collaboration_bindings', + 'entity_shadows', + 'sync_outbox', + 'applied_events', + 'sync_conflicts', + 'collaboration_live_drafts', + 'collaboration_offline_records', + ]); + assert.equal(result.body.version, 7); + assert.deepEqual(result.body.sessions, snapshotFixture('a').sessions); + assert.deepEqual(result.body.logs, snapshotFixture('a').logs); + assert.ok(Array.isArray(result.body.dictionary_items)); + assert.equal(result.body.dictionary_items.length, 1); + assert.deepEqual(result.body.dictionary_items[0], { + dict_type: 'qth_dictionary', + raw: 'Cloud QTH', + pinyin: 'cloud qth', + abbreviation: 'CQ', + sync_id: result.body.dictionary_items[0].sync_id, + created_at: '2026-07-03T01:02:03.456Z', + updated_at: '2026-07-03T01:02:03.456Z', + deleted_at: null, + origin: 'user', + }); + assert.match( + result.body.dictionary_items[0].sync_id, + /^dict-cloud-[0-9a-f]{32}$/, + ); + for (const table of [ + 'settings', + 'oplog', + 'collaboration_bindings', + 'entity_shadows', + 'sync_outbox', + 'applied_events', + 'sync_conflicts', + 'collaboration_live_drafts', + 'collaboration_offline_records', + ]) { + assert.deepEqual(result.body[table], [], `${table} must be empty`); + } + + const audits = db.prepare(` + SELECT action, actor_user_id, target_type, target_id, details_json + FROM admin_governance_audit_events + WHERE action = 'personal_snapshot.database_v7.exported' + AND target_id = 'user-a' + `).all() as Array>; + assert.deepEqual(audits, [{ + action: 'personal_snapshot.database_v7.exported', + actor_user_id: 'admin-root', + target_type: 'user', + target_id: 'user-a', + details_json: JSON.stringify({ accessId }), + }]); + assert.equal(JSON.stringify(audits).includes('BG5A'), false); + assert.equal(JSON.stringify(audits).includes('private personal note'), false); + }); + test('reports missing and corrupt snapshots without creating a read audit', async () => { const token = accessToken('admin-root', 'admin'); assertError( diff --git a/test/personal-snapshot-v1.test.ts b/test/personal-snapshot-v1.test.ts index dade2c5..cd53cc5 100644 --- a/test/personal-snapshot-v1.test.ts +++ b/test/personal-snapshot-v1.test.ts @@ -336,6 +336,13 @@ describe('account personal cloud snapshot v1', () => { 404, 'PERSONAL_SNAPSHOT_NOT_FOUND', ); + assertError( + await request('/api/v1/account/personal-snapshot/database-backup-v7', { + token: ownerToken, + }), + 404, + 'PERSONAL_SNAPSHOT_NOT_FOUND', + ); }); test('requires both optimistic concurrency and an explicit dangerous confirmation', async () => { @@ -389,6 +396,138 @@ describe('account personal cloud snapshot v1', () => { /openlogtool-personal-snapshot-r1\.json/, ); assert.deepEqual(download.body.personalSnapshot.snapshot, snapshot); + + const databaseBackup = await request( + '/api/v1/account/personal-snapshot/database-backup-v7', + { token: ownerToken }, + ); + assert.equal(databaseBackup.status, 200); + assert.match( + databaseBackup.headers.get('content-disposition') ?? '', + /openlogtool-personal-r1-d0-v7\.json/, + ); + assert.equal(databaseBackup.headers.get('x-openlogtool-backup-format-version'), '7'); + assert.equal(databaseBackup.headers.get('x-personal-snapshot-revision'), '1'); + assert.equal(databaseBackup.headers.get('x-personal-dictionary-snapshot-revision'), '0'); + assert.equal(databaseBackup.body.version, 7); + assert.ok(Number.isFinite(Date.parse(databaseBackup.body.exportedAt))); + assert.deepEqual(databaseBackup.body.sessions, snapshot.sessions); + assert.deepEqual(databaseBackup.body.logs, snapshot.logs); + for (const table of [ + 'dictionary_items', + 'settings', + 'oplog', + 'collaboration_bindings', + 'entity_shadows', + 'sync_outbox', + 'applied_events', + 'sync_conflicts', + 'collaboration_live_drafts', + 'collaboration_offline_records', + ]) { + assert.deepEqual(databaseBackup.body[table], [], `${table} must be an empty v7 table`); + } + }); + + test('converts personal dictionary changes into client v7 database rows', async () => { + const dictionary = { + version: 1, + exportedAt: '2026-07-18T12:34:56.789+08:00', + items: [ + { + dictType: 'callsign', + raw: 'BG5CLOUD', + origin: 'user', + state: 'active', + pinyin: null, + abbreviation: 'BC', + }, + { + dictType: 'antenna', + raw: 'Legacy antenna', + origin: 'builtin', + state: 'deleted', + pinyin: null, + abbreviation: null, + }, + ], + }; + const uploaded = await request('/api/v1/account/personal-dictionary-snapshot', { + method: 'PUT', + token: ownerToken, + body: { + expectedRevision: 0, + confirmation: 'REPLACE_PERSONAL_DICTIONARY_SNAPSHOT', + snapshot: dictionary, + }, + }); + assert.equal(uploaded.status, 200); + + const exported = await request( + '/api/v1/account/personal-snapshot/database-backup-v7', + { token: ownerToken }, + ); + assert.equal(exported.status, 200); + assert.match( + exported.headers.get('content-disposition') ?? '', + /openlogtool-personal-r1-d1-v7\.json/, + ); + assert.equal(exported.headers.get('x-personal-dictionary-snapshot-revision'), '1'); + assert.equal(exported.body.dictionary_items.length, 2); + assert.deepEqual( + exported.body.dictionary_items.map((item: any) => ({ + dict_type: item.dict_type, + raw: item.raw, + pinyin: item.pinyin, + abbreviation: item.abbreviation, + deleted_at: item.deleted_at, + origin: item.origin, + })), + [ + { + dict_type: 'callsign_dictionary', + raw: 'BG5CLOUD', + pinyin: null, + abbreviation: 'BC', + deleted_at: null, + origin: 'user', + }, + { + dict_type: 'antenna_dictionary', + raw: 'Legacy antenna', + pinyin: null, + abbreviation: null, + deleted_at: dictionary.exportedAt, + origin: 'builtin', + }, + ], + ); + for (const item of exported.body.dictionary_items) { + assert.match(item.sync_id, /^dict-cloud-[0-9a-f]{32}$/); + assert.equal(item.created_at, dictionary.exportedAt); + assert.equal(item.updated_at, dictionary.exportedAt); + assert.equal('id' in item, false, 'the client must allocate local row IDs'); + } + assert.deepEqual(exported.body.collaboration_bindings, []); + assert.deepEqual(exported.body.sync_outbox, []); + + db.prepare(` + UPDATE personal_dictionary_snapshots + SET byte_size = byte_size + 1 + WHERE user_id = ? + `).run(OWNER_ID); + assertError( + await request('/api/v1/account/personal-snapshot/database-backup-v7', { + token: ownerToken, + }), + 500, + 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT', + ); + db.prepare(` + UPDATE personal_dictionary_snapshots + SET byte_size = byte_size - 1 + WHERE user_id = ? + `).run(OWNER_ID); }); test('isolates snapshots by account and rejects stale destructive replacement', async () => { diff --git a/web/src/api.ts b/web/src/api.ts index 1e9025a..abff32a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -243,6 +243,10 @@ export const accountApi = { revokeDevice: (id: string) => unwrap(api.delete(`/account/devices/${encodeURIComponent(id)}`)), personalSnapshot: () => unwrap(api.get<{ personalSnapshot: PersonalSnapshotMetadata }>('/account/personal-snapshot')), downloadPersonalSnapshot: () => unwrap(api.get<{ personalSnapshot: PersonalSnapshotDownload }>('/account/personal-snapshot/download')), + exportPersonalSnapshotDatabaseV7: () => downloadGetFile( + '/account/personal-snapshot/database-backup-v7', + 'openlogtool-personal-v7.json', + ), personalDictionarySnapshot: () => unwrap(api.get<{ personalDictionarySnapshot: PersonalDictionarySnapshotMetadata }>('/account/personal-dictionary-snapshot')), downloadPersonalDictionarySnapshot: () => unwrap(api.get<{ personalDictionarySnapshot: PersonalDictionarySnapshotDownload }>('/account/personal-dictionary-snapshot/download')), }; @@ -453,19 +457,51 @@ export type OperationalSettingsUpdate = Omit; async function downloadAdminFile(url: string, body: Record, fallbackName: string) { try { const response = await api.post(url, body, { responseType: 'blob', timeout: 0 }); - const disposition = response.headers['content-disposition'] as string | undefined; - const match = disposition?.match(/filename="?([^";]+)"?/i); - const objectUrl = URL.createObjectURL(response.data); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = match?.[1] ?? fallbackName; - anchor.hidden = true; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000); + saveDownloadedBlob( + response.data, + response.headers['content-disposition'] as string | undefined, + fallbackName, + ); } catch (error) { - throw normalizeError(error); + throw await normalizeResponseError(error); + } +} + +function saveDownloadedBlob( + blob: Blob, + disposition: string | undefined, + fallbackName: string, +) { + const match = disposition?.match(/filename="?([^";]+)"?/i); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = match?.[1] ?? fallbackName; + anchor.hidden = true; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000); +} + +async function downloadGetFile( + url: string, + fallbackName: string, + headers?: Record, +) { + try { + const response = await api.get(url, { + responseType: 'blob', + timeout: 0, + ...(headers ? { headers } : {}), + }); + saveDownloadedBlob( + response.data, + response.headers['content-disposition'] as string | undefined, + fallbackName, + ); + } catch (error) { + throw await normalizeResponseError(error); } } @@ -503,6 +539,11 @@ export const adminApi = { unwrap(api.get(`/admin/personal-snapshots/${encodeURIComponent(userId)}`, { headers: { 'X-Admin-Access-Id': accessId }, })), + exportPersonalSnapshotDatabaseV7: (userId: string) => downloadGetFile( + `/admin/personal-snapshots/${encodeURIComponent(userId)}/database-backup-v7`, + 'openlogtool-personal-v7.json', + { 'X-Admin-Access-Id': crypto.randomUUID() }, + ), personalDictionarySnapshots: (params: { page: number; pageSize: number; q?: string }) => unwrap(api.get>('/admin/personal-dictionary-snapshots', { params })), personalDictionarySnapshot: (userId: string, accessId: string) => diff --git a/web/src/components/PersonalSnapshotViewer.tsx b/web/src/components/PersonalSnapshotViewer.tsx index 0e6fb06..cdd0f7b 100644 --- a/web/src/components/PersonalSnapshotViewer.tsx +++ b/web/src/components/PersonalSnapshotViewer.tsx @@ -1,5 +1,5 @@ -import { EyeOutlined, SearchOutlined } from '@ant-design/icons'; -import { Alert, Button, Card, Descriptions, Empty, Input, Space, Statistic, Table, Tag, Typography } from 'antd'; +import { DownloadOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons'; +import { Alert, App, Button, Card, Descriptions, Empty, Input, Space, Statistic, Table, Tag, Typography } from 'antd'; import { useEffect, useMemo, useState } from 'react'; import type { PersonalSnapshotDownload, PersonalSnapshotLog, PersonalSnapshotOwner, PersonalSnapshotSession } from '../types'; import { useI18n } from '../useI18n'; @@ -20,13 +20,16 @@ function valueOrDash(value: string | null): string { return value || '—'; } -export function PersonalSnapshotViewer({ owner, personalSnapshot, admin }: { +export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExportDatabaseV7 }: { owner: PersonalSnapshotOwner; personalSnapshot: PersonalSnapshotDownload; admin: boolean; + onExportDatabaseV7: () => Promise; }) { const { t, locale } = useI18n(); + const { message } = App.useApp(); const { snapshot } = personalSnapshot; + const [exporting, setExporting] = useState(false); const [sessionQuery, setSessionQuery] = useState(''); const [logQuery, setLogQuery] = useState(''); const [selectedSessionId, setSelectedSessionId] = useState(() => snapshot.sessions[0]?.session_id ?? null); @@ -43,6 +46,19 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin }: { return counts; }, [snapshot.logs]); const selectedSession = snapshot.sessions.find((session) => session.session_id === selectedSessionId) ?? null; + const exportDatabaseV7 = async () => { + setExporting(true); + try { + await onExportDatabaseV7(); + message.success(t('personalCloud.exportDatabaseV7Succeeded')); + } catch (error) { + message.error(t('personalCloud.exportDatabaseV7Failed', { + message: error instanceof Error ? error.message : String(error), + })); + } finally { + setExporting(false); + } + }; return <> - + } + loading={exporting} + onClick={() => void exportDatabaseV7()} + >{t('personalCloud.exportDatabaseV7')}} + style={{ marginBottom: 18 }} + > + + {t('personalCloud.exportDatabaseV7Hint')} + {owner.username}
{owner.id} }, { key: 'format', label: t('personalCloud.formatVersion'), children: personalSnapshot.formatVersion }, diff --git a/web/src/i18n.ts b/web/src/i18n.ts index 2def6a9..cce3fb4 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -90,6 +90,10 @@ const zh = { 'personalCloud.exportedAt': '客户端导出时间', 'personalCloud.createdAt': '首次上传时间', 'personalCloud.updatedAt': '服务器更新时间', + 'personalCloud.exportDatabaseV7': '导出客户端 v7 JSON', + 'personalCloud.exportDatabaseV7Hint': '生成可由 OpenLogTool 客户端“导入本地数据库”恢复的 v7 JSON。记录和个人词库改动会保留,设置及协作绑定不会导出;客户端导入会替换当前本地数据库。', + 'personalCloud.exportDatabaseV7Succeeded': '客户端 v7 JSON 已开始下载', + 'personalCloud.exportDatabaseV7Failed': '导出失败:{message}', 'personalCloud.snapshotSessions': '快照会话', 'personalCloud.searchSessions': '搜索会话名或 ID', 'personalCloud.noMatchingSessions': '没有匹配的会话', @@ -413,6 +417,10 @@ const en: Record = { 'personalCloud.size': 'Size', 'personalCloud.snapshotMetadata': 'Snapshot metadata', 'personalCloud.formatVersion': 'Format version', 'personalCloud.exportedAt': 'Client export time', 'personalCloud.createdAt': 'First uploaded', 'personalCloud.updatedAt': 'Server updated', + 'personalCloud.exportDatabaseV7': 'Export client v7 JSON', + 'personalCloud.exportDatabaseV7Hint': 'Creates a v7 JSON file accepted by OpenLogTool’s local database import. Records and personal dictionary changes are retained; settings and collaboration bindings are omitted. Importing it replaces the client’s current local database.', + 'personalCloud.exportDatabaseV7Succeeded': 'The client v7 JSON download has started', + 'personalCloud.exportDatabaseV7Failed': 'Export failed: {message}', 'personalCloud.snapshotSessions': 'Snapshot sessions', 'personalCloud.searchSessions': 'Search name or ID', 'personalCloud.noMatchingSessions': 'No matching sessions', 'personalCloud.searchLogs': 'Search record content', 'personalCloud.logDetails': 'Record details', 'personalCloud.selectSession': 'Select a session to inspect its records', diff --git a/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx b/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx index 8563da8..02d3974 100644 --- a/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx +++ b/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx @@ -60,7 +60,12 @@ export default function AdminPersonalSnapshotDetailPage() { ]} /> {dataset === 'records' ? - {recordsState.data && } + {recordsState.data && adminApi.exportPersonalSnapshotDatabaseV7(userId)} + />} : {dictionariesState.data && } } diff --git a/web/src/pages/app/PersonalCloudPage.tsx b/web/src/pages/app/PersonalCloudPage.tsx index 5f2fbba..1ef77f6 100644 --- a/web/src/pages/app/PersonalCloudPage.tsx +++ b/web/src/pages/app/PersonalCloudPage.tsx @@ -46,7 +46,12 @@ export default function PersonalCloudPage() { key: 'records', label: {t('personalCloud.recordsTab')}, children: state.data.recordsDownload - ? + ? : <>
, }, { From 73f65e1cb2b53293ab22eabc90f6269f45d2eb56 Mon Sep 17 00:00:00 2001 From: Mazha0309 Date: Wed, 22 Jul 2026 20:14:11 +0800 Subject: [PATCH 3/6] fix: export personal backups by session --- README.md | 4 +- docs/personal-cloud-snapshot-api-v1.md | 59 +++++---- src/api/admin-governance-v1.ts | 15 ++- src/api/admin-personal-snapshots-v1.ts | 73 ++++++----- src/api/personal-snapshot-v1.ts | 90 +++++++------- src/personal-snapshot/database-backup-v7.ts | 116 +++--------------- test/admin-personal-snapshots-v1.test.ts | 86 +++++++++---- test/personal-snapshot-v1.test.ts | 105 +++++++++------- web/src/api.ts | 12 +- web/src/components/PersonalSnapshotViewer.tsx | 33 ++--- web/src/i18n.ts | 12 +- .../admin/AdminPersonalSnapshotDetailPage.tsx | 3 +- web/src/pages/app/PersonalCloudPage.tsx | 2 +- 13 files changed, 289 insertions(+), 321 deletions(-) diff --git a/README.md b/README.md index 67782b5..50509b7 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ | GET/DELETE | `/api/v1/account/devices...` | 查看并撤销自己的设备会话 | | GET/PUT | `/api/v1/account/personal-snapshot` | 读取元数据或按 revision 原子替换个人记录云快照 | | GET | `/api/v1/account/personal-snapshot/download` | 下载个人记录云快照;与协作 Session 完全分离 | -| GET | `/api/v1/account/personal-snapshot/database-backup-v7` | 将自己的记录快照及可用词库改动导出为客户端可恢复的数据库备份 v7 JSON | +| GET | `/api/v1/account/personal-snapshot/sessions/:sessionId/database-backup-v7` | 将自己的单个快照会话及其记录导出为客户端可恢复的数据库备份 v7 JSON | | GET/PUT | `/api/v1/account/personal-dictionary-snapshot` | 读取元数据或按 revision 原子替换词库用户改动快照 | | GET | `/api/v1/account/personal-dictionary-snapshot/download` | 下载用户词条及默认词条删除覆盖;不传输完整内置词库 | | GET | `/api/v1/admin/overview` | 管理员读取服务器与用户、Session 的非识别聚合概览 | @@ -162,7 +162,7 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ | GET | `/api/v1/admin/users?q=&role=&page=&pageSize=` | 管理员分页搜索账户 | | GET | `/api/v1/admin/personal-snapshots?q=&page=&pageSize=` | 管理员分页查看各账户个人云快照元数据;不属于协作 Session | | GET | `/api/v1/admin/personal-snapshots/:userId` | 管理员只读查看并审计某账户的完整个人云快照 | -| GET | `/api/v1/admin/personal-snapshots/:userId/database-backup-v7` | 管理员审计后导出指定账户的客户端数据库备份 v7 JSON | +| GET | `/api/v1/admin/personal-snapshots/:userId/sessions/:sessionId/database-backup-v7` | 管理员审计后导出指定账户的单个快照会话 v7 JSON | | GET | `/api/v1/admin/personal-dictionary-snapshots?q=&page=&pageSize=` | 管理员分页查看账户词库改动快照元数据 | | GET | `/api/v1/admin/personal-dictionary-snapshots/:userId` | 管理员只读查看并审计账户词库改动快照 | | PATCH | `/api/v1/admin/users/:userId/role` | 幂等变更账户角色并撤销其活动 refresh token | diff --git a/docs/personal-cloud-snapshot-api-v1.md b/docs/personal-cloud-snapshot-api-v1.md index c666cdc..33a6e9b 100644 --- a/docs/personal-cloud-snapshot-api-v1.md +++ b/docs/personal-cloud-snapshot-api-v1.md @@ -116,30 +116,33 @@ the stored `snapshot` inside `personalSnapshot`. It returns `404 PERSONAL_SNAPSHOT_NOT_FOUND` before the first upload. The response includes the revision `ETag` and an attachment filename. -## Export a client database backup v7 - -`GET /api/v1/account/personal-snapshot/database-backup-v7` converts the current -account's record snapshot into the complete top-level JSON shape accepted by -the OpenLogTool client's local database import. The response body is the raw -backup rather than a `personalSnapshot` envelope, declares `version: 7`, and is -downloaded as `openlogtool-personal-r{recordRevision}-d{dictionaryRevision}-v7.json`. - -The export preserves Sessions, Logs, tombstones, complete timestamps, remarks, -and source device IDs. If the same account has a valid personal dictionary -snapshot, user additions and built-in deletion overrides are converted into v7 -`dictionary_items`; the client re-seeds current built-in entries after import -while respecting those tombstones. Settings, oplog rows, collaboration -bindings, shadows, outbox, applied events, conflicts, live-draft caches, and -offline records are emitted as empty arrays. Consequently, restored Sessions -are editable local data and never retain a server collaboration binding. +## Export one Session as a client database backup v7 + +`GET /api/v1/account/personal-snapshot/sessions/:sessionId/database-backup-v7` +converts exactly one Session from the current account's record snapshot into +the complete top-level JSON shape accepted by the OpenLogTool client's local +database import. The response body is the raw backup rather than a +`personalSnapshot` envelope, declares `version: 7`, and is downloaded as +`openlogtool-session-{sessionId}-r{recordRevision}-v7.json`. + +The export contains one `sessions` row and only the `logs` rows whose +`session_id` matches it. It preserves tombstones, complete timestamps, remarks, +and source device IDs. Other Sessions and the account-wide personal dictionary +snapshot are deliberately excluded. `dictionary_items`, settings, oplog rows, +collaboration bindings, shadows, outbox, applied events, conflicts, live-draft +caches, and offline records are emitted as empty arrays. Consequently, the +restored Session is editable local data and never retains a server +collaboration binding. The response includes `X-OpenLogTool-Backup-Format-Version`, -`X-Personal-Snapshot-Revision`, and -`X-Personal-Dictionary-Snapshot-Revision`. A missing record snapshot returns -`404 PERSONAL_SNAPSHOT_NOT_FOUND`; a corrupt record or dictionary snapshot -returns a 500 integrity error instead of producing a partially recoverable -file. Importing this file through the client replaces its current local -database, so the Web portal labels that consequence explicitly. +`X-Personal-Snapshot-Revision`, and `X-Personal-Snapshot-Session-Id`. A missing +record snapshot returns `404 PERSONAL_SNAPSHOT_NOT_FOUND`; a Session absent from +that snapshot returns `404 PERSONAL_SNAPSHOT_SESSION_NOT_FOUND`; corrupt record +data returns a 500 integrity error. The former account-wide route +`GET /api/v1/account/personal-snapshot/database-backup-v7` returns +`422 PERSONAL_SNAPSHOT_SESSION_REQUIRED` and never emits a combined file. +Importing a v7 file through the client replaces its current local database, so +the Web portal labels that consequence explicitly. ## Atomic dangerous replacement @@ -221,8 +224,10 @@ administrator, target account, access ID, and 15-minute bucket produce one audit row. The audit stores the access ID only: it never copies snapshot content, checksums, titles, callsigns, or remarks into audit details. -`GET /api/v1/admin/personal-snapshots/:userId/database-backup-v7` returns the -same client-compatible raw v7 backup for a selected account. It performs the -same integrity validation and records -`personal_snapshot.database_v7.exported` in the governance audit without -copying snapshot content into the audit row. +`GET /api/v1/admin/personal-snapshots/:userId/sessions/:sessionId/database-backup-v7` +returns the same client-compatible raw v7 backup for one selected Session in an +account snapshot. It performs the same integrity validation and records +`personal_snapshot.session_database_v7.exported` in the governance audit with +the personal snapshot Session ID, without copying Session titles, Log content, +checksums, callsigns, or remarks into the audit row. The former account-wide +administrator route returns `422 PERSONAL_SNAPSHOT_SESSION_REQUIRED`. diff --git a/src/api/admin-governance-v1.ts b/src/api/admin-governance-v1.ts index a093e62..4dc1ec0 100644 --- a/src/api/admin-governance-v1.ts +++ b/src/api/admin-governance-v1.ts @@ -383,9 +383,11 @@ export function auditSensitiveUserRead( req: V1AuthRequest, userId: string, action = 'user.detail.viewed', + additionalDetails: Record = {}, ): void { const supplied = req.header('x-admin-access-id'); const accessId = supplied ? normalizeStableId(supplied, 'X-Admin-Access-Id') : randomUUID(); + const details = { accessId, ...additionalDetails }; const bucketMs = 15 * 60_000; const bucketStart = new Date(Math.floor(Date.now() / bucketMs) * bucketMs).toISOString(); const bucketEnd = new Date(Date.parse(bucketStart) + bucketMs).toISOString(); @@ -395,9 +397,16 @@ export function auditSensitiveUserRead( WHERE action = ? AND actor_user_id = ? AND target_type = 'user' AND target_id = ? AND session_id IS NULL AND occurred_at >= ? AND occurred_at < ? - AND json_extract(details_json, '$.accessId') = ? + AND details_json = ? LIMIT 1 - `).get(action, req.auth!.userId, userId, bucketStart, bucketEnd, accessId)) return; + `).get( + action, + req.auth!.userId, + userId, + bucketStart, + bucketEnd, + JSON.stringify(details), + )) return; appendGovernanceAudit(db, { action, actorUserId: req.auth!.userId, @@ -405,7 +414,7 @@ export function auditSensitiveUserRead( mutationId: `read:${randomUUID()}`, targetType: 'user', targetId: userId, - details: { accessId }, + details, }); } diff --git a/src/api/admin-personal-snapshots-v1.ts b/src/api/admin-personal-snapshots-v1.ts index bec9b37..b4092fc 100644 --- a/src/api/admin-personal-snapshots-v1.ts +++ b/src/api/admin-personal-snapshots-v1.ts @@ -10,11 +10,9 @@ import { createAccessTokenMiddleware, V1AuthRequest } from '../middleware/auth-v import { usernameIdentity } from '../auth/username-identity'; import { CLIENT_DATABASE_BACKUP_FORMAT_VERSION, - StoredPersonalDictionarySnapshotExportRow, StoredPersonalSnapshotExportRow, - clientDatabaseBackupV7Filename, - createClientDatabaseBackupV7, - validatedStoredPersonalDictionarySnapshotForExport, + clientSessionDatabaseBackupV7Filename, + createClientSessionDatabaseBackupV7, validatedStoredPersonalSnapshotForExport, } from '../personal-snapshot/database-backup-v7'; import { @@ -290,56 +288,44 @@ export function createAdminPersonalSnapshotsV1Router( }); router.get( - '/personal-snapshots/:userId/database-backup-v7', + '/personal-snapshots/:userId/sessions/:sessionId/database-backup-v7', (req: V1AuthRequest, res, next) => { try { const userId = normalizeStableId(req.params.userId, 'userId'); + const sessionId = normalizeStableId(req.params.sessionId, 'sessionId'); const db = database(); - const stored = db.transaction(() => ({ - records: db.prepare(` - SELECT - p.user_id, u.username, p.revision, p.format_version, - p.snapshot_json, p.session_count, p.log_count, p.byte_size, - p.checksum, p.created_at, p.updated_at - FROM personal_cloud_snapshots p - INNER JOIN users u ON u.id = p.user_id - WHERE p.user_id = ? - `).get(userId) as (AdminPersonalSnapshotRow & StoredPersonalSnapshotExportRow) | undefined, - dictionary: db.prepare(` - SELECT - revision, format_version, snapshot_json, - item_count, active_count, deleted_count, - byte_size, checksum - FROM personal_dictionary_snapshots - WHERE user_id = ? - `).get(userId) as StoredPersonalDictionarySnapshotExportRow | undefined, - }))(); - if (!stored.records) throw snapshotNotFound(); - const records = validatedStoredPersonalSnapshotForExport(stored.records); - const dictionary = stored.dictionary - ? validatedStoredPersonalDictionarySnapshotForExport(stored.dictionary) - : undefined; - const backup = createClientDatabaseBackupV7(records, dictionary); - const dictionaryRevision = Number(stored.dictionary?.revision ?? 0); + const stored = db.prepare(` + SELECT + p.user_id, u.username, p.revision, p.format_version, + p.snapshot_json, p.session_count, p.log_count, p.byte_size, + p.checksum, p.created_at, p.updated_at + FROM personal_cloud_snapshots p + INNER JOIN users u ON u.id = p.user_id + WHERE p.user_id = ? + `).get(userId) as (AdminPersonalSnapshotRow & StoredPersonalSnapshotExportRow) | undefined; + if (!stored) throw snapshotNotFound(); + const records = validatedStoredPersonalSnapshotForExport(stored); + const backup = createClientSessionDatabaseBackupV7(records, sessionId); auditSensitiveUserRead( db, req, userId, - 'personal_snapshot.database_v7.exported', + 'personal_snapshot.session_database_v7.exported', + { personalSnapshotSessionId: sessionId }, ); res.setHeader( 'Content-Disposition', - `attachment; filename="${clientDatabaseBackupV7Filename( - Number(stored.records.revision), - dictionaryRevision, + `attachment; filename="${clientSessionDatabaseBackupV7Filename( + sessionId, + Number(stored.revision), )}"`, ); res.setHeader( 'X-OpenLogTool-Backup-Format-Version', String(CLIENT_DATABASE_BACKUP_FORMAT_VERSION), ); - res.setHeader('X-Personal-Snapshot-Revision', String(stored.records.revision)); - res.setHeader('X-Personal-Dictionary-Snapshot-Revision', String(dictionaryRevision)); + res.setHeader('X-Personal-Snapshot-Revision', String(stored.revision)); + res.setHeader('X-Personal-Snapshot-Session-Id', sessionId); res.json(backup); } catch (error) { next(error); @@ -347,5 +333,18 @@ export function createAdminPersonalSnapshotsV1Router( }, ); + router.get( + '/personal-snapshots/:userId/database-backup-v7', + (_req, _res, next) => { + next( + new AppError( + 422, + 'PERSONAL_SNAPSHOT_SESSION_REQUIRED', + 'Export one Session through the session-scoped database backup endpoint', + ), + ); + }, + ); + return router; } diff --git a/src/api/personal-snapshot-v1.ts b/src/api/personal-snapshot-v1.ts index 5cc7b3b..b33acf4 100644 --- a/src/api/personal-snapshot-v1.ts +++ b/src/api/personal-snapshot-v1.ts @@ -1,16 +1,15 @@ import Database from 'better-sqlite3'; import { Router } from 'express'; import { AppConfig, config } from '../config'; +import { normalizeStableId } from '../collaboration/access'; import { getDb } from '../db/database'; import { AppError } from '../errors/app-error'; import { createAccessTokenMiddleware, V1AuthRequest } from '../middleware/auth-v1'; import { createMemoryRateLimiter } from '../middleware/rate-limit'; import { CLIENT_DATABASE_BACKUP_FORMAT_VERSION, - StoredPersonalDictionarySnapshotExportRow, - clientDatabaseBackupV7Filename, - createClientDatabaseBackupV7, - validatedStoredPersonalDictionarySnapshotForExport, + clientSessionDatabaseBackupV7Filename, + createClientSessionDatabaseBackupV7, validatedStoredPersonalSnapshotForExport, } from '../personal-snapshot/database-backup-v7'; import { @@ -296,50 +295,49 @@ export function createPersonalSnapshotV1Router( } }); - router.get('/personal-snapshot/database-backup-v7', (req: V1AuthRequest, res, next) => { - try { - const db = database(); - const stored = db.transaction(() => ({ - records: readSnapshotRow(db, req.auth!.userId), - dictionary: db.prepare(` - SELECT - revision, format_version, snapshot_json, - item_count, active_count, deleted_count, - byte_size, checksum - FROM personal_dictionary_snapshots - WHERE user_id = ? - `).get(req.auth!.userId) as StoredPersonalDictionarySnapshotExportRow | undefined, - }))(); - if (!stored.records) { - throw new AppError( - 404, - 'PERSONAL_SNAPSHOT_NOT_FOUND', - 'No personal cloud snapshot has been uploaded for this account', + router.get( + '/personal-snapshot/sessions/:sessionId/database-backup-v7', + (req: V1AuthRequest, res, next) => { + try { + const sessionId = normalizeStableId(req.params.sessionId, 'sessionId'); + const stored = readSnapshotRow(database(), req.auth!.userId); + if (!stored) { + throw new AppError( + 404, + 'PERSONAL_SNAPSHOT_NOT_FOUND', + 'No personal cloud snapshot has been uploaded for this account', + ); + } + const records = validatedStoredPersonalSnapshotForExport(stored); + const backup = createClientSessionDatabaseBackupV7(records, sessionId); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${clientSessionDatabaseBackupV7Filename( + sessionId, + Number(stored.revision), + )}"`, + ); + res.setHeader( + 'X-OpenLogTool-Backup-Format-Version', + String(CLIENT_DATABASE_BACKUP_FORMAT_VERSION), ); + res.setHeader('X-Personal-Snapshot-Revision', String(stored.revision)); + res.setHeader('X-Personal-Snapshot-Session-Id', sessionId); + res.json(backup); + } catch (error) { + next(error); } - const records = validatedStoredPersonalSnapshotForExport(stored.records); - const dictionary = stored.dictionary - ? validatedStoredPersonalDictionarySnapshotForExport(stored.dictionary) - : undefined; - const backup = createClientDatabaseBackupV7(records, dictionary); - const dictionaryRevision = Number(stored.dictionary?.revision ?? 0); - res.setHeader( - 'Content-Disposition', - `attachment; filename="${clientDatabaseBackupV7Filename( - Number(stored.records.revision), - dictionaryRevision, - )}"`, - ); - res.setHeader( - 'X-OpenLogTool-Backup-Format-Version', - String(CLIENT_DATABASE_BACKUP_FORMAT_VERSION), - ); - res.setHeader('X-Personal-Snapshot-Revision', String(stored.records.revision)); - res.setHeader('X-Personal-Dictionary-Snapshot-Revision', String(dictionaryRevision)); - res.json(backup); - } catch (error) { - next(error); - } + }, + ); + + router.get('/personal-snapshot/database-backup-v7', (_req, _res, next) => { + next( + new AppError( + 422, + 'PERSONAL_SNAPSHOT_SESSION_REQUIRED', + 'Export one Session through the session-scoped database backup endpoint', + ), + ); }); router.put( diff --git a/src/personal-snapshot/database-backup-v7.ts b/src/personal-snapshot/database-backup-v7.ts index ceb9248..cc73f68 100644 --- a/src/personal-snapshot/database-backup-v7.ts +++ b/src/personal-snapshot/database-backup-v7.ts @@ -1,9 +1,4 @@ -import { createHash } from 'crypto'; import { AppError } from '../errors/app-error'; -import { - PersonalDictionarySnapshot, - validatePersonalDictionarySnapshot, -} from '../personal-dictionary-snapshot/model'; import { PersonalSnapshot, validatePersonalSnapshot, @@ -21,33 +16,12 @@ export interface StoredPersonalSnapshotExportRow { checksum: string; } -export interface StoredPersonalDictionarySnapshotExportRow { - revision: number; - format_version: number; - snapshot_json: string; - item_count: number; - active_count: number; - deleted_count: number; - byte_size: number; - checksum: string; -} - export interface ClientDatabaseBackupV7 { version: 7; exportedAt: string; logs: PersonalSnapshot['logs']; sessions: PersonalSnapshot['sessions']; - dictionary_items: Array<{ - dict_type: string; - raw: string; - pinyin: string | null; - abbreviation: string | null; - sync_id: string; - created_at: string; - updated_at: string; - deleted_at: string | null; - origin: 'user' | 'builtin'; - }>; + dictionary_items: []; settings: []; oplog: []; collaboration_bindings: []; @@ -99,80 +73,27 @@ export function validatedStoredPersonalSnapshotForExport( } } -export function validatedStoredPersonalDictionarySnapshotForExport( - row: StoredPersonalDictionarySnapshotExportRow, -): PersonalDictionarySnapshot { - try { - const validated = validatePersonalDictionarySnapshot( - JSON.parse(row.snapshot_json) as unknown, - ); - if ( - validated.snapshot.version !== Number(row.format_version) || - validated.itemCount !== Number(row.item_count) || - validated.activeCount !== Number(row.active_count) || - validated.deletedCount !== Number(row.deleted_count) || - validated.byteSize !== Number(row.byte_size) || - validated.checksum !== row.checksum - ) { - throw corruptSnapshot( - 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT', - 'The stored personal dictionary snapshot failed integrity validation', - ); - } - return validated.snapshot; - } catch (error) { - if ( - error instanceof AppError && - error.code === 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT' - ) { - throw error; - } - throw corruptSnapshot( - 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT', - 'The stored personal dictionary snapshot failed integrity validation', - error, - ); - } -} - -function clientDictionaryType(value: string): string { - return `${value}_dictionary`; -} - -function dictionarySyncId(dictType: string, raw: string): string { - const digest = createHash('sha256') - .update(`openlogtool/personal-cloud-dictionary/v1\0${dictType}\0${raw}`) - .digest('hex'); - return `dict-cloud-${digest.slice(0, 32)}`; -} - -export function createClientDatabaseBackupV7( +export function createClientSessionDatabaseBackupV7( records: PersonalSnapshot, - dictionary: PersonalDictionarySnapshot | undefined, + sessionId: string, exportedAt = new Date().toISOString(), ): ClientDatabaseBackupV7 { - const dictionaryItems = (dictionary?.items ?? []).map((item) => { - const dictType = clientDictionaryType(item.dictType); - const timestamp = dictionary?.exportedAt ?? exportedAt; - return { - dict_type: dictType, - raw: item.raw, - pinyin: item.pinyin, - abbreviation: item.abbreviation, - sync_id: dictionarySyncId(dictType, item.raw), - created_at: timestamp, - updated_at: timestamp, - deleted_at: item.state === 'deleted' ? timestamp : null, - origin: item.origin, - }; - }); + const session = records.sessions.find((item) => item.session_id === sessionId); + if (!session) { + throw new AppError( + 404, + 'PERSONAL_SNAPSHOT_SESSION_NOT_FOUND', + 'The requested Session is not present in the personal cloud snapshot', + { sessionId }, + ); + } return { version: CLIENT_DATABASE_BACKUP_FORMAT_VERSION, exportedAt, - logs: records.logs, - sessions: records.sessions, - dictionary_items: dictionaryItems, + logs: records.logs.filter((log) => log.session_id === sessionId), + sessions: [session], + dictionary_items: [], settings: [], oplog: [], collaboration_bindings: [], @@ -185,9 +106,10 @@ export function createClientDatabaseBackupV7( }; } -export function clientDatabaseBackupV7Filename( +export function clientSessionDatabaseBackupV7Filename( + sessionId: string, recordRevision: number, - dictionaryRevision: number, ): string { - return `openlogtool-personal-r${recordRevision}-d${dictionaryRevision}-v7.json`; + const fileSafeSessionId = sessionId.replace(/[^A-Za-z0-9._-]+/g, '-'); + return `openlogtool-session-${fileSafeSessionId}-r${recordRevision}-v7.json`; } diff --git a/test/admin-personal-snapshots-v1.test.ts b/test/admin-personal-snapshots-v1.test.ts index caa24be..e7a834b 100644 --- a/test/admin-personal-snapshots-v1.test.ts +++ b/test/admin-personal-snapshots-v1.test.ts @@ -71,6 +71,15 @@ function snapshotFixture(suffix: string): PersonalSnapshot { closed_at: '2026-07-18T11:00:00.000+08:00', deleted_at: null, }, + { + session_id: `session-${suffix}-other`, + title: `Other personal net ${suffix}`, + status: 'active', + created_at: '2026-07-18T12:00:00.000+08:00', + updated_at: '2026-07-18T12:30:00.000+08:00', + closed_at: null, + deleted_at: null, + }, ], logs: [ { @@ -92,6 +101,25 @@ function snapshotFixture(suffix: string): PersonalSnapshot { deleted_at: null, source_device_id: null, }, + { + sync_id: `log-${suffix}-other`, + session_id: `session-${suffix}-other`, + time: '20:16:59', + controller: 'BG5CTRL', + callsign: `BG5${suffix.toUpperCase()}X`, + rst_sent: '59', + rst_rcvd: '59', + qth: 'Ningbo', + device: null, + power: '5W', + antenna: null, + height: null, + remarks: 'other private personal note', + created_at: '2026-07-18T12:16:59.000+08:00', + updated_at: '2026-07-18T12:16:59.000+08:00', + deleted_at: null, + source_device_id: null, + }, ], }; } @@ -249,7 +277,7 @@ describe('administrator personal cloud snapshot read API', { concurrency: false 'ADMIN_REQUIRED', ); assertError( - await request('/api/v1/admin/personal-snapshots/user-a/database-backup-v7', { + await request('/api/v1/admin/personal-snapshots/user-a/sessions/session-a/database-backup-v7', { token: accessToken('member', 'user'), }), 403, @@ -428,10 +456,17 @@ describe('administrator personal cloud snapshot read API', { concurrency: false `).pluck().get()), 2); }); - test('exports an audited client-compatible v7 database backup', async () => { + test('exports one audited Session as a client-compatible v7 database backup', async () => { const accessId = `personal-v7-export-${randomUUID()}`; + assertError( + await request('/api/v1/admin/personal-snapshots/user-a/database-backup-v7', { + token: accessToken('admin-root', 'admin'), + }), + 422, + 'PERSONAL_SNAPSHOT_SESSION_REQUIRED', + ); const result = await request( - '/api/v1/admin/personal-snapshots/user-a/database-backup-v7', + '/api/v1/admin/personal-snapshots/user-a/sessions/session-a/database-backup-v7', { token: accessToken('admin-root', 'admin'), headers: { 'x-admin-access-id': accessId }, @@ -441,10 +476,10 @@ describe('administrator personal cloud snapshot read API', { concurrency: false assert.equal(result.headers.get('cache-control'), 'no-store'); assert.equal(result.headers.get('x-openlogtool-backup-format-version'), '7'); assert.equal(result.headers.get('x-personal-snapshot-revision'), '1'); - assert.equal(result.headers.get('x-personal-dictionary-snapshot-revision'), '1'); + assert.equal(result.headers.get('x-personal-snapshot-session-id'), 'session-a'); assert.match( result.headers.get('content-disposition') ?? '', - /openlogtool-personal-r1-d1-v7\.json/, + /openlogtool-session-session-a-r1-v7\.json/, ); exactKeys(result.body, [ 'version', @@ -463,25 +498,10 @@ describe('administrator personal cloud snapshot read API', { concurrency: false 'collaboration_offline_records', ]); assert.equal(result.body.version, 7); - assert.deepEqual(result.body.sessions, snapshotFixture('a').sessions); - assert.deepEqual(result.body.logs, snapshotFixture('a').logs); - assert.ok(Array.isArray(result.body.dictionary_items)); - assert.equal(result.body.dictionary_items.length, 1); - assert.deepEqual(result.body.dictionary_items[0], { - dict_type: 'qth_dictionary', - raw: 'Cloud QTH', - pinyin: 'cloud qth', - abbreviation: 'CQ', - sync_id: result.body.dictionary_items[0].sync_id, - created_at: '2026-07-03T01:02:03.456Z', - updated_at: '2026-07-03T01:02:03.456Z', - deleted_at: null, - origin: 'user', - }); - assert.match( - result.body.dictionary_items[0].sync_id, - /^dict-cloud-[0-9a-f]{32}$/, - ); + const accountSnapshot = snapshotFixture('a'); + assert.deepEqual(result.body.sessions, [accountSnapshot.sessions[0]]); + assert.deepEqual(result.body.logs, [accountSnapshot.logs[0]]); + assert.deepEqual(result.body.dictionary_items, []); for (const table of [ 'settings', 'oplog', @@ -499,18 +519,30 @@ describe('administrator personal cloud snapshot read API', { concurrency: false const audits = db.prepare(` SELECT action, actor_user_id, target_type, target_id, details_json FROM admin_governance_audit_events - WHERE action = 'personal_snapshot.database_v7.exported' + WHERE action = 'personal_snapshot.session_database_v7.exported' AND target_id = 'user-a' `).all() as Array>; assert.deepEqual(audits, [{ - action: 'personal_snapshot.database_v7.exported', + action: 'personal_snapshot.session_database_v7.exported', actor_user_id: 'admin-root', target_type: 'user', target_id: 'user-a', - details_json: JSON.stringify({ accessId }), + details_json: JSON.stringify({ + accessId, + personalSnapshotSessionId: 'session-a', + }), }]); assert.equal(JSON.stringify(audits).includes('BG5A'), false); assert.equal(JSON.stringify(audits).includes('private personal note'), false); + + assertError( + await request( + '/api/v1/admin/personal-snapshots/user-a/sessions/missing-session/database-backup-v7', + { token: accessToken('admin-root', 'admin') }, + ), + 404, + 'PERSONAL_SNAPSHOT_SESSION_NOT_FOUND', + ); }); test('reports missing and corrupt snapshots without creating a read audit', async () => { diff --git a/test/personal-snapshot-v1.test.ts b/test/personal-snapshot-v1.test.ts index cd53cc5..7685b5c 100644 --- a/test/personal-snapshot-v1.test.ts +++ b/test/personal-snapshot-v1.test.ts @@ -10,6 +10,7 @@ import jwt from 'jsonwebtoken'; import Database from 'better-sqlite3'; import { createApp } from '../src/app'; import { openDatabase } from '../src/db/database'; +import { createClientSessionDatabaseBackupV7 } from '../src/personal-snapshot/database-backup-v7'; import { validatePersonalSnapshot } from '../src/personal-snapshot/model'; interface HttpResult { @@ -116,6 +117,31 @@ test('personal snapshot checksum follows the cross-client canonical vector', () ); }); +test('client v7 backup contains exactly one selected Session', () => { + const snapshot: any = sampleSnapshot(); + snapshot.sessions.push({ + ...snapshot.sessions[0], + session_id: 'another-session', + title: 'Another net', + }); + snapshot.logs.push({ + ...snapshot.logs[0], + sync_id: 'another-log', + session_id: 'another-session', + callsign: 'BG5OTHER', + }); + + const backup = createClientSessionDatabaseBackupV7( + snapshot, + 'local-session-2026-07-18', + NOW, + ); + assert.deepEqual(backup.sessions, [snapshot.sessions[0]]); + assert.deepEqual(backup.logs, [snapshot.logs[0]]); + assert.deepEqual(backup.dictionary_items, []); + assert.equal(backup.exportedAt, NOW); +}); + test('personal snapshot accepts archived sessions and rejects duplicate log IDs globally', () => { const archived: any = sampleSnapshot(); archived.sessions[0].status = 'archived'; @@ -340,6 +366,14 @@ describe('account personal cloud snapshot v1', () => { await request('/api/v1/account/personal-snapshot/database-backup-v7', { token: ownerToken, }), + 422, + 'PERSONAL_SNAPSHOT_SESSION_REQUIRED', + ); + assertError( + await request( + '/api/v1/account/personal-snapshot/sessions/missing-session/database-backup-v7', + { token: ownerToken }, + ), 404, 'PERSONAL_SNAPSHOT_NOT_FOUND', ); @@ -398,17 +432,20 @@ describe('account personal cloud snapshot v1', () => { assert.deepEqual(download.body.personalSnapshot.snapshot, snapshot); const databaseBackup = await request( - '/api/v1/account/personal-snapshot/database-backup-v7', + '/api/v1/account/personal-snapshot/sessions/local-session-2026-07-18/database-backup-v7', { token: ownerToken }, ); assert.equal(databaseBackup.status, 200); assert.match( databaseBackup.headers.get('content-disposition') ?? '', - /openlogtool-personal-r1-d0-v7\.json/, + /openlogtool-session-local-session-2026-07-18-r1-v7\.json/, ); assert.equal(databaseBackup.headers.get('x-openlogtool-backup-format-version'), '7'); assert.equal(databaseBackup.headers.get('x-personal-snapshot-revision'), '1'); - assert.equal(databaseBackup.headers.get('x-personal-dictionary-snapshot-revision'), '0'); + assert.equal( + databaseBackup.headers.get('x-personal-snapshot-session-id'), + 'local-session-2026-07-18', + ); assert.equal(databaseBackup.body.version, 7); assert.ok(Number.isFinite(Date.parse(databaseBackup.body.exportedAt))); assert.deepEqual(databaseBackup.body.sessions, snapshot.sessions); @@ -427,9 +464,17 @@ describe('account personal cloud snapshot v1', () => { ]) { assert.deepEqual(databaseBackup.body[table], [], `${table} must be an empty v7 table`); } + assertError( + await request( + '/api/v1/account/personal-snapshot/sessions/not-in-snapshot/database-backup-v7', + { token: ownerToken }, + ), + 404, + 'PERSONAL_SNAPSHOT_SESSION_NOT_FOUND', + ); }); - test('converts personal dictionary changes into client v7 database rows', async () => { + test('keeps account-wide dictionary data out of a Session export', async () => { const dictionary = { version: 1, exportedAt: '2026-07-18T12:34:56.789+08:00', @@ -464,50 +509,15 @@ describe('account personal cloud snapshot v1', () => { assert.equal(uploaded.status, 200); const exported = await request( - '/api/v1/account/personal-snapshot/database-backup-v7', + '/api/v1/account/personal-snapshot/sessions/local-session-2026-07-18/database-backup-v7', { token: ownerToken }, ); assert.equal(exported.status, 200); assert.match( exported.headers.get('content-disposition') ?? '', - /openlogtool-personal-r1-d1-v7\.json/, - ); - assert.equal(exported.headers.get('x-personal-dictionary-snapshot-revision'), '1'); - assert.equal(exported.body.dictionary_items.length, 2); - assert.deepEqual( - exported.body.dictionary_items.map((item: any) => ({ - dict_type: item.dict_type, - raw: item.raw, - pinyin: item.pinyin, - abbreviation: item.abbreviation, - deleted_at: item.deleted_at, - origin: item.origin, - })), - [ - { - dict_type: 'callsign_dictionary', - raw: 'BG5CLOUD', - pinyin: null, - abbreviation: 'BC', - deleted_at: null, - origin: 'user', - }, - { - dict_type: 'antenna_dictionary', - raw: 'Legacy antenna', - pinyin: null, - abbreviation: null, - deleted_at: dictionary.exportedAt, - origin: 'builtin', - }, - ], + /openlogtool-session-local-session-2026-07-18-r1-v7\.json/, ); - for (const item of exported.body.dictionary_items) { - assert.match(item.sync_id, /^dict-cloud-[0-9a-f]{32}$/); - assert.equal(item.created_at, dictionary.exportedAt); - assert.equal(item.updated_at, dictionary.exportedAt); - assert.equal('id' in item, false, 'the client must allocate local row IDs'); - } + assert.deepEqual(exported.body.dictionary_items, []); assert.deepEqual(exported.body.collaboration_bindings, []); assert.deepEqual(exported.body.sync_outbox, []); @@ -516,13 +526,14 @@ describe('account personal cloud snapshot v1', () => { SET byte_size = byte_size + 1 WHERE user_id = ? `).run(OWNER_ID); - assertError( - await request('/api/v1/account/personal-snapshot/database-backup-v7', { + const exportWithCorruptDictionary = await request( + '/api/v1/account/personal-snapshot/sessions/local-session-2026-07-18/database-backup-v7', + { token: ownerToken, - }), - 500, - 'PERSONAL_DICTIONARY_SNAPSHOT_CORRUPT', + }, ); + assert.equal(exportWithCorruptDictionary.status, 200); + assert.deepEqual(exportWithCorruptDictionary.body.dictionary_items, []); db.prepare(` UPDATE personal_dictionary_snapshots SET byte_size = byte_size - 1 diff --git a/web/src/api.ts b/web/src/api.ts index abff32a..f649460 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -243,9 +243,9 @@ export const accountApi = { revokeDevice: (id: string) => unwrap(api.delete(`/account/devices/${encodeURIComponent(id)}`)), personalSnapshot: () => unwrap(api.get<{ personalSnapshot: PersonalSnapshotMetadata }>('/account/personal-snapshot')), downloadPersonalSnapshot: () => unwrap(api.get<{ personalSnapshot: PersonalSnapshotDownload }>('/account/personal-snapshot/download')), - exportPersonalSnapshotDatabaseV7: () => downloadGetFile( - '/account/personal-snapshot/database-backup-v7', - 'openlogtool-personal-v7.json', + exportPersonalSnapshotSessionDatabaseV7: (sessionId: string) => downloadGetFile( + `/account/personal-snapshot/sessions/${encodeURIComponent(sessionId)}/database-backup-v7`, + `openlogtool-session-${sessionId}-v7.json`, ), personalDictionarySnapshot: () => unwrap(api.get<{ personalDictionarySnapshot: PersonalDictionarySnapshotMetadata }>('/account/personal-dictionary-snapshot')), downloadPersonalDictionarySnapshot: () => unwrap(api.get<{ personalDictionarySnapshot: PersonalDictionarySnapshotDownload }>('/account/personal-dictionary-snapshot/download')), @@ -539,9 +539,9 @@ export const adminApi = { unwrap(api.get(`/admin/personal-snapshots/${encodeURIComponent(userId)}`, { headers: { 'X-Admin-Access-Id': accessId }, })), - exportPersonalSnapshotDatabaseV7: (userId: string) => downloadGetFile( - `/admin/personal-snapshots/${encodeURIComponent(userId)}/database-backup-v7`, - 'openlogtool-personal-v7.json', + exportPersonalSnapshotSessionDatabaseV7: (userId: string, sessionId: string) => downloadGetFile( + `/admin/personal-snapshots/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/database-backup-v7`, + `openlogtool-session-${sessionId}-v7.json`, { 'X-Admin-Access-Id': crypto.randomUUID() }, ), personalDictionarySnapshots: (params: { page: number; pageSize: number; q?: string }) => diff --git a/web/src/components/PersonalSnapshotViewer.tsx b/web/src/components/PersonalSnapshotViewer.tsx index cdd0f7b..c4e44c5 100644 --- a/web/src/components/PersonalSnapshotViewer.tsx +++ b/web/src/components/PersonalSnapshotViewer.tsx @@ -20,16 +20,16 @@ function valueOrDash(value: string | null): string { return value || '—'; } -export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExportDatabaseV7 }: { +export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExportSessionDatabaseV7 }: { owner: PersonalSnapshotOwner; personalSnapshot: PersonalSnapshotDownload; admin: boolean; - onExportDatabaseV7: () => Promise; + onExportSessionDatabaseV7: (sessionId: string) => Promise; }) { const { t, locale } = useI18n(); const { message } = App.useApp(); const { snapshot } = personalSnapshot; - const [exporting, setExporting] = useState(false); + const [exportingSessionId, setExportingSessionId] = useState(null); const [sessionQuery, setSessionQuery] = useState(''); const [logQuery, setLogQuery] = useState(''); const [selectedSessionId, setSelectedSessionId] = useState(() => snapshot.sessions[0]?.session_id ?? null); @@ -46,17 +46,17 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExpor return counts; }, [snapshot.logs]); const selectedSession = snapshot.sessions.find((session) => session.session_id === selectedSessionId) ?? null; - const exportDatabaseV7 = async () => { - setExporting(true); + const exportSessionDatabaseV7 = async (sessionId: string) => { + setExportingSessionId(sessionId); try { - await onExportDatabaseV7(); + await onExportSessionDatabaseV7(sessionId); message.success(t('personalCloud.exportDatabaseV7Succeeded')); } catch (error) { message.error(t('personalCloud.exportDatabaseV7Failed', { message: error instanceof Error ? error.message : String(error), })); } finally { - setExporting(false); + setExportingSessionId(null); } }; @@ -74,19 +74,7 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExpor - } - loading={exporting} - onClick={() => void exportDatabaseV7()} - >{t('personalCloud.exportDatabaseV7')}} - style={{ marginBottom: 18 }} - > - - {t('personalCloud.exportDatabaseV7Hint')} - + {owner.username}
{owner.id} }, { key: 'format', label: t('personalCloud.formatVersion'), children: personalSnapshot.formatVersion }, @@ -101,6 +89,9 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExpor title={{t('personalCloud.snapshotSessions')}} value={sessionQuery} onChange={(event) => setSessionQuery(event.target.value)} placeholder={t('personalCloud.searchSessions')} style={{ width: 280, maxWidth: '100%' }} />} style={{ marginBottom: 18 }} > + + {t('personalCloud.exportDatabaseV7Hint')} + {visibleSessions.length === 0 ?
: rowKey="session_id" dataSource={visibleSessions} @@ -112,7 +103,7 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExpor { title: t('common.status'), dataIndex: 'status', width: 120, render: (value: PersonalSnapshotSession['status']) => {t(`personalCloud.status.${value}`)} }, { title: t('personalCloud.logs'), width: 100, render: (_, row) => logCounts.get(row.session_id) ?? 0 }, { title: t('sessions.updatedAt'), dataIndex: 'updated_at', width: 190, render: (value: string) => timestamp(value, locale) }, - { title: t('common.actions'), width: 130, render: (_, row) => }, + { title: t('common.actions'), width: 260, render: (_, row) => }, ]} />}
diff --git a/web/src/i18n.ts b/web/src/i18n.ts index cce3fb4..e9e76c7 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -90,9 +90,9 @@ const zh = { 'personalCloud.exportedAt': '客户端导出时间', 'personalCloud.createdAt': '首次上传时间', 'personalCloud.updatedAt': '服务器更新时间', - 'personalCloud.exportDatabaseV7': '导出客户端 v7 JSON', - 'personalCloud.exportDatabaseV7Hint': '生成可由 OpenLogTool 客户端“导入本地数据库”恢复的 v7 JSON。记录和个人词库改动会保留,设置及协作绑定不会导出;客户端导入会替换当前本地数据库。', - 'personalCloud.exportDatabaseV7Succeeded': '客户端 v7 JSON 已开始下载', + 'personalCloud.exportDatabaseV7': '导出此会话 v7 JSON', + 'personalCloud.exportDatabaseV7Hint': '每次只导出所选会话及该会话的全部记录,生成可由 OpenLogTool 客户端“导入本地数据库”恢复的 v7 JSON;其他会话、个人词库、设置及协作绑定均不会包含。客户端导入会替换当前本地数据库。', + 'personalCloud.exportDatabaseV7Succeeded': '所选会话的客户端 v7 JSON 已开始下载', 'personalCloud.exportDatabaseV7Failed': '导出失败:{message}', 'personalCloud.snapshotSessions': '快照会话', 'personalCloud.searchSessions': '搜索会话名或 ID', @@ -417,9 +417,9 @@ const en: Record = { 'personalCloud.size': 'Size', 'personalCloud.snapshotMetadata': 'Snapshot metadata', 'personalCloud.formatVersion': 'Format version', 'personalCloud.exportedAt': 'Client export time', 'personalCloud.createdAt': 'First uploaded', 'personalCloud.updatedAt': 'Server updated', - 'personalCloud.exportDatabaseV7': 'Export client v7 JSON', - 'personalCloud.exportDatabaseV7Hint': 'Creates a v7 JSON file accepted by OpenLogTool’s local database import. Records and personal dictionary changes are retained; settings and collaboration bindings are omitted. Importing it replaces the client’s current local database.', - 'personalCloud.exportDatabaseV7Succeeded': 'The client v7 JSON download has started', + 'personalCloud.exportDatabaseV7': 'Export this Session as v7 JSON', + 'personalCloud.exportDatabaseV7Hint': 'Each export contains only the selected Session and all of its Logs in a v7 JSON file accepted by OpenLogTool’s local database import. Other Sessions, personal dictionaries, settings, and collaboration bindings are omitted. Importing it replaces the client’s current local database.', + 'personalCloud.exportDatabaseV7Succeeded': 'The selected Session v7 JSON download has started', 'personalCloud.exportDatabaseV7Failed': 'Export failed: {message}', 'personalCloud.snapshotSessions': 'Snapshot sessions', 'personalCloud.searchSessions': 'Search name or ID', 'personalCloud.noMatchingSessions': 'No matching sessions', 'personalCloud.searchLogs': 'Search record content', diff --git a/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx b/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx index 02d3974..c65c96a 100644 --- a/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx +++ b/web/src/pages/admin/AdminPersonalSnapshotDetailPage.tsx @@ -64,7 +64,8 @@ export default function AdminPersonalSnapshotDetailPage() { owner={recordsState.data.user} personalSnapshot={recordsState.data.personalSnapshot} admin - onExportDatabaseV7={() => adminApi.exportPersonalSnapshotDatabaseV7(userId)} + onExportSessionDatabaseV7={(sessionId) => + adminApi.exportPersonalSnapshotSessionDatabaseV7(userId, sessionId)} />} : {dictionariesState.data && } diff --git a/web/src/pages/app/PersonalCloudPage.tsx b/web/src/pages/app/PersonalCloudPage.tsx index 1ef77f6..122fbe5 100644 --- a/web/src/pages/app/PersonalCloudPage.tsx +++ b/web/src/pages/app/PersonalCloudPage.tsx @@ -50,7 +50,7 @@ export default function PersonalCloudPage() { owner={owner} personalSnapshot={state.data.recordsDownload} admin={false} - onExportDatabaseV7={accountApi.exportPersonalSnapshotDatabaseV7} + onExportSessionDatabaseV7={accountApi.exportPersonalSnapshotSessionDatabaseV7} /> : <>
, }, From 4cbd31fdd9cb81c3d1992b8d8e7c7b615f140854 Mon Sep 17 00:00:00 2001 From: Mazha0309 Date: Wed, 22 Jul 2026 20:49:39 +0800 Subject: [PATCH 4/6] feat: improve Live Share administration --- .env.example | 4 + README.md | 48 +++++- deploy.sh | 35 ++++- docker-compose.yml | 1 + docs/public-liveshare-statistics-api-v1.md | 32 +++- .../2026-07-11-collaboration-v1-design.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/api/collaboration-metrics-v1.ts | 75 ++++++--- src/api/server-info.ts | 2 + src/config.ts | 2 + src/operations/ip-geolocation.ts | 148 ++++++++++++++++++ src/operations/public-share-analytics.ts | 21 ++- src/server-version.ts | 17 ++ test/collaboration-metrics.test.ts | 6 +- test/config.test.ts | 5 + test/foundation.test.ts | 1 + test/ip-geolocation.test.ts | 54 +++++++ test/public-liveshare.test.ts | 12 +- web/src/components/AppShell.tsx | 17 +- web/src/components/PersonalSnapshotViewer.tsx | 8 +- web/src/i18n.ts | 12 +- web/src/pages/admin/AdminAuditPage.tsx | 2 +- .../admin/AdminPersonalSnapshotsPage.tsx | 2 +- .../pages/admin/AdminSessionDetailPage.tsx | 2 +- web/src/pages/admin/AdminSessionsPage.tsx | 2 +- web/src/pages/admin/OperationsPage.tsx | 17 +- .../pages/admin/PublicLiveshareDetailPage.tsx | 19 ++- web/src/pages/admin/UsersPage.tsx | 2 +- web/src/pages/app/SessionDetailPage.tsx | 2 +- web/src/pages/app/SessionsPage.tsx | 2 +- web/src/styles.css | 31 +++- web/src/types.ts | 11 ++ 33 files changed, 516 insertions(+), 86 deletions(-) create mode 100644 src/operations/ip-geolocation.ts create mode 100644 src/server-version.ts create mode 100644 test/ip-geolocation.test.ts diff --git a/.env.example b/.env.example index 45a296f..262c78a 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ ADMIN_BOOTSTRAP_TOKEN= INVITE_HMAC_KEY= PUBLIC_SHARE_HMAC_KEY= +# Optional. Enables province/city/district inference for public visitor IPs. +# Create an AK with Baidu Maps' normal IP geolocation service enabled. +BAIDU_MAP_AK= + JWT_ISSUER=openlogtool-server ACCESS_TOKEN_TTL_SECONDS=900 REFRESH_TOKEN_TTL_DAYS=30 diff --git a/README.md b/README.md index 50509b7..b693911 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ PUBLIC_SHARE_HMAC_KEY=<至少 32 字节的独立随机值> 必须在服务前配置 HTTPS 反向代理,并保持 `NODE_ENV=production`;不要通过局域网明文 HTTP 登录成员门户或管理后台。若代理终止 TLS,按实际代理层级配置 `TRUST_PROXY`。 -## 启动 +## 安装与启动 -### Docker +### Docker 首次安装 ~~~bash mkdir -p data @@ -72,6 +72,45 @@ Compose 默认只把服务发布到宿主机 `http://127.0.0.1:3000`,容器内 `0.0.0.0:3000`;SQLite 位于 `./data/openlogtool.db`。需要从其他机器访问时,优先在 本机部署 HTTPS 反向代理;确需直接发布时再修改 `.env` 中的 `BIND_ADDRESS`。 +### 一键部署脚本(原生 Node.js) + +`deploy.sh` 默认从远端 `main` 分支安装或快进更新代码,保留已有 `.env` 和 +`./data/openlogtool.db`,随后依次安装依赖、构建后端与两个 Web 前端并重启服务: + +~~~bash +curl -fsSLO https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy.sh +bash deploy.sh 3000 +~~~ + +脚本优先使用已经安装的 PM2;没有 PM2 时以 `nohup` 启动并写入 `server.log`。需要临时 +测试其他远端分支时可显式设置,例如 `OPENLOGTOOL_BRANCH=dev bash deploy.sh 3000`。 +仓库存在未提交的已跟踪文件修改或无法快进时,脚本会停止,不会强制覆盖本地改动。 + +### 更新已有部署 + +更新前不要删除或重新生成 `.env` 中现有的密钥,否则既有登录、邀请或 Live Share +链接可能失效。Docker 部署建议先完成镜像构建,再短暂停服并备份 SQLite,最后重建容器: + +~~~bash +cd ~/OpenLogToolServer +git fetch origin main +git switch main +git pull --ff-only origin main +docker compose build server +docker compose stop server +cp -a data "data-backup-$(date +%Y%m%d-%H%M%S)" +docker compose up -d --force-recreate server +docker compose ps +curl -fsS http://127.0.0.1:3000/api/v1/server-info +~~~ + +若 `.env` 中自定义了 `HOST_PORT`,最后的健康检查地址也要改成对应端口。数据库迁移在新 +版本进程启动时自动执行;不要同时运行新旧两个进程访问同一个 SQLite 文件。仅修改环境 +变量时不必重新构建镜像,执行 `docker compose up -d --force-recreate server` 即可。 + +原生 Node.js/PM2 部署可在备份 `./data` 后重新运行相同的一键部署命令。部署 `dev` 仅用于 +测试;生产环境应使用默认的 `main`。 + ### HTTPS 反向代理与 WebSocket 若只有一层可信 HTTPS 反向代理,在 `.env` 中设置: @@ -171,7 +210,7 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ | GET | `/api/v1/admin/audit-events?...` | 按稳定 cursor 查询运行时管理审计 | | GET | `/api/v1/admin/collaboration-metrics` | 管理员读取进程 CPU/内存、运行环境资源、请求、连接和数据库聚合指标 | | GET | `/api/v1/admin/public-liveshare-stats?limit=` | 管理员读取 Live Share 当前连接与有效打开统计;列表响应 `schemaVersion: 1` | -| GET | `/api/v1/admin/public-liveshare-stats/:publicShareId` | 管理员读取单个 Live Share 的统计及访客 IP 详情;详情响应 `schemaVersion: 2` | +| GET | `/api/v1/admin/public-liveshare-stats/:publicShareId` | 管理员读取单个 Live Share 的统计及按 IP 聚合的访客详情;详情响应 `schemaVersion: 3` | | GET | `/api/v1/admin/session-event-retention/preview` | 管理员只读预演 Session 事件裁剪 | | POST | `/api/v1/admin/session-event-retention/prune` | 管理员显式、幂等执行有界 Session 事件裁剪 | | POST | `/api/v1/admin/elevate` | 当前密码复核,签发 5 分钟危险操作 elevation | @@ -213,7 +252,7 @@ curl -X POST http://127.0.0.1:3000/api/v1/auth/bootstrap \ 公开 snapshot 和 event 使用逐字段白名单 DTO:保留 Session 标题、状态及 Log 业务字段(包括电台设备字段 `device`),删除 actor、user/account ID、actor deviceId/sourceDeviceId、mutationId、entityVersion、成员、邀请和内部审计数据。同一 share 最多存在 8 张、同一 public JWT `jti` 最多存在 4 张未消费 ticket;签发前立即清理已过期 ticket,成功消费后在同一事务中删除 ticket 行。公开链接被 Owner 撤销、自然到期或所属 Session 删除后,exchange、REST、未消费 ticket 和现有 `/ws/public` 连接都会停止授权;Session 删除时,已连接页面先收到裁剪后的最终 `session.deleted` 再关闭。 -管理员统计把 Live Share 的“当前观看连接”定义为当前进程内活动的公开 WebSocket 数,近似表示打开的页面/标签页,不代表可识别的独立人数;断网连接最多会在心跳检测后移除。“累计有效打开”由公开页面每次生命周期生成仅存内存的随机 ID,并在 secret 验证成功时登记,同一页面的 5 分钟 access token 续签不会重复累计。服务器只保存由 `PUBLIC_SHARE_HMAC_KEY` 派生的 HMAC 去重值,不保存原始页面 ID 或 User-Agent;迁移 v24 起还会为每个去重会话保存最近一次可信请求 IP,并在管理员详情接口中返回,IP 的准确性和安全边界取决于 `TRUST_PROXY`。分享撤销、过期或 Session 删除后清理去重和 IP 明细,但保留聚合计数。去重明细硬限制为每个分享 10,000 条、当前数据库 100,000 条;达到任一限制时计入触发限制的那次打开,随后停止增加该分享的累计数并在管理端标成下限值,避免公开链接造成无限数据库增长。聚合统计从迁移 v23 部署后开始,IP 明细从迁移 v24 部署后开始,均不回填历史访问;旧明细的 IP 可为 `null`。这些数据不能可靠识别自然人,也不应用于计费。完整响应、范围和隐私约束见 [Public Live Share Statistics API v1](docs/public-liveshare-statistics-api-v1.md)。 +管理员统计把 Live Share 的“当前观看连接”定义为当前进程内活动的公开 WebSocket 数,近似表示打开的页面/标签页,不代表可识别的独立人数;断网连接最多会在心跳检测后移除。“累计有效打开”由公开页面每次生命周期生成仅存内存的随机 ID,并在 secret 验证成功时登记,同一页面的 5 分钟 access token 续签不会重复累计。服务器只保存由 `PUBLIC_SHARE_HMAC_KEY` 派生的 HMAC 去重值,不保存原始页面 ID 或 User-Agent;迁移 v24 起还会为每个去重会话保存最近一次可信请求 IP,并在管理员详情接口中按 IP 聚合访问次数,IP 的准确性和安全边界取决于 `TRUST_PROXY`。可选配置 `BAIDU_MAP_AK` 后,服务端通过[百度地图普通 IP 定位](https://lbsyun.baidu.com/docs/webapi?title=locationip%2Fip-api-base)推断省、市、区县并作有界缓存;密钥和原始响应不会返回浏览器,查询失败、内网或非 IPv4 地址显示为不可用。IP 属地不是精确定位,也不能可靠识别自然人或用于计费。分享撤销、过期或 Session 删除后清理去重和 IP 明细,但保留聚合计数。去重明细硬限制为每个分享 10,000 条、当前数据库 100,000 条;达到任一限制时计入触发限制的那次打开,随后停止增加该分享的累计数并在管理端标成下限值,避免公开链接造成无限数据库增长。聚合统计从迁移 v23 部署后开始,IP 明细从迁移 v24 部署后开始,均不回填历史访问;旧明细的 IP 可为 `null`。完整响应、范围和隐私约束见 [Public Live Share Statistics API v1](docs/public-liveshare-statistics-api-v1.md)。 生产默认启用实例内存限流:公开链接管理按 actor/IP/Session 为 60 次/分钟,并另按 actor/Session 限制为 120 次/分钟;exchange 按 IP 为 30 次/分钟、按 IP+share 为 10 次/分钟;snapshot 与 public WS ticket 分别按 IP+Session 为 30 次/分钟、按 share 为 60 次/分钟。这些限流桶、snapshot 并发计数与实时 hub 都是单进程内状态,生产环境必须保持单 Node.js 进程;多副本部署前需实现共享限流状态和跨实例 pub/sub。 @@ -315,6 +354,7 @@ npm run verify | `ADMIN_BOOTSTRAP_TOKEN` | 无 | 空库必填,至少 24 字节 | | `INVITE_HMAC_KEY` | 无 | 必填,至少 32 字节;不得复用 JWT 密钥 | | `PUBLIC_SHARE_HMAC_KEY` | 无 | 必填,至少 32 字节;独立派生公开链接 secret,不得复用 JWT/邀请密钥 | +| `BAIDU_MAP_AK` | 空 | 可选;启用管理员 Live Share 访客省/市/区县属地推断 | | `JWT_ISSUER` | `openlogtool-server` | JWT issuer | | `ACCESS_TOKEN_TTL_SECONDS` | `900` | Access token 生命周期 | | `REFRESH_TOKEN_TTL_DAYS` | `30` | Refresh token 生命周期 | diff --git a/deploy.sh b/deploy.sh index b2fe8bb..d0c4428 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,15 +3,27 @@ set -euo pipefail # OpenLogTool Server 一键部署脚本 # 用法: bash deploy.sh [server_port] +# 可通过 OPENLOGTOOL_BRANCH=dev 部署其他远端分支;默认部署 main。 PORT="${1:-3000}" PROJECT_DIR="$HOME/OpenLogToolServer" +BRANCH="${OPENLOGTOOL_BRANCH:-main}" if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then echo "端口必须是 1-65535 之间的整数" exit 1 fi +if ! command -v git &>/dev/null; then + echo "请先安装 Git" + exit 1 +fi + +if ! git check-ref-format --branch "$BRANCH" &>/dev/null; then + echo "无效的 Git 分支名: $BRANCH" + exit 1 +fi + echo "=== 1. 检查 Node.js & npm ===" if ! command -v node &>/dev/null; then echo "请先安装 Node.js (>=24.18):" @@ -35,9 +47,23 @@ fi echo "=== 2. 克隆/更新代码 ===" if [ -d "$PROJECT_DIR" ]; then cd "$PROJECT_DIR" - git pull origin rewrite + if [ ! -d .git ]; then + echo "$PROJECT_DIR 已存在但不是 Git 仓库,请手动处理后重试。" + exit 1 + fi + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "仓库存在未提交的已跟踪文件修改;为避免覆盖数据,部署已停止。" + exit 1 + fi + git fetch --prune origin "$BRANCH" + if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git switch "$BRANCH" + else + git switch --track -c "$BRANCH" "origin/$BRANCH" + fi + git pull --ff-only origin "$BRANCH" else - git clone -b rewrite https://github.com/Mazha0309/OpenLogToolServer.git "$PROJECT_DIR" + git clone --branch "$BRANCH" --single-branch https://github.com/Mazha0309/OpenLogToolServer.git "$PROJECT_DIR" cd "$PROJECT_DIR" fi @@ -107,9 +133,7 @@ ensure_secret ADMIN_BOOTSTRAP_TOKEN 24 24 ensure_secret INVITE_HMAC_KEY 32 32 ensure_secret PUBLIC_SHARE_HMAC_KEY 32 32 -if ! grep -q '^PORT=' .env; then - echo "PORT=$PORT" >> .env -fi +write_env_value PORT "$PORT" if ! grep -q '^NODE_ENV=' .env; then echo "NODE_ENV=production" >> .env fi @@ -130,6 +154,7 @@ fi echo "" echo "=== 部署完成 ===" +echo "分支: $BRANCH" echo "服务器: http://localhost:$PORT" echo "管理后台: http://localhost:$PORT/admin" echo "Public Liveshare: http://localhost:$PORT/live/#token=" diff --git a/docker-compose.yml b/docker-compose.yml index 3ad3022..18d6f8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: ADMIN_BOOTSTRAP_TOKEN: ${ADMIN_BOOTSTRAP_TOKEN:-} INVITE_HMAC_KEY: ${INVITE_HMAC_KEY:?INVITE_HMAC_KEY must be set} PUBLIC_SHARE_HMAC_KEY: ${PUBLIC_SHARE_HMAC_KEY:?PUBLIC_SHARE_HMAC_KEY must be set} + BAIDU_MAP_AK: ${BAIDU_MAP_AK:-} JWT_ISSUER: ${JWT_ISSUER:-openlogtool-server} ACCESS_TOKEN_TTL_SECONDS: "${ACCESS_TOKEN_TTL_SECONDS:-900}" REFRESH_TOKEN_TTL_DAYS: "${REFRESH_TOKEN_TTL_DAYS:-30}" diff --git a/docs/public-liveshare-statistics-api-v1.md b/docs/public-liveshare-statistics-api-v1.md index a0fd155..524ca18 100644 --- a/docs/public-liveshare-statistics-api-v1.md +++ b/docs/public-liveshare-statistics-api-v1.md @@ -84,7 +84,7 @@ endpoint returns the same item shape plus at most 200 visitor rows. ~~~json { - "schemaVersion": 2, + "schemaVersion": 3, "generatedAt": "2026-07-22T12:00:00.000Z", "scope": { "currentConnections": "current-process", @@ -121,16 +121,36 @@ endpoint returns the same item shape plus at most 200 visitor rows. "ipAddress": "203.0.113.42", "firstSeenAt": "2026-07-22T10:01:00.000Z", "lastSeenAt": "2026-07-22T11:59:00.000Z", - "currentConnections": 1 + "visitCount": 3, + "currentConnections": 1, + "location": { + "province": "浙江省", + "city": "杭州市", + "district": "萧山区", + "adcode": "330109", + "displayName": "浙江省 杭州市 萧山区", + "source": "baidu-ip" + } } ] } ~~~ -Visitor rows represent anonymous page-lifetime sessions, not accounts or -verified people. Multiple rows may have the same IP, and one person can produce -multiple rows. `firstSeenAt`, `lastSeenAt`, or `ipAddress` may be `null` for -legacy or currently connected rows that have no matching stored detail. +Visitor rows aggregate anonymous page-lifetime sessions by the most recently +observed IP address. `visitCount` is the number of stored page sessions grouped +into the row, while `currentConnections` is the number of live WebSocket +connections currently using that IP. IP grouping does not identify accounts or +verified people: multiple people may share an IP, and one person may use several +IPs. `firstSeenAt`, `lastSeenAt`, or `ipAddress` may be `null` for legacy or +currently connected rows that have no matching stored detail. + +When `BAIDU_MAP_AK` is configured, the server resolves public IPv4 addresses +through Baidu Maps' normal IP geolocation API and caches successful results for +24 hours and failures for 10 minutes. `location` is otherwise `null`, including +for private, reserved, IPv6, failed, or unconfigured lookups. Only the normalized +province, city, district, administrative code, display name, and source are +returned; the API key and provider response stay server-side. IP location is an +inference from a third-party database, not precise positioning. ## Tracking and retention diff --git a/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md b/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md index ffb9bec..5be7558 100644 --- a/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md +++ b/docs/superpowers/specs/2026-07-11-collaboration-v1-design.md @@ -755,9 +755,9 @@ POST /api/v1/admin/session-event-retention/prune 这些接口都要求成员 access token 的 admin claim 与数据库中的当前 admin 角色同时成立,并使用严格 query/body 白名单和 `Cache-Control: no-store`。协作指标及两个 Live Share 统计接口共享每名管理员/IP 30 次/分钟的限流,preview 限 12 次/分钟,prune 限 6 次/分钟;这些桶与其他运行时限流一样只存在当前进程。 -协作指标响应带 `schemaVersion=2`,明确区分从当前进程启动时累计的 runtime counter 与从当前数据库一致读取的 gauge。runtime 固定覆盖进程/系统 CPU 与内存、HTTP surface/结果/累计 `le*` 延迟桶、mutation accepted/conflict/rejected/replay、已提交事件的 REST/成员 WS/公开 WS 投递,以及成员/公开 WebSocket 尝试、拒绝、活动、关闭、非零 cursor 恢复、重同步、撤权和控制帧失败;gauge 固定覆盖 Session/Log/membership 数量、活动 invite/public share、仍可授权的 ticket、持久事件/幂等行和事件保留下界。维度集合固定,禁止把 Session ID、用户 ID、路径参数、IP 或内容作为动态 label,也不返回标题、Log、membership 关联或 secret。进程重启后 runtime counter 从零开始,多实例部署必须由外部系统汇聚。 +协作指标响应带 `schemaVersion=3`,并返回当前服务端软件版本;它明确区分从当前进程启动时累计的 runtime counter 与从当前数据库一致读取的 gauge。runtime 固定覆盖进程/系统 CPU 与内存、HTTP surface/结果/累计 `le*` 延迟桶、mutation accepted/conflict/rejected/replay、已提交事件的 REST/成员 WS/公开 WS 投递,以及成员/公开 WebSocket 尝试、拒绝、活动、关闭、非零 cursor 恢复、重同步、撤权和控制帧失败;gauge 固定覆盖 Session/Log/membership 数量、活动 invite/public share、仍可授权的 ticket、持久事件/幂等行和事件保留下界。维度集合固定,禁止把 Session ID、用户 ID、路径参数、IP 或内容作为动态 label,也不返回标题、Log、membership 关联或 secret。进程重启后 runtime counter 从零开始,多实例部署必须由外部系统汇聚。 -Live Share 列表响应为 `schemaVersion=1`,返回当前进程连接数和当前数据库的有效打开聚合;单分享详情响应为 `schemaVersion=2`,额外返回最多 200 条匿名页面会话的最近可信请求 IP、首末访问时间和当前连接数。页面原始随机 ID 与 User-Agent 不存储;IP 从迁移 v24 起保存且不回填历史数据,受 `TRUST_PROXY` 安全边界影响。完整字段、计数饱和语义和隐私限制以 [Public Live Share Statistics API v1](../../public-liveshare-statistics-api-v1.md) 为准。 +Live Share 列表响应为 `schemaVersion=1`,返回当前进程连接数和当前数据库的有效打开聚合;单分享详情响应为 `schemaVersion=3`,额外按最近可信请求 IP 聚合最多 200 组匿名页面会话,返回访问次数、首末访问时间和当前连接数。页面原始随机 ID 与 User-Agent 不存储;IP 从迁移 v24 起保存且不回填历史数据,受 `TRUST_PROXY` 安全边界影响。完整字段、计数饱和语义和隐私限制以 [Public Live Share Statistics API v1](../../public-liveshare-statistics-api-v1.md) 为准。 preview 在一致读事务中执行与 prune 相同的计划器但零写入;prune 要求符合上述安全标识合同的 `Idempotency-Key`,并在 `BEGIN IMMEDIATE` 中再次确认当前 admin、精确重放或执行裁剪、写入管理审计及保存响应。策略字段与边界如下: diff --git a/package-lock.json b/package-lock.json index 030ae00..bd43710 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openlogtool-server", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openlogtool-server", - "version": "0.7.0", + "version": "0.8.0", "engines": { "node": ">=24.18.0" }, diff --git a/package.json b/package.json index cf1f7ba..9c32300 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openlogtool-server", - "version": "0.7.0", + "version": "0.8.0", "private": true, "engines": { "node": ">=24.18.0" diff --git a/src/api/collaboration-metrics-v1.ts b/src/api/collaboration-metrics-v1.ts index 8f8a484..38958a4 100644 --- a/src/api/collaboration-metrics-v1.ts +++ b/src/api/collaboration-metrics-v1.ts @@ -8,15 +8,20 @@ import { AppError } from '../errors/app-error'; import { createAccessTokenMiddleware, V1AuthRequest } from '../middleware/auth-v1'; import { createMemoryRateLimiter } from '../middleware/rate-limit'; import { getRuntimeMetrics } from '../operations/metrics'; +import { + IpAdministrativeLocation, + IpGeolocationResolver, +} from '../operations/ip-geolocation'; import { getPublicShareAnalytics, listPublicShareAnalytics, - listPublicShareVisitorSessions, + listPublicShareVisitorsByIp, PUBLIC_SHARE_VIEW_SESSION_LIMITS, PublicShareAnalytics, readPublicShareAnalyticsSummary, } from '../operations/public-share-analytics'; import { rejectUnknownKeys } from '../utils/validation'; +import { SERVER_VERSION } from '../server-version'; export interface CollaborationMetricsV1Dependencies { db: Database.Database; @@ -53,7 +58,9 @@ interface PublicLiveshareVisitorDto { ipAddress: string | null; firstSeenAt: string | null; lastSeenAt: string | null; + visitCount: number; currentConnections: number; + location: IpAdministrativeLocation | null; } function currentAdminMiddleware(db: Database.Database): RequestHandler { @@ -115,57 +122,81 @@ function publicLiveshareScope(db: Database.Database) { }; } -function publicLiveshareVisitors( +async function publicLiveshareVisitors( db: Database.Database, publicShareId: string, -) { - const stored = listPublicShareVisitorSessions( + ipGeolocation: IpGeolocationResolver, +): Promise { + const stored = listPublicShareVisitorsByIp( db, publicShareId, PUBLIC_LIVESHARE_VISITOR_LIMIT, ); - const storedByHash = new Map(stored.map((item) => [item.viewSessionHash, item])); + const ipKey = (ipAddress: string | null) => ipAddress ?? '\0unknown'; + const storedByIp = new Map(stored.map((item) => [ipKey(item.ipAddress), item])); const activeGroups = new Map; }>(); for (const connection of getRealtimeHub(db).publicShareConnections(publicShareId)) { - const key = `${connection.viewSessionHash ?? 'untracked'}\0${connection.ipAddress}`; + const key = ipKey(connection.ipAddress); const current = activeGroups.get(key); - if (current) current.currentConnections += 1; - else activeGroups.set(key, { ...connection, currentConnections: 1 }); + if (current) { + current.currentConnections += 1; + if (connection.viewSessionHash) current.viewSessionHashes.add(connection.viewSessionHash); + } else { + activeGroups.set(key, { + ipAddress: connection.ipAddress, + currentConnections: 1, + viewSessionHashes: new Set(connection.viewSessionHash ? [connection.viewSessionHash] : []), + }); + } } - const activeHashes = new Set(); - const visitors: PublicLiveshareVisitorDto[] = [...activeGroups.values()].map((active) => { - const historical = active.viewSessionHash - ? storedByHash.get(active.viewSessionHash) - : undefined; - if (active.viewSessionHash) activeHashes.add(active.viewSessionHash); + const activeIps = new Set(activeGroups.keys()); + const visitors: Array> = [...activeGroups.entries()].map(([key, active]) => { + const historical = storedByIp.get(key); return { ipAddress: active.ipAddress, firstSeenAt: historical?.firstSeenAt ?? null, lastSeenAt: historical?.lastSeenAt ?? null, + visitCount: historical?.visitCount ?? Math.max(1, active.viewSessionHashes.size), currentConnections: active.currentConnections, }; }); for (const historical of stored) { - if (activeHashes.has(historical.viewSessionHash)) continue; + if (activeIps.has(ipKey(historical.ipAddress))) continue; visitors.push({ ipAddress: historical.ipAddress, firstSeenAt: historical.firstSeenAt, lastSeenAt: historical.lastSeenAt, + visitCount: historical.visitCount, currentConnections: 0, }); } - return visitors + const recent = visitors .sort((left, right) => right.currentConnections - left.currentConnections || String(right.lastSeenAt ?? '').localeCompare(String(left.lastSeenAt ?? '')) || String(left.ipAddress ?? '').localeCompare(String(right.ipAddress ?? '')), ) .slice(0, PUBLIC_LIVESHARE_VISITOR_LIMIT); + const resolved = new Array(recent.length); + let cursor = 0; + const worker = async () => { + while (cursor < recent.length) { + const index = cursor; + cursor += 1; + const visitor = recent[index]; + resolved[index] = { + ...visitor, + location: await ipGeolocation.resolve(visitor.ipAddress), + }; + } + }; + await Promise.all(Array.from({ length: Math.min(6, recent.length) }, worker)); + return resolved; } function publicLiveshareItem( @@ -284,6 +315,7 @@ export function createCollaborationMetricsV1Router( const metrics = getRuntimeMetrics(db); const accessToken = createAccessTokenMiddleware(config, db); const currentAdmin = currentAdminMiddleware(db); + const ipGeolocation = new IpGeolocationResolver(config.baiduMapAk); const limiter = createMemoryRateLimiter({ windowMs: 60_000, max: 30, @@ -313,8 +345,9 @@ export function createCollaborationMetricsV1Router( const activePublic = runtime.websockets.active.public; res.setHeader('Cache-Control', 'no-store'); res.json({ - schemaVersion: 2, + schemaVersion: 3, serverInstanceId: gauges.instance_id, + serverVersion: SERVER_VERSION, generatedAt, scope: { runtimeCounters: 'current-process', @@ -440,7 +473,7 @@ export function createCollaborationMetricsV1Router( accessToken, currentAdmin, ...(config.rateLimitEnabled ? [limiter] : []), - (req: V1AuthRequest, res, next) => { + async (req: V1AuthRequest, res, next) => { try { rejectUnknownKeys(req.query as Record, []); const publicShareId = normalizeStableId( @@ -455,11 +488,11 @@ export function createCollaborationMetricsV1Router( const connections = getRealtimeHub(db).publicShareConnectionCounts(); res.setHeader('Cache-Control', 'no-store'); res.json({ - schemaVersion: 2, + schemaVersion: 3, generatedAt, scope: publicLiveshareScope(db), item: publicLiveshareItem(analytics, connections), - visitors: publicLiveshareVisitors(db, publicShareId), + visitors: await publicLiveshareVisitors(db, publicShareId, ipGeolocation), }); } catch (error) { next(error); diff --git a/src/api/server-info.ts b/src/api/server-info.ts index 6dc1818..b99b7a2 100644 --- a/src/api/server-info.ts +++ b/src/api/server-info.ts @@ -4,6 +4,7 @@ import { AppConfig, config } from '../config'; import { getDb } from '../db/database'; import { AppError } from '../errors/app-error'; import { publicShareFeatureAvailable } from '../collaboration/public'; +import { SERVER_VERSION } from '../server-version'; interface ServerInfoDependencies { db?: Database.Database; @@ -62,6 +63,7 @@ export function createServerInfoRouter(dependencies: ServerInfoDependencies = {} ]; res.json({ serverInstanceId: row.instance_id, + serverVersion: SERVER_VERSION, protocolMin: 1, protocolMax: 1, features, diff --git a/src/config.ts b/src/config.ts index 51ed6cc..b4038b2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ export interface AppConfig { bootstrapSecret: string; inviteHmacKey: string; publicShareHmacKey: string; + baiduMapAk?: string; accessTokenTtlSeconds: number; refreshTokenTtlSeconds: number; corsOrigins: string[]; @@ -64,6 +65,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { bootstrapSecret: env.ADMIN_BOOTSTRAP_TOKEN?.trim() || '', inviteHmacKey: env.INVITE_HMAC_KEY?.trim() || '', publicShareHmacKey: env.PUBLIC_SHARE_HMAC_KEY?.trim() || '', + baiduMapAk: env.BAIDU_MAP_AK?.trim() || undefined, accessTokenTtlSeconds: parsePositiveInteger( env.ACCESS_TOKEN_TTL_SECONDS, 15 * 60, diff --git a/src/operations/ip-geolocation.ts b/src/operations/ip-geolocation.ts new file mode 100644 index 0000000..1491244 --- /dev/null +++ b/src/operations/ip-geolocation.ts @@ -0,0 +1,148 @@ +import { isIP } from 'net'; + +const BAIDU_IP_ENDPOINT = 'https://api.map.baidu.com/location/ip'; +const SUCCESS_CACHE_MS = 24 * 60 * 60_000; +const FAILURE_CACHE_MS = 10 * 60_000; +const REQUEST_TIMEOUT_MS = 4_000; +const MAX_CACHE_ENTRIES = 2_000; + +export interface IpAdministrativeLocation { + province: string | null; + city: string | null; + district: string | null; + adcode: string | null; + displayName: string; + source: 'baidu-ip'; +} + +interface CacheEntry { + expiresAt: number; + value: IpAdministrativeLocation | null; +} + +type Fetcher = (input: string, init?: RequestInit) => Promise; + +function normalizedPublicIpv4(value: string): string | null { + const normalized = value.trim().replace(/^::ffff:/i, ''); + if (isIP(normalized) !== 4) return null; + const octets = normalized.split('.').map(Number); + const [a, b] = octets; + const privateOrReserved = + a === 0 || a === 10 || a === 127 || a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && octets[2] === 100) || + (a === 203 && b === 0 && octets[2] === 113); + return privateOrReserved ? null : normalized; +} + +function optionalText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized.length > 0 && normalized.length <= 128 ? normalized : null; +} + +function objectValue(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function parsedLocation(value: unknown): IpAdministrativeLocation | null { + const root = objectValue(value); + if (!root || Number(root.status) !== 0) return null; + const content = objectValue(root.content); + const detail = objectValue(content?.address_detail); + if (!detail) return null; + const province = optionalText(detail.province); + const city = optionalText(detail.city); + const district = optionalText(detail.district); + const parts = [province, city, district].filter((part, index, values): part is string => ( + part !== null && values.indexOf(part) === index + )); + if (parts.length === 0) return null; + const adcodeValue = detail.adcode; + const adcode = typeof adcodeValue === 'number' && Number.isSafeInteger(adcodeValue) + ? String(adcodeValue) + : optionalText(adcodeValue); + return { + province, + city, + district, + adcode, + displayName: parts.join(' '), + source: 'baidu-ip', + }; +} + +export class IpGeolocationResolver { + private readonly cache = new Map(); + private readonly inFlight = new Map>(); + private providerUnavailableUntil = 0; + + constructor( + private readonly baiduMapAk: string | undefined, + private readonly fetcher: Fetcher = fetch, + ) {} + + async resolve(ipAddress: string | null): Promise { + const ip = ipAddress ? normalizedPublicIpv4(ipAddress) : null; + const key = this.baiduMapAk?.trim(); + if (!ip || !key) return null; + const now = Date.now(); + if (this.providerUnavailableUntil > now) return null; + const cached = this.cache.get(ip); + if (cached && cached.expiresAt > now) return cached.value; + if (cached) this.cache.delete(ip); + const pending = this.inFlight.get(ip); + if (pending) return pending; + + const lookup = this.lookup(ip, key) + .then((value) => { + this.remember(ip, value, Date.now()); + return value; + }) + .catch(() => { + const failedAt = Date.now(); + this.providerUnavailableUntil = failedAt + FAILURE_CACHE_MS; + this.remember(ip, null, failedAt); + return null; + }) + .finally(() => this.inFlight.delete(ip)); + this.inFlight.set(ip, lookup); + return lookup; + } + + private async lookup(ip: string, key: string): Promise { + const url = new URL(BAIDU_IP_ENDPOINT); + url.searchParams.set('ip', ip); + url.searchParams.set('coor', 'bd09ll'); + url.searchParams.set('ak', key); + const response = await this.fetcher(url.toString(), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`Baidu IP geolocation returned HTTP ${response.status}`); + const body = await response.json(); + const root = objectValue(body); + if (!root || Number(root.status) !== 0) { + throw new Error('Baidu IP geolocation rejected the request'); + } + return parsedLocation(body); + } + + private remember(ip: string, value: IpAdministrativeLocation | null, now: number): void { + if (!this.cache.has(ip) && this.cache.size >= MAX_CACHE_ENTRIES) { + const oldest = this.cache.keys().next().value as string | undefined; + if (oldest) this.cache.delete(oldest); + } + this.cache.set(ip, { + value, + expiresAt: now + (value ? SUCCESS_CACHE_MS : FAILURE_CACHE_MS), + }); + } +} diff --git a/src/operations/public-share-analytics.ts b/src/operations/public-share-analytics.ts index 130f09d..3513093 100644 --- a/src/operations/public-share-analytics.ts +++ b/src/operations/public-share-analytics.ts @@ -78,11 +78,11 @@ export interface PublicShareViewCleanupResult { failed: boolean; } -export interface PublicShareVisitorSession { - viewSessionHash: string; +export interface PublicShareVisitorIpGroup { ipAddress: string | null; firstSeenAt: string; lastSeenAt: string; + visitCount: number; } interface AnalyticsRow { @@ -387,31 +387,36 @@ export function recordPublicShareOpen( return result; } -export function listPublicShareVisitorSessions( +export function listPublicShareVisitorsByIp( db: Database.Database, publicShareId: string, limit = 200, -): PublicShareVisitorSession[] { +): PublicShareVisitorIpGroup[] { if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) { throw new Error(`Public share visitor limit must be between 1 and ${MAX_LIST_LIMIT}`); } const rows = db.prepare(` - SELECT view_session_hash, last_ip_address, first_seen_at, last_seen_at + SELECT + last_ip_address, + MIN(first_seen_at) AS first_seen_at, + MAX(last_seen_at) AS last_seen_at, + COUNT(*) AS visit_count FROM public_share_view_sessions WHERE public_share_id = ? - ORDER BY last_seen_at DESC, view_session_hash + GROUP BY last_ip_address + ORDER BY last_seen_at DESC, COALESCE(last_ip_address, '') LIMIT ? `).all(publicShareId, limit) as Array<{ - view_session_hash: string; last_ip_address: string | null; first_seen_at: string; last_seen_at: string; + visit_count: number; }>; return rows.map((row) => ({ - viewSessionHash: row.view_session_hash, ipAddress: row.last_ip_address, firstSeenAt: row.first_seen_at, lastSeenAt: row.last_seen_at, + visitCount: Number(row.visit_count), })); } diff --git a/src/server-version.ts b/src/server-version.ts new file mode 100644 index 0000000..60c22a4 --- /dev/null +++ b/src/server-version.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +interface PackageMetadata { + version?: unknown; +} + +function readServerVersion(): string { + const packagePath = join(__dirname, '..', 'package.json'); + const metadata = JSON.parse(readFileSync(packagePath, 'utf8')) as PackageMetadata; + if (typeof metadata.version !== 'string' || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(metadata.version)) { + throw new Error('Server package version is missing or invalid'); + } + return metadata.version; +} + +export const SERVER_VERSION = readServerVersion(); diff --git a/test/collaboration-metrics.test.ts b/test/collaboration-metrics.test.ts index 1a00aad..6fb5252 100644 --- a/test/collaboration-metrics.test.ts +++ b/test/collaboration-metrics.test.ts @@ -624,18 +624,20 @@ describe('collaboration operational metrics API', { concurrency: false }, () => assertNoStore(success); }); - test('returns a bounded v2 schema, fixed surfaces, resource health, basic gauges and no sensitive values', async () => { + test('returns a bounded v3 schema, version, fixed surfaces, resource health, basic gauges and no sensitive values', async () => { const body = await metrics(); exactKeys(body, [ 'schemaVersion', 'serverInstanceId', + 'serverVersion', 'generatedAt', 'scope', 'runtime', 'gauges', ]); - assert.equal(body.schemaVersion, 2); + assert.equal(body.schemaVersion, 3); assert.equal(body.serverInstanceId, serverInstanceId); + assert.equal(body.serverVersion, '0.8.0'); assert.equal(typeof body.generatedAt, 'string'); assert.ok(Number.isFinite(Date.parse(String(body.generatedAt)))); diff --git a/test/config.test.ts b/test/config.test.ts index e63a522..83320f7 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -51,3 +51,8 @@ test('container mode is explicit and validates boolean environment values', () = /Invalid boolean value/, ); }); + +test('Baidu Maps AK is optional and trimmed when configured', () => { + assert.equal(loadConfig({}).baiduMapAk, undefined); + assert.equal(loadConfig({ BAIDU_MAP_AK: ' example-ak ' }).baiduMapAk, 'example-ak'); +}); diff --git a/test/foundation.test.ts b/test/foundation.test.ts index 1d5fe44..9ef38d4 100644 --- a/test/foundation.test.ts +++ b/test/foundation.test.ts @@ -595,6 +595,7 @@ describe('v1 HTTP foundation', { concurrency: false }, () => { assert.equal(first.status, 200, first.text); assertRecord(first.body, 'server-info'); assert.match(String(first.body.serverInstanceId), /^[0-9a-f]{8}-[0-9a-f-]{27}$/i); + assert.equal(first.body.serverVersion, '0.8.0'); assert.equal(first.body.protocolMin, 1); assert.equal(first.body.protocolMax, 1); assert.ok(Array.isArray(first.body.features)); diff --git a/test/ip-geolocation.test.ts b/test/ip-geolocation.test.ts new file mode 100644 index 0000000..6c5d816 --- /dev/null +++ b/test/ip-geolocation.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { IpGeolocationResolver } from '../src/operations/ip-geolocation'; + +test('Baidu IP geolocation returns province, city and district and caches by IP', async () => { + let requests = 0; + const resolver = new IpGeolocationResolver('test-ak', async (input) => { + requests += 1; + const url = new URL(input); + assert.equal(url.origin + url.pathname, 'https://api.map.baidu.com/location/ip'); + assert.equal(url.searchParams.get('ip'), '1.2.3.4'); + assert.equal(url.searchParams.get('ak'), 'test-ak'); + return new Response(JSON.stringify({ + status: 0, + content: { + address_detail: { + province: '浙江省', + city: '杭州市', + district: '萧山区', + adcode: 330109, + }, + }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }); + + const expected = { + province: '浙江省', + city: '杭州市', + district: '萧山区', + adcode: '330109', + displayName: '浙江省 杭州市 萧山区', + source: 'baidu-ip', + }; + assert.deepEqual(await resolver.resolve('1.2.3.4'), expected); + assert.deepEqual(await resolver.resolve('1.2.3.4'), expected); + assert.equal(requests, 1); +}); + +test('IP geolocation skips unconfigured, private, reserved and IPv6 addresses', async () => { + let requests = 0; + const fetcher = async () => { + requests += 1; + return new Response('{}'); + }; + const configured = new IpGeolocationResolver('test-ak', fetcher); + const unconfigured = new IpGeolocationResolver(undefined, fetcher); + + assert.equal(await configured.resolve('127.0.0.1'), null); + assert.equal(await configured.resolve('192.168.1.2'), null); + assert.equal(await configured.resolve('203.0.113.10'), null); + assert.equal(await configured.resolve('2001:db8::1'), null); + assert.equal(await unconfigured.resolve('1.2.3.4'), null); + assert.equal(requests, 0); +}); diff --git a/test/public-liveshare.test.ts b/test/public-liveshare.test.ts index fa26dd6..d78c1a0 100644 --- a/test/public-liveshare.test.ts +++ b/test/public-liveshare.test.ts @@ -1180,7 +1180,7 @@ describe('public Liveshare v1 capability', { concurrency: false }, () => { const detail = success(detailResult); assert.equal(detailResult.headers.get('cache-control'), 'no-store'); exactKeys(detail, ['schemaVersion', 'generatedAt', 'scope', 'item', 'visitors']); - assert.equal(detail.schemaVersion, 2); + assert.equal(detail.schemaVersion, 3); assertObject(detail.scope, 'public Liveshare detail scope'); assertObject(detail.item, 'public Liveshare detail item'); assert.equal(detail.item.publicShareId, created.share.publicShareId); @@ -1188,19 +1188,23 @@ describe('public Liveshare v1 capability', { concurrency: false }, () => { assert.equal(detail.item.currentConnections, 2); assert.equal(detail.item.totalOpens, 2); assert.ok(Array.isArray(detail.visitors)); - assert.equal(detail.visitors.length, 2); + assert.equal(detail.visitors.length, 1); for (const visitor of detail.visitors) { assertObject(visitor, 'public Liveshare visitor'); exactKeys(visitor, [ 'ipAddress', 'firstSeenAt', 'lastSeenAt', + 'visitCount', 'currentConnections', + 'location', ]); assert.equal(typeof visitor.ipAddress, 'string'); assert.equal(typeof visitor.firstSeenAt, 'string'); assert.equal(typeof visitor.lastSeenAt, 'string'); - assert.equal(visitor.currentConnections, 1); + assert.equal(visitor.visitCount, 2); + assert.equal(visitor.currentConnections, 2); + assert.equal(visitor.location, null); } const storedViewSessions = JSON.stringify(db.prepare(` @@ -1235,7 +1239,7 @@ describe('public Liveshare v1 capability', { concurrency: false }, () => { assert.equal(closedDetail.item.currentConnections, 0); assert.equal(closedDetail.item.totalOpens, 2); assert.ok(Array.isArray(closedDetail.visitors)); - assert.equal(closedDetail.visitors.length, 2); + assert.equal(closedDetail.visitors.length, 1); assert.ok(closedDetail.visitors.every((visitor) => ( visitor && typeof visitor === 'object' && Number((visitor as JsonObject).currentConnections) === 0 diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 0c02015..3223937 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -62,7 +62,7 @@ export function AppShell({ admin = false }: { admin?: boolean }) { const navigate = useNavigate(); const [collapsed, setCollapsed] = useState(() => localStorage.getItem('olt.web.sidebar-collapsed') === 'true'); const [drawerOpen, setDrawerOpen] = useState(false); - const width = collapsed ? 72 : 236; + const width = collapsed ? 72 : 256; const items = useMemo(() => admin ? [ { key: '/admin', icon: , label: t('nav.adminOverview') }, @@ -85,22 +85,27 @@ export function AppShell({ admin = false }: { admin?: boolean }) { const selected = location.pathname === '/admin' || location.pathname === '/app' ? location.pathname : [...(items ?? [])].filter((item): item is Exclude[number], null> & { key: string } => Boolean(item && 'key' in item && typeof item.key === 'string')) .map((item) => item.key).filter((key) => location.pathname.startsWith(key)).sort((a, b) => b.length - a.length)[0]; - const menu = { navigate(key); setDrawerOpen(false); }} />; + const selectMenuItem: MenuProps['onClick'] = ({ key }) => { + navigate(key); + setDrawerOpen(false); + }; + const desktopMenu = ; + const mobileMenu = ; const toggle = () => setCollapsed((value) => { localStorage.setItem('olt.web.sidebar-collapsed', String(!value)); return !value; }); return ( - + - {menu} + {desktopMenu}
- setDrawerOpen(false)} styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}> - {menu} + setDrawerOpen(false)} styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}> + {mobileMenu} diff --git a/web/src/components/PersonalSnapshotViewer.tsx b/web/src/components/PersonalSnapshotViewer.tsx index c4e44c5..a7152c4 100644 --- a/web/src/components/PersonalSnapshotViewer.tsx +++ b/web/src/components/PersonalSnapshotViewer.tsx @@ -86,10 +86,10 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExpor {t('personalCloud.snapshotSessions')}} value={sessionQuery} onChange={(event) => setSessionQuery(event.target.value)} placeholder={t('personalCloud.searchSessions')} style={{ width: 280, maxWidth: '100%' }} />} + title={
{t('personalCloud.snapshotSessions')}} value={sessionQuery} onChange={(event) => setSessionQuery(event.target.value)} placeholder={t('personalCloud.searchSessions')} />
} style={{ marginBottom: 18 }} > - + {t('personalCloud.exportDatabaseV7Hint')} {visibleSessions.length === 0 ?
: @@ -109,7 +109,7 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExpor
{t('personalCloud.logDetails')}
{selectedSession && {selectedSession.title}}
} value={logQuery} onChange={(event) => setLogQuery(event.target.value)} placeholder={t('personalCloud.searchLogs')} style={{ width: 280, maxWidth: '100%' }} />} + title={
{t('personalCloud.logDetails')}
{selectedSession && {selectedSession.title}}
} value={logQuery} onChange={(event) => setLogQuery(event.target.value)} placeholder={t('personalCloud.searchLogs')} />
} > {!selectedSession ?
: rowKey="sync_id" @@ -127,7 +127,7 @@ export function PersonalSnapshotViewer({ owner, personalSnapshot, admin, onExpor { title: t('logs.power'), dataIndex: 'power', width: 100, render: valueOrDash }, { title: t('logs.antenna'), dataIndex: 'antenna', width: 160, render: valueOrDash }, { title: t('logs.height'), dataIndex: 'height', width: 100, render: valueOrDash }, - { title: t('logs.remarks'), dataIndex: 'remarks', width: 220, render: valueOrDash }, + { title: t('logs.remarks'), dataIndex: 'remarks', width: 220, className: 'table-wrap-cell', render: valueOrDash }, ]} />}
diff --git a/web/src/i18n.ts b/web/src/i18n.ts index e9e76c7..fba5706 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -228,6 +228,7 @@ const zh = { 'account.current': '当前设备', 'admin.overview': '服务器概览', 'admin.instance': '实例 ID', + 'admin.serverVersion': '服务器版本', 'admin.users': '用户总数', 'admin.admins': '管理员', 'admin.sessions': '协作会话总数', @@ -316,9 +317,12 @@ const zh = { 'admin.visitorViewingNow': '正在访问({count} 个连接)', 'admin.visitorDisconnected': '已离开', 'admin.visitorIpAddress': 'IP 地址', + 'admin.visitorLocation': 'IP 属地', + 'admin.visitorLocationUnavailable': '未解析', + 'admin.visitorVisitCount': '访问次数', 'admin.visitorFirstSeenAt': '首次访问', 'admin.visitorLastSeenAt': '最近访问', - 'admin.visitorIpTrustProxyHint': '最多显示最近 {count} 条匿名页面会话;IP 地址依据服务器 TRUST_PROXY 配置识别,正在访问状态每 10 秒刷新。', + 'admin.visitorIpTrustProxyHint': '相同 IP 的匿名页面会话会合并显示,最多显示最近 {count} 个 IP;访问次数仍按页面会话计算。IP 地址依据服务器 TRUST_PROXY 配置识别;配置 BAIDU_MAP_AK 后显示省、市、区县级推断属地。IP 属地并非精确定位,正在访问状态每 10 秒刷新。', 'admin.liveShareState.active': '有效', 'admin.liveShareState.expired': '已过期', 'admin.liveShareState.revoked': '已撤销', @@ -491,6 +495,7 @@ const en: Record = { 'account.device': 'Device ID', 'account.lastUsed': 'Last used', 'account.expires': 'Expires', 'account.neverExpires': 'Never expires', 'account.current': 'Current device', 'admin.overview': 'Server overview', 'admin.instance': 'Instance ID', + 'admin.serverVersion': 'Server version', 'admin.users': 'Users', 'admin.admins': 'Administrators', 'admin.sessions': 'Collaboration sessions', 'admin.userManagement': 'User management', 'admin.changeRole': 'Change role', 'admin.revokeTokens': 'Sign out all devices', 'admin.loginExpiration': 'Sign-in expiration', @@ -571,9 +576,12 @@ const en: Record = { 'admin.visitorViewingNow': 'Viewing now ({count} connection(s))', 'admin.visitorDisconnected': 'Disconnected', 'admin.visitorIpAddress': 'IP address', + 'admin.visitorLocation': 'IP location', + 'admin.visitorLocationUnavailable': 'Unavailable', + 'admin.visitorVisitCount': 'Visits', 'admin.visitorFirstSeenAt': 'First seen', 'admin.visitorLastSeenAt': 'Last seen', - 'admin.visitorIpTrustProxyHint': 'Shows up to {count} recent anonymous page sessions. IP detection follows the server TRUST_PROXY setting, and active status refreshes every 10 seconds.', + 'admin.visitorIpTrustProxyHint': 'Anonymous page sessions from the same IP are grouped, with up to {count} recent IPs shown. Visits are still counted by page session. IP detection follows TRUST_PROXY; configure BAIDU_MAP_AK to show inferred province, city, and district. IP geolocation is not precise positioning. Active status refreshes every 10 seconds.', 'admin.liveShareState.active': 'Active', 'admin.liveShareState.expired': 'Expired', 'admin.liveShareState.revoked': 'Revoked', diff --git a/web/src/pages/admin/AdminAuditPage.tsx b/web/src/pages/admin/AdminAuditPage.tsx index 042494f..01c068e 100644 --- a/web/src/pages/admin/AdminAuditPage.tsx +++ b/web/src/pages/admin/AdminAuditPage.tsx @@ -16,7 +16,7 @@ export default function AdminAuditPage() { const [pageSize, setPageSize] = useState(50); const state = useAsync(() => adminApi.audit({ page, pageSize, action: action || undefined }), [page, pageSize, action]); return <>} onClick={state.reload}>{t('common.refresh')}} /> - } placeholder={t('audit.action')} value={input} onChange={(event) => setInput(event.target.value)} onSearch={() => setAction(input.trim())} style={{ maxWidth: 360 }} />}> + } placeholder={t('audit.action')} value={input} onChange={(event) => setInput(event.target.value)} onSearch={() => setAction(input.trim())} />}> rowKey="auditEventId" dataSource={state.data?.items ?? []} pagination={{ current: page, pageSize, total: state.data?.total, showSizeChanger: true, onChange: (next, size) => { setPage(next); setPageSize(size); } }} scroll={{ x: 1050 }} expandable={{ expandedRowRender: (row) =>
{JSON.stringify({ before: row.before, after: row.after, details: row.details, reason: row.reason, requestId: row.requestId, mutationId: row.mutationId }, null, 2)}
}} columns={[ { title: t('common.time'), dataIndex: 'occurredAt', width: 190, render: (value: string) => new Date(value).toLocaleString(locale) }, diff --git a/web/src/pages/admin/AdminPersonalSnapshotsPage.tsx b/web/src/pages/admin/AdminPersonalSnapshotsPage.tsx index aacab0b..621c3e8 100644 --- a/web/src/pages/admin/AdminPersonalSnapshotsPage.tsx +++ b/web/src/pages/admin/AdminPersonalSnapshotsPage.tsx @@ -43,13 +43,13 @@ export default function AdminPersonalSnapshotsPage() { const state = dataset === 'records' ? recordsState : dictionariesState; const search = } value={input} onChange={(event) => setInput(event.target.value)} onSearch={() => setQuery(input.trim())} placeholder={t('personalCloud.searchAccounts')} - style={{ width: 320, maxWidth: '100%' }} />; return <> diff --git a/web/src/pages/admin/AdminSessionDetailPage.tsx b/web/src/pages/admin/AdminSessionDetailPage.tsx index 3426430..60f2677 100644 --- a/web/src/pages/admin/AdminSessionDetailPage.tsx +++ b/web/src/pages/admin/AdminSessionDetailPage.tsx @@ -84,7 +84,7 @@ function AdminLogs({ sessionId, accessId, sessionDeleted, danger }: { sessionId: await adminApi.updateLog(sessionId, editing!, patch); messageApi.success(t('logs.saved')); setEditing(null); state.reload(); }; - return <>{contextHolder}{sessionDeleted && } { setPage(1); setQuery(value.trim()); }} style={{ width: 260 }} /> { setPage(1); setIncludeDeleted(event.target.checked); }}>{t('logs.includeDeleted')}{!sessionDeleted && }} extra={}> + return <>{contextHolder}{sessionDeleted && } { setPage(1); setQuery(value.trim()); }} /> { setPage(1); setIncludeDeleted(event.target.checked); }}>{t('logs.includeDeleted')}{!sessionDeleted && }} extra={}> rowKey="syncId" dataSource={state.data?.items ?? []} size="middle" scroll={{ x: 1320 }} pagination={{ current: page, pageSize, total: state.data?.total, showSizeChanger: true, onChange: (next, size) => { setPage(next); setPageSize(size); } }} columns={[ { title: t('common.time'), dataIndex: 'time', fixed: 'left', width: 180, render: (value: string) => new Date(value).toLocaleString(locale) }, diff --git a/web/src/pages/admin/AdminSessionsPage.tsx b/web/src/pages/admin/AdminSessionsPage.tsx index d30e18c..e7739ea 100644 --- a/web/src/pages/admin/AdminSessionsPage.tsx +++ b/web/src/pages/admin/AdminSessionsPage.tsx @@ -21,7 +21,7 @@ export default function AdminSessionsPage() { const state = useAsync(() => adminApi.sessions({ page, pageSize, q: query || undefined, status, includeDeleted }), [page, pageSize, query, status, includeDeleted]); useEffect(() => setPage(1), [query, status, includeDeleted]); return <>} onClick={state.reload}>{t('common.refresh')}} /> - } value={input} onChange={(event) => setInput(event.target.value)} onSearch={() => setQuery(input.trim())} placeholder={t('common.search')} style={{ width: 280 }} /> ({ value, label: t(`session.${value}`) }))} /> setIncludeDeleted(event.target.checked)}>{t('logs.includeDeleted')}}> rowKey="sessionId" dataSource={state.data?.items ?? []} scroll={{ x: 920 }} pagination={{ current: page, pageSize, total: state.data?.total, showSizeChanger: true, onChange: (next, size) => { setPage(next); setPageSize(size); } }} columns={[ { title: t('sessions.session'), dataIndex: 'title', render: (value: string, row) =>
{value}
{row.sessionId}
}, diff --git a/web/src/pages/admin/OperationsPage.tsx b/web/src/pages/admin/OperationsPage.tsx index bdf2843..a70bcc2 100644 --- a/web/src/pages/admin/OperationsPage.tsx +++ b/web/src/pages/admin/OperationsPage.tsx @@ -226,11 +226,16 @@ export default function OperationsPage() { {t('admin.metricsUpdatedAt', { time: formatTimestamp(metrics.generatedAt, locale) })} } } - actions={} + actions={ + {metrics && + {t('admin.serverVersion')}: v{metrics.serverVersion} + } + + } /> {dashboard.error && snapshot && v{metrics.serverVersion} }, + { key: 'instance', label: t('admin.instance'), span: 2, children: {metrics.serverInstanceId} }, { key: 'cpu-one', label: t('admin.oneCoreUsage'), children: formatPercent(metrics.runtime.process.cpu.percentOfOneCore) }, { key: 'cpu-machine', label: t('admin.machineCapacityUsage'), children: formatPercent(metrics.runtime.process.cpu.percentOfMachineCapacity) }, { key: 'rss', label: t('admin.rssMemory'), children: formatBytes(metrics.runtime.process.memoryBytes.rss, locale) }, diff --git a/web/src/pages/admin/PublicLiveshareDetailPage.tsx b/web/src/pages/admin/PublicLiveshareDetailPage.tsx index fc17344..66f46a8 100644 --- a/web/src/pages/admin/PublicLiveshareDetailPage.tsx +++ b/web/src/pages/admin/PublicLiveshareDetailPage.tsx @@ -179,8 +179,6 @@ export default function PublicLiveshareDetailPage() { rowKey={(visitor, index) => [ visitor.ipAddress ?? 'unknown', - visitor.firstSeenAt ?? 'active', - visitor.lastSeenAt ?? 'active', index ?? 0, ].join(':')} size="small" @@ -190,7 +188,7 @@ export default function PublicLiveshareDetailPage() { rowClassName={(visitor) => visitor.currentConnections > 0 ? 'liveshare-active-visitor-row' : ''} - scroll={{ x: 720 }} + scroll={{ x: 1_060 }} columns={[ { title: t('admin.visitorStatus'), @@ -210,6 +208,21 @@ export default function PublicLiveshareDetailPage() { ? {value} : , }, + { + title: t('admin.visitorLocation'), + dataIndex: 'location', + width: 230, + render: (value: PublicLiveshareVisitor['location']) => value + ? {value.displayName} + : {t('admin.visitorLocationUnavailable')}, + }, + { + title: t('admin.visitorVisitCount'), + dataIndex: 'visitCount', + width: 110, + align: 'right', + render: (value: number) => finite(value).toLocaleString(locale), + }, { title: t('admin.visitorFirstSeenAt'), dataIndex: 'firstSeenAt', diff --git a/web/src/pages/admin/UsersPage.tsx b/web/src/pages/admin/UsersPage.tsx index 6e829f5..8ba8450 100644 --- a/web/src/pages/admin/UsersPage.tsx +++ b/web/src/pages/admin/UsersPage.tsx @@ -114,7 +114,7 @@ export default function UsersPage() { } finally { setWorking(false); } }; return <>{contextHolder}} onClick={state.reload}>{t('common.refresh')}} /> - } placeholder={t('common.search')} value={input} onChange={(event) => setInput(event.target.value)} onSearch={() => setQuery(input.trim())} style={{ width: 280 }} />}> rowKey="id" dataSource={state.data?.items ?? []} scroll={{ x: 960 }} pagination={{ current: page, pageSize, total: state.data?.total, showSizeChanger: true, onChange: (next, size) => { setPage(next); setPageSize(size); } }} columns={[ { title: t('auth.username'), dataIndex: 'username', render: (value: string, row) =>
{value}{row.id === current?.id && {t('account.current')}}
{row.id}
}, diff --git a/web/src/pages/app/SessionDetailPage.tsx b/web/src/pages/app/SessionDetailPage.tsx index 5f36eef..32392cb 100644 --- a/web/src/pages/app/SessionDetailPage.tsx +++ b/web/src/pages/app/SessionDetailPage.tsx @@ -133,7 +133,7 @@ function LogsTab({ session }: { session: SessionSummary }) { <> {contextHolder} - { setPage(1); setSearch(value.trim()); }} style={{ width: 260 }} /> { setPage(1); setIncludeDeleted(event.target.checked); }}>{t('logs.includeDeleted')}} extra={}> + { setPage(1); setSearch(value.trim()); }} /> { setPage(1); setIncludeDeleted(event.target.checked); }}>{t('logs.includeDeleted')}} extra={}> rowKey="syncId" dataSource={state.data?.items ?? []} columns={columns} size="middle" scroll={{ x: 1430 }} pagination={{ current: page, pageSize, total: state.data?.total, showSizeChanger: true, showTotal: (total) => t('sessions.logCount', { count: total }), onChange: (next, size) => { setPage(next); setPageSize(size); } }} rowClassName={(row) => row.deletedAt ? 'ant-table-row-disabled' : ''} /> diff --git a/web/src/pages/app/SessionsPage.tsx b/web/src/pages/app/SessionsPage.tsx index 98d2baa..6e7d91f 100644 --- a/web/src/pages/app/SessionsPage.tsx +++ b/web/src/pages/app/SessionsPage.tsx @@ -29,7 +29,7 @@ export default function SessionsPage() { return ( <> } onClick={state.reload}>{t('common.refresh')}} /> - } placeholder={t('common.search')} value={input} onChange={(event) => setInput(event.target.value)} onSearch={() => { setPage(1); setQuery(input.trim()); }} style={{ maxWidth: 360 }} />}> + } placeholder={t('common.search')} value={input} onChange={(event) => setInput(event.target.value)} onSearch={() => { setPage(1); setQuery(input.trim()); }} />}> rowKey="sessionId" columns={columns} dataSource={items} pagination={{ current: page, pageSize, total: state.data?.total, showSizeChanger: true, onChange: (next, size) => { setPage(next); setPageSize(size); } }} scroll={{ x: 850 }} /> diff --git a/web/src/styles.css b/web/src/styles.css index 88bc3b2..ed6be95 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -47,16 +47,25 @@ button, input, textarea, select { font: inherit; } .user-chip-name { max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } .user-chip-role { color: var(--app-muted); font-size: 11px; margin-top: 3px; } .page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; } +.page-header > :first-child { min-width: 0; } .page-header h1 { margin: 0 !important; font-size: 24px !important; } -.page-header-description { color: var(--app-muted); margin-top: 6px; } -.page-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.page-header-description { max-width: 920px; color: var(--app-muted); margin-top: 6px; overflow-wrap: anywhere; } +.page-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } .stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } .content-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .surface { border-color: var(--app-border) !important; box-shadow: 0 1px 2px rgb(0 0 0 / 3%); } +.surface > .ant-card-head .ant-card-head-title { min-width: 0; white-space: normal; } +.surface .ant-descriptions-item-content { min-width: 0; overflow-wrap: anywhere; } .table-card .ant-card-body { padding: 0; } .table-card .ant-table-wrapper { overflow: hidden; } .table-card .ant-table-cell { white-space: nowrap; } -.table-card .ant-table-cell:last-child { white-space: normal; } +.table-card .ant-table-cell.table-wrap-cell { white-space: normal; } +.table-card > .ant-card-head .ant-card-head-wrapper { gap: 12px; } +.table-card > .ant-card-head .ant-card-head-title { overflow: visible; } +.table-card > .ant-card-body > .ant-table-wrapper .ant-pagination { padding-inline: 20px; } +.table-toolbar { display: flex; align-items: center; gap: 8px; max-width: 100%; flex-wrap: wrap; } +.table-toolbar-search { width: 280px; max-width: 100%; } +.table-card-intro { margin: 0 !important; padding: 16px 24px; border-bottom: 1px solid var(--app-border); } .empty-state { min-height: 220px; display: grid; place-items: center; padding: 32px; } .error-state { min-height: 220px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; padding: 32px; text-align: center; } .error-code { color: var(--app-muted); font-family: ui-monospace, monospace; font-size: 12px; } @@ -102,10 +111,12 @@ button, input, textarea, select { font: inherit; } .liveshare-visitor-table { margin: 16px 0; } .liveshare-visitor-table .ant-card-extra { max-width: 48%; text-align: end; } .liveshare-active-visitor-row > .ant-table-cell { background: color-mix(in srgb, #52c41a 7%, transparent); } -.liveshare-visitor-hint { margin-top: 12px; } +.liveshare-visitor-hint { margin-top: 0; padding: 12px 16px 14px; border-top: 1px solid var(--app-border); } .identifier-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; word-break: break-all; } +.server-version-tag { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .operations-section-title { margin: 26px 0 14px !important; font-size: 19px !important; } .mobile-only { display: none !important; } +.ant-drawer-content-wrapper { max-width: 100vw; } @media (max-width: 900px) { .app-sider { display: none; } @@ -118,7 +129,9 @@ button, input, textarea, select { font: inherit; } .content-grid { grid-template-columns: 1fr; } .auth-page { grid-template-columns: 1fr; } .auth-aside { display: none; } + .auth-panel { padding-top: 76px; } .page-header { align-items: stretch; flex-direction: column; } + .page-actions { justify-content: flex-start; } .personal-dictionary-toolbar { grid-template-columns: 1fr 1fr; } .personal-dictionary-toolbar > :first-child { grid-column: 1 / -1; } .operations-liveshare-table .ant-card-extra { max-width: 55%; font-size: 12px; } @@ -131,8 +144,18 @@ button, input, textarea, select { font: inherit; } .shell-content { padding: 12px; } .user-chip-copy { display: none; } .auth-panel { padding: 18px; } + .auth-panel { padding-top: 72px; } + .auth-top-actions { inset-block-start: 12px; inset-inline: 12px; justify-content: flex-end; } + .page-header h1 { font-size: 22px !important; } .personal-dictionary-toolbar { grid-template-columns: 1fr; } .personal-dictionary-toolbar > :first-child { grid-column: auto; } + .table-card > .ant-card-head .ant-card-head-wrapper { align-items: stretch; flex-direction: column; padding-block: 12px; } + .table-card > .ant-card-head .ant-card-extra { width: 100%; margin-inline-start: 0; } + .table-toolbar { align-items: stretch; display: grid; grid-template-columns: 1fr; width: 100%; } + .table-toolbar-search { width: 100% !important; } + .table-toolbar .ant-select { width: 100% !important; } + .table-card-intro { padding: 14px 16px; } + .table-card > .ant-card-body > .ant-table-wrapper .ant-pagination { padding-inline: 12px; } .operations-liveshare-table .ant-card-head { align-items: flex-start; } .operations-liveshare-table .ant-card-head-wrapper { align-items: flex-start; flex-direction: column; padding-block: 12px; } .operations-liveshare-table .ant-card-extra { max-width: 100%; margin-inline-start: 0; text-align: start; } diff --git a/web/src/types.ts b/web/src/types.ts index 2953c06..eb9bd72 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -164,6 +164,7 @@ export interface HttpSurfaceMetric { export interface CollaborationMetrics { schemaVersion: number; serverInstanceId: string; + serverVersion: string; generatedAt: string; scope: { runtimeCounters: string; @@ -307,11 +308,21 @@ export interface PublicLiveshareVisitor { ipAddress: string | null; firstSeenAt: string | null; lastSeenAt: string | null; + visitCount: number; currentConnections: number; + location: { + province: string | null; + city: string | null; + district: string | null; + adcode: string | null; + displayName: string; + source: 'baidu-ip'; + } | null; } export interface ServerInfo { serverInstanceId?: string; + serverVersion?: string; version?: string; protocolVersion?: number; registrationEnabled?: boolean; From ff0c83d895e57ba39e1dc22293335d42000c800d Mon Sep 17 00:00:00 2001 From: Mazha0309 Date: Wed, 22 Jul 2026 20:51:44 +0800 Subject: [PATCH 5/6] docs: add curl deployment command --- README.md | 11 +++++++---- deploy.sh | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b693911..23ac1a1 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,15 @@ Compose 默认只把服务发布到宿主机 `http://127.0.0.1:3000`,容器内 `./data/openlogtool.db`,随后依次安装依赖、构建后端与两个 Web 前端并重启服务: ~~~bash -curl -fsSLO https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy.sh -bash deploy.sh 3000 +curl --proto '=https' --tlsv1.2 -fsSL \ + https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy.sh \ + | bash -s -- 3000 ~~~ -脚本优先使用已经安装的 PM2;没有 PM2 时以 `nohup` 启动并写入 `server.log`。需要临时 -测试其他远端分支时可显式设置,例如 `OPENLOGTOOL_BRANCH=dev bash deploy.sh 3000`。 +这条命令要求目标机器已经安装 Git、Node.js 24.18 或更高版本和 npm;缺少依赖时脚本会 +明确退出,不会擅自修改系统软件源或安装系统级软件。若机器已安装 PM2,脚本会通过 PM2 +启动;否则使用 `nohup` 并将日志写入 `server.log`。需要临时测试其他远端分支时可显式 +设置,例如 `OPENLOGTOOL_BRANCH=dev bash deploy.sh 3000`。 仓库存在未提交的已跟踪文件修改或无法快进时,脚本会停止,不会强制覆盖本地改动。 ### 更新已有部署 diff --git a/deploy.sh b/deploy.sh index d0c4428..e0c9ea2 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,6 +3,7 @@ set -euo pipefail # OpenLogTool Server 一键部署脚本 # 用法: bash deploy.sh [server_port] +# 或: curl -fsSL https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy.sh | bash -s -- [server_port] # 可通过 OPENLOGTOOL_BRANCH=dev 部署其他远端分支;默认部署 main。 PORT="${1:-3000}" From 497e72b273669768b1fa3494e7821bf63e6d26b1 Mon Sep 17 00:00:00 2001 From: Mazha0309 Date: Wed, 22 Jul 2026 20:59:22 +0800 Subject: [PATCH 6/6] feat: add Docker Compose deployment script --- README.md | 23 +++++- deploy-docker.sh | 180 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100755 deploy-docker.sh diff --git a/README.md b/README.md index 23ac1a1..673e0ea 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ PUBLIC_SHARE_HMAC_KEY=<至少 32 字节的独立随机值> ~~~bash mkdir -p data +sudo chown -R 1000:1000 data # 容器使用 node 用户(UID/GID 1000) chmod 700 data docker compose up -d --build docker compose ps @@ -72,6 +73,24 @@ Compose 默认只把服务发布到宿主机 `http://127.0.0.1:3000`,容器内 `0.0.0.0:3000`;SQLite 位于 `./data/openlogtool.db`。需要从其他机器访问时,优先在 本机部署 HTTPS 反向代理;确需直接发布时再修改 `.env` 中的 `BIND_ADDRESS`。 +### Docker Compose 一键部署 + +目标机器已安装 Git、Docker 与 Docker Compose v2 时,可以不安装宿主机 Node.js,直接 +执行: + +~~~bash +curl --proto '=https' --tlsv1.2 -fsSL \ + https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy-docker.sh \ + | bash -s -- 3000 +~~~ + +[`deploy-docker.sh`](deploy-docker.sh) 会从 `main` 克隆或安全快进代码,生成并保留四项独立 +密钥、构建镜像、重建容器并等待健康检查。已有数据库会在镜像成功构建后停服,并备份到 +`~/OpenLogToolServer-backups/<时间>/`;脚本不会强制覆盖本地代码或静默轮换已有密钥。 +可通过 `OPENLOGTOOL_BRANCH=dev` 测试其他分支,通过 `OPENLOGTOOL_PROJECT_DIR` 和 +`OPENLOGTOOL_BACKUP_DIR` 修改代码及备份目录。更新时省略端口参数会保留 `.env` 中已有的 +`HOST_PORT`。 + ### 一键部署脚本(原生 Node.js) `deploy.sh` 默认从远端 `main` 分支安装或快进更新代码,保留已有 `.env` 和 @@ -92,7 +111,9 @@ curl --proto '=https' --tlsv1.2 -fsSL \ ### 更新已有部署 更新前不要删除或重新生成 `.env` 中现有的密钥,否则既有登录、邀请或 Live Share -链接可能失效。Docker 部署建议先完成镜像构建,再短暂停服并备份 SQLite,最后重建容器: +链接可能失效。使用上述 Docker Compose 一键脚本时,重新执行同一条 `curl` 命令即可安全 +快进、备份和重建。手动维护的 Docker 部署建议先完成镜像构建,再短暂停服并备份 SQLite, +最后重建容器: ~~~bash cd ~/OpenLogToolServer diff --git a/deploy-docker.sh b/deploy-docker.sh new file mode 100755 index 0000000..728884c --- /dev/null +++ b/deploy-docker.sh @@ -0,0 +1,180 @@ +#!/bin/bash +set -euo pipefail + +# OpenLogTool Server Docker Compose 一键部署脚本 +# 用法: bash deploy-docker.sh [host_port] +# 或: curl -fsSL https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy-docker.sh | bash -s -- [host_port] +# 可通过 OPENLOGTOOL_BRANCH=dev 部署其他远端分支;默认部署 main。 + +REQUESTED_HOST_PORT="${1:-}" +HOST_PORT="${REQUESTED_HOST_PORT:-3000}" +PROJECT_DIR="${OPENLOGTOOL_PROJECT_DIR:-$HOME/OpenLogToolServer}" +BACKUP_ROOT="${OPENLOGTOOL_BACKUP_DIR:-$HOME/OpenLogToolServer-backups}" +BRANCH="${OPENLOGTOOL_BRANCH:-main}" + +fail() { + echo "错误: $*" >&2 + exit 1 +} + +validate_port() { + local value="$1" + [[ "$value" =~ ^[0-9]+$ ]] && [ "$value" -ge 1 ] && [ "$value" -le 65535 ] \ + || fail "端口必须是 1-65535 之间的整数" +} + +validate_port "$HOST_PORT" + +for command_name in git docker; do + command -v "$command_name" &>/dev/null || fail "请先安装 ${command_name}" +done +docker compose version &>/dev/null || fail "需要 Docker Compose v2(docker compose)" +docker info &>/dev/null || fail "无法连接 Docker daemon,请启动 Docker 并确认当前用户有权限访问" +git check-ref-format --branch "$BRANCH" &>/dev/null || fail "无效的 Git 分支名: $BRANCH" + +echo "=== 1. 克隆/更新代码 ===" +if [ -e "$PROJECT_DIR" ]; then + [ -d "$PROJECT_DIR/.git" ] || fail "$PROJECT_DIR 已存在但不是 Git 仓库,请手动处理后重试" + cd "$PROJECT_DIR" + [ -z "$(git status --porcelain --untracked-files=no)" ] || fail "仓库存在未提交的已跟踪文件修改,部署已停止" + git fetch --prune origin "$BRANCH" + if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git switch "$BRANCH" + else + git switch --track -c "$BRANCH" "origin/$BRANCH" + fi + git pull --ff-only origin "$BRANCH" +else + git clone --branch "$BRANCH" --single-branch \ + https://github.com/Mazha0309/OpenLogToolServer.git "$PROJECT_DIR" + cd "$PROJECT_DIR" +fi + +echo "=== 2. 配置环境与独立密钥 ===" +umask 077 +if [ ! -f .env ]; then + cp .env.example .env +fi + +read_env_value() { + local value + value="$(awk -v key="$1" ' + { + line = $0 + sub(/^[[:space:]]*/, "", line) + if (index(line, key) != 1) next + rest = substr(line, length(key) + 1) + if (rest !~ /^[[:space:]]*=/) next + sub(/^[[:space:]]*=[[:space:]]*/, "", rest) + result = rest + } + END { printf "%s", result } + ' .env)" + value="${value%$'\r'}" + if [[ ${#value} -ge 2 && "$value" == \"*\" ]]; then + value="${value:1:${#value}-2}" + elif [[ ${#value} -ge 2 && "$value" == \'*\' ]]; then + value="${value:1:${#value}-2}" + fi + printf '%s' "$value" +} + +write_env_value() { + local name="$1" + local value="$2" + if grep -Eq "^[[:space:]]*${name}[[:space:]]*=" .env; then + sed -i -E "s|^[[:space:]]*${name}[[:space:]]*=.*$|${name}=${value}|" .env + else + printf '%s=%s\n' "$name" "$value" >> .env + fi +} + +ensure_secret() { + local name="$1" + local minimum_bytes="$2" + local generated_bytes="$3" + local value + local actual_bytes + + value="$(read_env_value "$name")" + if [ -z "$value" ]; then + value="$(od -An -N "$generated_bytes" -tx1 /dev/urandom | tr -d '[:space:]')" + write_env_value "$name" "$value" + echo "已生成 ${name}" + return + fi + + actual_bytes="$(LC_ALL=C printf '%s' "$value" | wc -c | tr -d '[:space:]')" + if [ "$actual_bytes" -lt "$minimum_bytes" ]; then + fail "${name} 已存在但只有 ${actual_bytes} 字节,至少需要 ${minimum_bytes} 字节;为避免静默轮换,部署已停止" + fi +} + +ensure_secret JWT_SECRET 32 32 +ensure_secret ADMIN_BOOTSTRAP_TOKEN 24 24 +ensure_secret INVITE_HMAC_KEY 32 32 +ensure_secret PUBLIC_SHARE_HMAC_KEY 32 32 +if [ -z "$REQUESTED_HOST_PORT" ]; then + configured_host_port="$(read_env_value HOST_PORT)" + if [ -n "$configured_host_port" ]; then + validate_port "$configured_host_port" + HOST_PORT="$configured_host_port" + fi +fi +write_env_value HOST_PORT "$HOST_PORT" +chmod 600 .env + +mkdir -p data +if ! chown -R 1000:1000 data 2>/dev/null; then + mismatched_owner="$(find data \( ! -uid 1000 -o ! -gid 1000 \) -print -quit)" + [ -z "$mismatched_owner" ] || fail "data 目录必须可由容器内 UID/GID 1000:1000 写入;请执行 sudo chown -R 1000:1000 '$PROJECT_DIR/data'" +fi +chmod 700 data + +echo "=== 3. 构建 Docker 镜像 ===" +docker compose build server + +if [ -f data/openlogtool.db ]; then + echo "=== 4. 停服并备份 SQLite ===" + backup_dir="$BACKUP_ROOT/$(date +%Y%m%d-%H%M%S)" + mkdir -p "$backup_dir" + docker compose stop server + cp -a data/. "$backup_dir/" + echo "数据库已备份到: $backup_dir" +else + echo "=== 4. 首次部署,无现有数据库需要备份 ===" +fi + +echo "=== 5. 重建并启动服务 ===" +docker compose up -d --force-recreate server + +echo "=== 6. 等待健康检查 ===" +healthy=false +for _ in $(seq 1 60); do + container_id="$(docker compose ps --all --quiet server)" + if [ -n "$container_id" ]; then + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)" + if [ "$health" = "healthy" ]; then + healthy=true + break + fi + if [ "$health" = "unhealthy" ] || [ "$health" = "exited" ] || [ "$health" = "dead" ]; then + break + fi + fi + sleep 2 +done + +if [ "$healthy" != true ]; then + docker compose ps + docker compose logs --tail=100 server + fail "服务未通过健康检查,请根据上方日志排查" +fi + +echo "" +echo "=== 部署完成 ===" +echo "分支: $BRANCH" +echo "服务器: http://localhost:$HOST_PORT" +echo "管理后台: http://localhost:$HOST_PORT/admin" +echo "Public Live Share: http://localhost:$HOST_PORT/live/#token=" +echo "首次初始化管理员需要使用 $PROJECT_DIR/.env 中的 ADMIN_BOOTSTRAP_TOKEN"