release: 发布 v1.12.0 分享审计保留与清理完善版本 (#103)
## 版本内容 ### 分享审计保留与清理 - 审计记录保留与清理能力:终态会话由所有者整体清空(写入墓碑事件保留追责线索)或导出完整 JSON 归档;终态满默认 90 天由 alarm 自动清理 - 审计保留期创建时自定义(7–365 天,白名单校验,默认 90) - 清理留痕集中展示:清理时间与方式(手动/自动)同步用户库(`audit_purged_at`/`audit_purge_type`),折叠面板展示;已清理分享等同删除效果,不再提供查看审计入口 - 手动清空后取消已排期的自动清理闹钟;保留期服务端校验统一为 7–365 天;修复闹钟设置失败导致"有排期无闹钟"的幽灵状态 ### 工程与质量 - 新增审计清理回归测试 8 用例(墓碑隔离、归属/终态校验、同步失败容错、排期回滚、留痕接缝、迁移幂等) - UserDBDO SQL 查询类型化封装(`query<T>`/`one<T>`),消除 22 处 `as unknown as` - 测试类型检查纳入门禁:`tests/` 拆分 `tsconfig.worker`/`tsconfig.frontend`(workers-types 与 DOM lib 的 Element 接口冲突无法单配置共存) - 弱随机数加固(会话名 `crypto.randomUUID`);pnpm 供应链策略(`blockExoticSubdeps`/`minimumReleaseAge` 7 天/`no-downgrade`);CI 权限收敛 `contents: read`;`.gitleaks.toml` 误报豁免 - pi-lens 项目策略口径(AGENTS.md #31):XSS 类规则刻意不做项目级禁用,仅行内逐处豁免 ## 提交列表 - 1c38c7a `release: 发布 v1.12.0 分享审计保留与清理完善版本` - 4dd7c63 `feat: 审计清理回归测试、UserDBDO 类型化查询与测试类型门禁` - 50389f3 `fix: 消除 pi-lens 剩余告警——SAFETY 注释/测试类型/XSS 抑制/空 catch 说明` - 23a3e8e `chore: 代码质量审查整改——弱随机数/测试类型检查/供应链加固等 7 项` - 1cf0ef9 `chore: 配置 pi-lens 项目策略降低风格类噪音并清理用户库遗留项` - 0f27a50 `fix: 审计清理后完全隐藏分享卡片,仅保留集中式清理记录` - 7c42e8e `feat: 审计清理改为删除效果并新增集中式清理记录留痕区` - a3847ee `fix: 审计清理后改用折叠面板展示清理记录并补齐中英文词条` - b730219 `fix: 审计保留期服务端校验统一为 7–365 天并修复清理闹钟静默失败` - a68a6c8 `feat: 分享审计保留期支持创建时自定义(7–365 天)` - 158b205 `fix: 手动清空审计后取消已排期的自动清理唤醒` - 618f63c `feat: 分享审计记录保留与清理能力(清空/导出/自动保留期)` ## 验证结果 - typecheck 全绿(src + tests.worker + tests.frontend + frontend 四段) - 全量测试 644/644(54 文件) - build:frontend 构建成功并内联 - 关联 Issue/PR:无(基于 v1.11.0 #102) --------- Co-authored-by: TanXin <tanxin@example.com>
This commit is contained in:
1 parent
2dc1f5f290
commit
52fec4e00f
43 files changed
+1305
-305
No files matched your search
@@ -19,6 +19,8 @@ jobs:
|
||||
deploy:
|
||||
if: github.repository_owner == 'newbietan'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# gitleaks 配置:豁免已核实的误报
|
||||
#
|
||||
# 1. README** 的 sealed_token 是 star-history.com 公开图表服务的 URL 参数
|
||||
# (公开仓库统计图,非凭据;token 由该第三方服务签发并公开在文档中)。
|
||||
# 2. tests/ssh/fixtures/ 下的私钥是公开协议测试夹具——AGENTS.md 明确声明
|
||||
# "私钥只用于公开协议测试,绝不可用于真实服务器",与仓库一同公开。
|
||||
# 3. tests/worker/session-reattach.test.ts 中的 resumeToken 是测试假数据。
|
||||
[allowlist]
|
||||
description = "CloudSSH 已核实误报豁免"
|
||||
paths = [
|
||||
'''^(?i)README(_en)?\.md$''',
|
||||
'''tests/ssh/fixtures/''',
|
||||
'''tests/worker/session-reattach\.test\.ts''',
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"format": { "enabled": false },
|
||||
"autofix": { "enabled": false },
|
||||
"ignore": [
|
||||
"tests/ssh/fixtures/**",
|
||||
"README.md",
|
||||
"README_en.md",
|
||||
"src/worker/html.ts",
|
||||
"docs/theme-editor/**"
|
||||
],
|
||||
"rules": {
|
||||
"code-style": {
|
||||
"disable": [
|
||||
"lint/style/useBlockStatements",
|
||||
"lint/style/noNestedTernary",
|
||||
"lint/style/noNegationElse",
|
||||
"lint/style/useTemplate",
|
||||
"lint/style/useCollapsedElseIf",
|
||||
"lint/complexity/noExcessiveCognitiveComplexity",
|
||||
"lint/complexity/noForEach",
|
||||
"lint/complexity/useOptionalChain",
|
||||
"lint/complexity/noAdjacentSpacesInRegex",
|
||||
"lint/suspicious/noExplicitAny",
|
||||
"lint/suspicious/noEmptyInterface",
|
||||
"lint/performance/noAccumulatingSpread",
|
||||
"lint/performance/noBarrelFile",
|
||||
"mixed-async-styles",
|
||||
"high-complexity",
|
||||
"missing-error-propagation",
|
||||
"console-statement",
|
||||
"ast-grep:large-class",
|
||||
"ast-grep:nested-ternary",
|
||||
"ast-grep:prefer-query-selector",
|
||||
"ast-grep:prefer-dom-node-append",
|
||||
"ast-grep:ts-redundant-filter-map",
|
||||
"ast-grep:ts-parseint-no-radix",
|
||||
"ast-grep:prefer-number-properties",
|
||||
"ast-grep:find-import-file-without-extension",
|
||||
"ast-grep:no-array-reverse-mutation",
|
||||
"ast-grep:no-non-null-assertion",
|
||||
"ast-grep:no-any-type",
|
||||
"ast-grep:no-as-any",
|
||||
"ast-grep:no-runtime-typeof",
|
||||
"ast-grep:no-unsafe-dictionary-unknown",
|
||||
"ast-grep:no-unknown-parameters",
|
||||
"ast-grep:prefer-at",
|
||||
"ast-grep:ts-double-negation",
|
||||
"ast-grep:no-console-except-error",
|
||||
"ast-grep:no-shape-in-symbol-names",
|
||||
"ast-grep:no-open-redirect"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
- package.json (依赖、脚本命令)
|
||||
- src/types.ts (Env 接口、类型定义)
|
||||
- biome.json (代码格式与 lint 约定)
|
||||
- .pi-lens.json (pi-lens 项目策略:规则禁用与豁免口径,见 #31)
|
||||
-->
|
||||
|
||||
## Project Overview
|
||||
@@ -162,6 +163,8 @@ pnpm run verify
|
||||
cd frontend && pnpm install
|
||||
```
|
||||
|
||||
> **供应链策略约束**:`pnpm-workspace.yaml` 已启用 `blockExoticSubdeps` / `minimumReleaseAge: 10080`(7 天)/ `trustPolicy: no-downgrade`。frozen-lockfile 部署不受影响;但未来**升级依赖到刚发布(<7 天)的新版本会安装失败**(`minimumReleaseAge` 拦截),需届时再决定放宽或等待版本成熟,勿把该报错当作环境问题排查。
|
||||
|
||||
## Critical Build Process
|
||||
|
||||
The frontend is **NOT** served separately in production. The build process:
|
||||
@@ -317,7 +320,7 @@ release: 发布 vX.Y.Z <主题>版本(如 `release: 发布 v1.10.2 工作流
|
||||
|
||||
25. **One-time SSH sharing** - Sharing is disabled unless `ENABLE_SSH_SHARING=true`. A link contains only a 256-bit capability, persists only its hash, can be claimed once, and exchanges for a one-minute connection ticket. Creation requires route-scoped verified host fingerprints for the target and every jump hop. Share policy is issued only by SSHShareDO/UserDBDO and must disable Agent, OS detection, host-key mutation, metadata mutation, keyboard-interactive auth, and reconnect while permitting only Terminal and optional SFTP. Record lifecycle, structured SFTP requests/results, and terminal output (not raw keystrokes); stop the session if audit storage fails or reaches 5 MiB/5000 events. Revocation and expiry must close the live SSHSessionDO — including detached sessions held in the 60s re-attach grace window (`detachedSessions`), which are NOT reachable through `this.sessions`. Preserve completed audit metadata if its saved-server record is later deleted.
|
||||
|
||||
**Grace-period re-attach(分享会话秒级恢复)**:全新完整重连仍被禁止,但认领设备在断线宽限期内的无缝 reattach 允许,且必须满足:① claim 时绑定设备公钥(`frontend/src/device-identity.ts` 生成的非可导出 ECDSA P-256 密钥,SPKI base64url 经 ShareDO 持久化,Worker 服务端链路下发、客户端不可注入;导出前含持久化回读校验——无痕模式等存储不可靠的环境不绑定公钥,此类分享会话不具备断线恢复资格:断线后即时终结并在终端说明原因);② 恢复请求携带对 `src/share-resume-schema.ts` 规范串的挑战签名,nonce 验签前单次消费防重放;③ 每次成功恢复轮换 resume token(旧 token 降级为“上一代”、仍可容忍一次携旧值重试以覆盖轮换帧丢失,设备签名与 nonce 校验不豁免);④ 到达 `sessionExpiresAt` 或被撤销时立即终结保持中的会话并拒绝恢复;⑤ 拒绝事件写入 `share.resume_denied` 审计。已知理论边界:原设备持有人实时中继签名不可由客户端方案阻止,靠全程审计追责兜底。前端分享连接必须走 resume-only 路径(`connectWithWebSocket(..., { resumeOnly: true })`);重试按指数退避铺满整个断线保持窗口(`SHARE_RESUME_RETRY_WINDOW_MS`,给用户留出切换网络的时间),窗口耗尽或凭据彻底失效时宣告分享结束而非回退完整重连。
|
||||
**Grace-period re-attach(分享会话秒级恢复)**:全新完整重连仍被禁止,但认领设备在断线宽限期内的无缝 reattach 允许,且必须满足:① claim 时绑定设备公钥(`frontend/src/device-identity.ts` 生成的非可导出 ECDSA P-256 密钥,SPKI base64url 经 ShareDO 持久化,Worker 服务端链路下发、客户端不可注入;导出前含持久化回读校验——无痕模式等存储不可靠的环境不绑定公钥,此类分享会话不具备断线恢复资格:断线后即时终结并在终端说明原因);② 恢复请求携带对 `src/share-resume-schema.ts` 规范串的挑战签名,nonce 验签前单次消费防重放;③ 每次成功恢复轮换 resume token(旧 token 降级为“上一代”、仍可容忍一次携旧值重试以覆盖轮换帧丢失,设备签名与 nonce 校验不豁免);④ 到达 `sessionExpiresAt` 或被撤销时立即终结保持中的会话并拒绝恢复;⑤ 拒绝事件写入 `share.resume_denied` 审计。已知理论边界:原设备持有人实时中继签名不可由客户端方案阻止,靠全程审计追责兜底。前端分享连接必须走 resume-only 路径(`connectWithWebSocket(..., { resumeOnly: true })`);重试按指数退避铺满整个断线保持窗口(`SHARE_RESUME_RETRY_WINDOW_MS`,给用户留出切换网络的时间),窗口耗尽或凭据彻底失效时宣告分享结束而非回退完整重连。审计明细默认长期留存:分享者可在终态后整体清空(写入墓碑事件保留追责线索)或由系统在保留期后自动清理——保留期默认 90 天、可在创建分享时自定义(7–365 天);手动清空会同步取消该排期。清理墓碑事件(`share.audit_purged`/`share.audit_auto_purged`)不进入常规审计列表;SSHShareDO 清理时将留痕(时间与手动/自动方式)同步至所有者 UserDBDO 的 `ssh_shares.audit_purged_at`/`audit_purge_type` 列,管理端在集中的「审计清理记录」折叠区展示全部清理操作,已清理的分享等同删除效果——不再提供查看审计入口。接收者无任何删除能力。**已知边界**:留痕同步(`notifyOwnerAuditPurged`)为尽力而为——部署窗口期旧版 UserDBDO 不认 `/internal/shares/:id/audit-purged` 路由时,清理成功但 `audit_purged_at` 留 NULL,前端会将该分享按“审计仍在”展示(点击为空审计);审计明细已删不可回滚,仅记录日志,前端展示以 `audit_purged_at` 为准。
|
||||
|
||||
26. **DNS rebinding SSRF defense** - Address-string checks (`isBlockedHost` / `validateBaseUrl`) alone can be bypassed by domains resolving to private/reserved IPs. `src/worker/dns-check.ts` resolves hostnames via DNS-over-HTTPS (1.1.1.1) and checks every resolved IP against a unified block list covering IPv6 edge cases; it gates both SSH outbound targets (`durable-object.ts`) and AI `base_url` (`agent/ssrf.ts`). When adding address families or reserved ranges, keep the DoH block list and the string-level checks synchronized.
|
||||
|
||||
@@ -329,6 +332,8 @@ release: 发布 vX.Y.Z <主题>版本(如 `release: 发布 v1.10.2 工作流
|
||||
|
||||
30. **Agent exec 输出有界性(弱网 OOM 防线)** - `AgentExecChannel` 对 exec 通道 stdout/stderr 执行有界捕获:合计 4MB 硬上限(`MAX_EXEC_CAPTURE_BYTES`,超限不再续 SSH window 并由会话层关闭通道击杀远端命令,如无界输出的 `docker logs`),保留头 128KB + 尾 256KB 环形视图并附加截断说明,`onData/onExtendedData` 的布尔返回值控制 window 续期,改动时勿恢复无界累积。守卫不只此一层:`docker_manage(logs)` 强制 `--tail 200` 且拒绝 `-f/--follow`;工具结果序列化进 LLM 前经 64K 字符中间截断;socket 写带 15s deadline(超时关底层 socket 解挂);独立于写路径的被动 idle 看门狗(60s 无入站数据即关闭)与终端输入队列 4MB 上限共同保证弱网下会话必然收敛,勿移除任一防线。
|
||||
|
||||
31. **pi-lens 项目策略口径** - `.pi-lens.json` 仅供本机 pi-lens(AI 代码审查插件)读取,不参与构建、部署与 CI 门禁(同 #27 的 Biome 定位),`pi-lens-ignore` 行内注释仅为工具豁免、无运行时行为。其中 `rules.disable` 是已**逐条评估后的误报静音**(以风格类规则为主;`ignore` 仅豁免测试夹具/README/生成文件等路径),而非无差别静音:**XSS 类规则(`no-inner-html`/`ts-xss-dom-sink`)刻意不做项目级禁用**——行内 `pi-lens-ignore` 只豁免逐处核实过的站点(agent-panel 的 Markdown 渲染经 DOMPurify 消毒,其余动态值均 escapeHtml 或来自可信 i18n 词条),未来新增的 innerHTML 站点仍会被规则捕获。新增 innerHTML 时请优先保证转义/消毒并核实后加行内豁免,切勿把这两条加入 `disable` 列表;安全类规则(如 `ast-grep:no-open-redirect`)同理保持克制——扩大禁用清单前先确认告警为误报,优先修复或局部豁免。pi-lens 版本/规则集随设备升级可能产生新告警,处理标准以“是否真实影响运行与门禁”为准。
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
### 双环境部署
|
||||
|
||||
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.12.0] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- 分享审计记录保留与清理能力:分享者在终态后可整体清空全部审计明细(写入墓碑事件保留追责线索,`audit_bytes` 同步重置)或导出完整 JSON 归档(含生命周期与终端输出原文)。
|
||||
- 审计保留期可自定义(7–365 天,默认 90 天):创建分享时指定;终态进入保留期,到期由 alarm 自动清除明细并写入自动清理墓碑。
|
||||
- 审计清理留痕集中展示:清理时间与方式(手动/自动)同步至用户库(`audit_purged_at`/`audit_purge_type`)并以折叠面板展示;已清理的分享等同删除效果,不再提供查看审计入口。
|
||||
- 新增审计清理回归测试(8 用例):墓碑隔离、归属/终态校验、同步失败容错、排期回滚、留痕接缝与迁移幂等。
|
||||
|
||||
### Changed
|
||||
|
||||
- 审计保留期服务端校验统一为 7–365 天(ShareDO 与 Worker 创建入口白名单一致)。
|
||||
- 手动清空审计后同步取消已排期的自动清理闹钟,避免到期后的无效唤起。
|
||||
- 清理墓碑事件(`share.audit_purged`/`share.audit_auto_purged`)从常规审计事件流分离:`ownerView` 单独以 `removals` 返回,前端不再把清洗记录当作普通事件渲染。
|
||||
- UserDBDO SQL 查询改为类型化封装(`query<T>`/`one<T>`),消除 22 处 `as unknown as` 断言及对应说明注释;COUNT 查询统一走 `one<T>`。
|
||||
- 分享会话名随机数改由 `crypto.randomUUID()` 生成;OAuth 回调与 base URL 解析增加防御性兜底。
|
||||
- 测试类型检查纳入门禁:`tests/` 拆分 `tsconfig.worker` / `tsconfig.frontend` 两个项目(workers-types 与 DOM lib 的 `Element` 接口冲突,无法在单一配置共存),并修复暴露的 3 处测试代码类型缺陷。
|
||||
- 供应链与权限收紧:pnpm 启用 `blockExoticSubdeps`/`minimumReleaseAge`(7 天)/`no-downgrade`;CI 部署权限收敛为 `contents: read`;新增 `.gitleaks.toml` 误报豁免配置。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 审计保留期闹钟设置失败静默:失败时回滚排期状态并同步内存,杜绝“有排期但无闹钟”的幽灵状态(自动清理永不触发)。
|
||||
- `updateStatus` 调用点统一 `await`,消除异步化改造后的未处理 rejection 风险。
|
||||
- 部署窗口期 UserDBDO 不支持审计留痕路由时清理仍可完成(尽力同步,失败仅记录日志)。
|
||||
- pi-lens 行内抑制失效:误报豁免注释与目标行之间被说明注释隔断,已补为紧邻放置。
|
||||
|
||||
### Docs
|
||||
|
||||
- AGENTS.md 第 25 条同步清理触发条件、墓碑/removals 域与留痕同步失败边界;新增第 31 条 pi-lens 项目策略口径(XSS 类规则刻意不做项目级禁用,仅行内逐处豁免)。
|
||||
- AGENTS.md 开发命令补充供应链策略约束说明;`tests/README.md` 说明测试类型检查拆分原因。
|
||||
|
||||
## [1.11.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudssh-frontend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -119,6 +119,7 @@ export class AgentPanel {
|
||||
'w-[560px] max-w-[calc(100vw-200px)] shrink-0 border-l border-[var(--border)] flex flex-col bg-[var(--bg)] overflow-hidden';
|
||||
this.panelEl.style.display = 'none';
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
this.panelEl.innerHTML = `
|
||||
<div class="agent-panel-header flex items-center justify-between px-4 py-2.5 border-b border-[var(--border)] bg-[var(--bg-elevated)]">
|
||||
<span class="text-xs font-bold tracking-[0.1em] text-[var(--accent-secondary)]" data-i18n="agent.title">AI Agent 助手</span>
|
||||
@@ -324,6 +325,7 @@ export class AgentPanel {
|
||||
if (!context) return;
|
||||
|
||||
const source = context.sourceLabel || t('agent.selectionUnknownSource');
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
this.contextEl.innerHTML = `
|
||||
<div class="agent-context-chip">
|
||||
<details class="agent-context-details">
|
||||
@@ -388,6 +390,7 @@ export class AgentPanel {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'agent-thinking-process';
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
container.innerHTML = `
|
||||
<button class="tp-accordion" type="button">
|
||||
<span class="tp-chevron material-symbols-outlined">expand_more</span>
|
||||
@@ -428,6 +431,7 @@ export class AgentPanel {
|
||||
if (this.livePreviewCache.length > 2) this.livePreviewCache.shift();
|
||||
const icon =
|
||||
'<span class="material-symbols-outlined tp-live-icon" style="font-variation-settings:\'FILL\' 0;">terminal</span>';
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
this.thinkingLiveEl.innerHTML = this.livePreviewCache
|
||||
.map((l) => `<div class="tp-live-item">${icon}<span>${escapeHtml(l)}</span></div>`)
|
||||
.join('');
|
||||
@@ -458,6 +462,7 @@ export class AgentPanel {
|
||||
? '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 0;">terminal</span>'
|
||||
: '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 1;">smart_toy</span>';
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
stepEl.innerHTML = `${icon}<span class="tp-step-label">${escapeHtml(label)}</span>`;
|
||||
|
||||
this.thinkingCurrentEl.appendChild(stepEl);
|
||||
@@ -490,6 +495,7 @@ export class AgentPanel {
|
||||
step.tool === 'execute_command' || step.tool === 'terminal'
|
||||
? '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 0;">check_circle</span>'
|
||||
: '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 1;">check_circle</span>';
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
stepEl.innerHTML = `${icon}<span class="tp-step-label">${escapeHtml(step.label)}</span>`;
|
||||
this.thinkingStepsEl.appendChild(stepEl);
|
||||
}
|
||||
@@ -553,6 +559,7 @@ export class AgentPanel {
|
||||
const themeColor = 'var(--agent-agent-color)';
|
||||
const roleIcon = `<span class="material-symbols-outlined text-[14px]" style="color:${themeColor};font-variation-settings:'FILL' 1;">smart_toy</span>`;
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
el.innerHTML = `
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="shrink-0 mt-0.5">${roleIcon}</div>
|
||||
@@ -587,8 +594,10 @@ export class AgentPanel {
|
||||
// renderMarkdown() wraps output in its own .agent-md-content div,
|
||||
// so we extract the inner HTML to avoid nesting.
|
||||
const tmp = document.createElement('div');
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
tmp.innerHTML = this.renderMarkdown(content || this.streamingText || '');
|
||||
const inner = tmp.querySelector('.agent-md-content');
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
contentEl.innerHTML = inner ? inner.innerHTML : content || this.streamingText || '';
|
||||
this.enhanceCodeBlocks(contentEl);
|
||||
}
|
||||
@@ -619,6 +628,7 @@ export class AgentPanel {
|
||||
const el = document.createElement('div');
|
||||
el.className =
|
||||
'agent-progress-extend p-2 rounded border border-[var(--accent)] bg-[var(--accent-bg)] text-[11px]';
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
el.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-[14px]" style="color:var(--accent);font-variation-settings:'FILL' 1;">trending_up</span>
|
||||
@@ -655,6 +665,7 @@ export class AgentPanel {
|
||||
el.setAttribute('aria-modal', 'true');
|
||||
el.setAttribute('aria-labelledby', 'agent-confirm-title');
|
||||
el.setAttribute('aria-describedby', 'agent-confirm-description');
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
el.innerHTML = `
|
||||
<div id="agent-confirm-title" class="text-[11px] font-bold text-[var(--error)] mb-1">⚠ ${t('agent.confirmTitle')}</div>
|
||||
<div class="text-[12px] mb-1 font-code bg-black/20 p-1 rounded">$ ${escapeHtml(command)}</div>
|
||||
@@ -801,6 +812,7 @@ export class AgentPanel {
|
||||
|
||||
// User messages: bubble on right. Agent/others: full width on left.
|
||||
if (isUser) {
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
el.innerHTML = `
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[85%] px-3 py-2 rounded-lg" style="background: color-mix(in srgb, ${themeColor} 12%, transparent); border: 1px solid color-mix(in srgb, ${themeColor} 30%, transparent);">
|
||||
@@ -813,6 +825,7 @@ export class AgentPanel {
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
el.innerHTML = `
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="shrink-0 mt-0.5">${roleIcon}</div>
|
||||
|
||||
@@ -97,13 +97,14 @@ export class AIConfigPanel {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
/* AI 配置加载为可选功能,解析失败保持默认即可 */
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchModels(): Promise<void> {
|
||||
const baseUrlEl = this.modalEl?.querySelector('#ai-base-url') as HTMLInputElement;
|
||||
const apiKeyEl = this.modalEl?.querySelector('#ai-api-key') as HTMLInputElement;
|
||||
const statusEl = this.modalEl?.querySelector('#ai-fetch-status') as HTMLElement;
|
||||
const modelListEl = this.modalEl?.querySelector('#ai-model-list') as HTMLDataListElement;
|
||||
const fetchBtn = this.modalEl?.querySelector('#ai-fetch-models-btn') as HTMLButtonElement;
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// 渲染模板中的 innerHTML 站点均带 `pi-lens-ignore: no-inner-html` 内联抑制:
|
||||
// 动态值均经 escapeHtml 转义或来自可信 i18n 词条,无用户输入直插;
|
||||
// GitHub Actions 质量门禁不含该规则(AGENTS.md #27)。
|
||||
import { onLocaleChange, t, translateDocument } from './i18n';
|
||||
import { loadKnownFingerprint } from './known-hosts';
|
||||
import { parsePort } from './port';
|
||||
import { populateRegionSelect, regionLabel } from './regions';
|
||||
import { populateRegionSelect } from './regions';
|
||||
import type { TabManager } from './tab-manager';
|
||||
import { type ColorScheme, getActiveColorScheme, onColorSchemeChange } from './theme';
|
||||
import { notify } from './ui-feedback';
|
||||
@@ -19,7 +22,7 @@ async function deriveKey(salt: Uint8Array): Promise<CryptoKey> {
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptCredentials(data: object): Promise<string> {
|
||||
async function encryptCredentials(data: Record<string, unknown>): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(salt);
|
||||
@@ -125,6 +128,7 @@ export class ConnectionForm {
|
||||
const container = document.getElementById('connection-form-container');
|
||||
if (!container) return;
|
||||
|
||||
// pi-lens-ignore: no-inner-html
|
||||
container.innerHTML = `
|
||||
<div class="flex min-h-[320px] flex-col items-center justify-center gap-5 px-4 text-center" id="github-auth-required-panel">
|
||||
<span class="material-symbols-outlined text-[var(--accent)]" style="font-size: 42px;" aria-hidden="true">lock</span>
|
||||
@@ -208,6 +212,7 @@ export class ConnectionForm {
|
||||
private render(): void {
|
||||
const container = document.getElementById('connection-form-container')!;
|
||||
|
||||
// pi-lens-ignore: no-inner-html
|
||||
container.innerHTML = `
|
||||
<form class="space-y-6" id="connection-form">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
@@ -382,6 +387,7 @@ export class ConnectionForm {
|
||||
const authLabel = item.authMethod === 'publickey' ? 'KEY' : 'PWD';
|
||||
const labelText = `${item.username}@${item.host}:${item.port}`;
|
||||
|
||||
// pi-lens-ignore: no-inner-html
|
||||
itemEl.innerHTML = `
|
||||
<div class="flex items-center gap-2 overflow-hidden mr-2 select-none flex-1">
|
||||
<span class="material-symbols-outlined text-muted" style="font-size: 14px;">history</span>
|
||||
@@ -457,7 +463,9 @@ export class ConnectionForm {
|
||||
let recent: any[] = [];
|
||||
try {
|
||||
recent = raw ? JSON.parse(raw) : [];
|
||||
} catch {}
|
||||
} catch {
|
||||
/* 本地存储损坏时回退为空列表,无需上报 */
|
||||
}
|
||||
|
||||
if (index >= 0 && index < recent.length) {
|
||||
recent.splice(index, 1);
|
||||
@@ -588,7 +596,9 @@ export class ConnectionForm {
|
||||
try {
|
||||
recent = recentRaw ? JSON.parse(recentRaw) : [];
|
||||
if (!Array.isArray(recent)) recent = [];
|
||||
} catch {}
|
||||
} catch {
|
||||
/* 本地存储损坏时回退为空列表,无需上报 */
|
||||
}
|
||||
|
||||
const id = `${username}@${host}:${port}`;
|
||||
const newRecord = {
|
||||
@@ -643,9 +653,10 @@ export class ConnectionForm {
|
||||
expectedFingerprint: expectedFingerprint || undefined,
|
||||
locationHint: regionValue || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 连接失败时关闭该标签
|
||||
tm.closeTab(tab.id);
|
||||
// pi-lens-ignore: no-inner-html
|
||||
document.getElementById('status-text')!.innerHTML =
|
||||
`<span class="w-2 h-2 bg-surface-dot inline-block"></span> ${t('auth.statusOffline')}`;
|
||||
}
|
||||
|
||||
@@ -247,6 +247,18 @@ export const enUS: Record<keyof typeof zhCN, string> = {
|
||||
'terminal.shareResumeUnsupported':
|
||||
'This browser cannot pass device identity verification; auto-resume is unavailable and this shared session has ended',
|
||||
'terminal.status.shareExpiringWarning': 'Note: this shared session ends in less than 1 minute',
|
||||
'share.auditExport': 'Export',
|
||||
'share.auditPurge': 'Purge log',
|
||||
'share.auditPurgeTitle': 'Purge audit log',
|
||||
'share.auditPurgeMessage':
|
||||
'This permanently deletes all audit details (including terminal output and operations), keeping only a single tombstone marker. This cannot be undone.',
|
||||
'share.auditPurged': 'Audit log purged',
|
||||
'share.auditCleanupLog': 'Audit purge log',
|
||||
'share.auditPurgeManual': 'Manually purged',
|
||||
'share.auditPurgeAuto': 'Auto-purged after retention',
|
||||
'share.auditRemovalHint':
|
||||
'Purged details cannot be recovered; these entries only record the purge operations.',
|
||||
'share.auditPurgeFailed': 'Failed to purge audit log',
|
||||
'terminal.shareResumeEnded':
|
||||
'Connection lost and could not be resumed. This shared session has ended.',
|
||||
'terminal.hostKeyChangeTitle': 'SSH host fingerprint changed',
|
||||
@@ -487,6 +499,8 @@ export const enUS: Record<keyof typeof zhCN, string> = {
|
||||
'share.linkExpiry': 'Claim window',
|
||||
'share.sessionDuration': 'Maximum session duration',
|
||||
'share.minutes': 'minutes',
|
||||
'share.auditRetention': 'Audit retention',
|
||||
'share.days': 'days',
|
||||
'share.createWarning':
|
||||
'A share link is a temporary access credential. The recipient receives a full terminal and SFTP; send it only through a trusted channel. Every host fingerprint in the route must already be verified.',
|
||||
'share.create': 'Create share',
|
||||
|
||||
@@ -202,6 +202,17 @@ export const zhCN = {
|
||||
'terminal.shareResumeUnsupported':
|
||||
'当前浏览器环境无法通过设备身份校验,不支持断线自动恢复,本次分享会话已结束',
|
||||
'terminal.status.shareExpiringWarning': '注意:分享会话将在 1 分钟内结束',
|
||||
'share.auditExport': '导出',
|
||||
'share.auditPurge': '清空审计',
|
||||
'share.auditPurgeTitle': '清空审计记录',
|
||||
'share.auditPurgeMessage':
|
||||
'将永久删除全部审计明细(含终端输出与操作记录),仅保留一条清除标记。此操作不可恢复。',
|
||||
'share.auditPurged': '审计记录已清空',
|
||||
'share.auditCleanupLog': '审计清理记录',
|
||||
'share.auditPurgeManual': '手动清空',
|
||||
'share.auditPurgeAuto': '到期自动清理',
|
||||
'share.auditRemovalHint': '已清除的明细不可恢复,此处仅保留清理操作的记录。',
|
||||
'share.auditPurgeFailed': '清空审计失败',
|
||||
'terminal.shareResumeEnded': '连接已断开且无法恢复,本次分享会话已结束。',
|
||||
'terminal.hostKeyChangeTitle': 'SSH 主机指纹已变更',
|
||||
'terminal.hostKeySaveTitle': '无法记录 SSH 主机指纹',
|
||||
@@ -446,6 +457,8 @@ export const zhCN = {
|
||||
'share.linkExpiry': '链接领取有效期',
|
||||
'share.sessionDuration': '会话最长时间',
|
||||
'share.minutes': '分钟',
|
||||
'share.auditRetention': '审计保留期',
|
||||
'share.days': '天',
|
||||
'share.createWarning':
|
||||
'分享链接等同于临时访问凭证。接收者可使用完整终端和 SFTP,请仅通过可信渠道发送。创建前必须已通过普通连接验证全部主机指纹。',
|
||||
'share.create': '创建分享',
|
||||
|
||||
@@ -45,6 +45,7 @@ export function populateRegionSelect(
|
||||
el: HTMLSelectElement,
|
||||
selected: string | null | undefined
|
||||
): void {
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
el.innerHTML = REGION_OPTIONS.map(
|
||||
(o) =>
|
||||
`<option value="${o.value}" ${o.value === (selected || '') ? 'selected' : ''}>${t(o.labelKey)}</option>`
|
||||
|
||||
@@ -332,6 +332,7 @@ export class ServerList {
|
||||
tagFilterWrapper?.classList.toggle('hidden', allTags.length === 0);
|
||||
if (tagFilter) {
|
||||
if (this.selectedTag && !allTags.includes(this.selectedTag)) this.selectedTag = '';
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
tagFilter.innerHTML = [
|
||||
`<option value="">${t('server.allTags')}</option>`,
|
||||
...allTags.map(
|
||||
@@ -358,6 +359,7 @@ export class ServerList {
|
||||
this.currentPage = page.currentPage;
|
||||
const visibleServers = page.items;
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
grid.innerHTML = visibleServers.map((server) => this.renderServerCard(server)).join('');
|
||||
|
||||
// 绑定卡片事件
|
||||
@@ -526,6 +528,7 @@ export class ServerList {
|
||||
|
||||
const connectBtn = document.getElementById(`connect-${serverId}`);
|
||||
if (connectBtn) {
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
connectBtn.innerHTML = `
|
||||
<span class="material-symbols-outlined animate-spin" style="font-size: 14px;">progress_activity</span>
|
||||
${t('server.connecting')}
|
||||
@@ -563,6 +566,7 @@ export class ServerList {
|
||||
});
|
||||
} finally {
|
||||
if (connectBtn) {
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
connectBtn.innerHTML = `
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">power_settings_new</span>
|
||||
${t('common.connect')}
|
||||
@@ -623,6 +627,7 @@ export class ServerList {
|
||||
if (!modal || !title || !submitBtn) return;
|
||||
|
||||
title.textContent = mode === 'add' ? t('server.add') : t('server.edit');
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
submitBtn.innerHTML = `
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">save</span>
|
||||
${mode === 'add' ? t('server.save') : t('server.update')}
|
||||
@@ -844,6 +849,7 @@ export class ServerList {
|
||||
|
||||
const submitBtn = document.getElementById('server-submit-btn') as HTMLButtonElement;
|
||||
submitBtn.disabled = true;
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
submitBtn.innerHTML = `
|
||||
<span class="material-symbols-outlined animate-spin" style="font-size: 18px;">progress_activity</span>
|
||||
${t('server.saving')}
|
||||
@@ -896,7 +902,7 @@ export class ServerList {
|
||||
// DEBUG_MODE 时,响应中包含 _debug 字段:显示完整调试日志
|
||||
if (debugLines) {
|
||||
console.log('[locationHint 调试信息]');
|
||||
debugLines.forEach((msg) => console.log(msg));
|
||||
for (const msg of debugLines) console.log(msg);
|
||||
this.showDebugNotification(debugLines);
|
||||
}
|
||||
|
||||
@@ -931,6 +937,7 @@ export class ServerList {
|
||||
});
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
submitBtn.innerHTML = `
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">save</span>
|
||||
${this.editingServerId ? t('server.update') : t('server.save')}
|
||||
@@ -961,6 +968,7 @@ export class ServerList {
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className =
|
||||
'absolute top-2 right-2 text-muted hover:text-[var(--accent)] cursor-pointer';
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
closeBtn.innerHTML =
|
||||
'<span class="material-symbols-outlined" style="font-size: 16px;">close</span>';
|
||||
closeBtn.onclick = () => notification.remove();
|
||||
|
||||
@@ -197,6 +197,7 @@ export class SFTPPanel {
|
||||
panel.style.width = 'min(420px, 100vw)';
|
||||
panel.style.transform = 'translateX(100%)';
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
panel.innerHTML = `
|
||||
<div class="flex flex-col w-full h-full bg-surface border-l border-outline-variant text-on-surface">
|
||||
<!-- Header -->
|
||||
@@ -577,7 +578,9 @@ export class SFTPPanel {
|
||||
this.closedByPanel.add(ws);
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {}
|
||||
} catch {
|
||||
/* 连接可能已关闭,close 抛出可忽略(幂等操作) */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,6 +770,7 @@ export class SFTPPanel {
|
||||
});
|
||||
this.renderedEntries = sorted;
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
entriesContainer.innerHTML = sorted
|
||||
.map(
|
||||
(entry, idx) => `
|
||||
@@ -799,7 +803,7 @@ export class SFTPPanel {
|
||||
});
|
||||
});
|
||||
|
||||
el.addEventListener('dblclick', (e) => {
|
||||
el.addEventListener('dblclick', () => {
|
||||
const target = el as HTMLElement;
|
||||
const idx = parseInt(target.dataset['idx']!);
|
||||
const entry = sorted[idx];
|
||||
@@ -941,6 +945,7 @@ export class SFTPPanel {
|
||||
className: 'text-error',
|
||||
});
|
||||
|
||||
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
|
||||
menu.innerHTML = items
|
||||
.map(
|
||||
(item) => `
|
||||
@@ -1346,7 +1351,7 @@ export class SFTPPanel {
|
||||
this.uploadWaiter.resolveProgress(loaded);
|
||||
}
|
||||
|
||||
private onUploadComplete(path: string): void {
|
||||
private onUploadComplete(_path: string): void {
|
||||
this.uploadWaiter.resolveComplete();
|
||||
this.uploadActive = false;
|
||||
this.uploadCancelRequested = false;
|
||||
@@ -1567,7 +1572,7 @@ export class SFTPPanel {
|
||||
(this.container.querySelector('#sftp-status-text') as HTMLElement).textContent = text;
|
||||
}
|
||||
|
||||
private updateItemCount(count: number): void {
|
||||
private updateItemCount(_count: number): void {
|
||||
const dirs = this.entries.filter((e) => e.isDir).length;
|
||||
const files = this.entries.filter((e) => !e.isDir).length;
|
||||
(this.container.querySelector('#sftp-item-count') as HTMLElement).textContent = t(
|
||||
|
||||
+159
-33
@@ -16,6 +16,9 @@ interface ShareSummary {
|
||||
activeAt: number | null;
|
||||
closedAt: number | null;
|
||||
createdAt: number;
|
||||
/** 审计明细被清理的时间与方式;NULL 表示审计仍在,可查看 */
|
||||
auditPurgedAt?: number | null;
|
||||
auditPurgeType?: 'manual' | 'auto' | null;
|
||||
}
|
||||
|
||||
interface AuditEvent {
|
||||
@@ -52,7 +55,10 @@ function formatShareStatus(status: string | undefined): string {
|
||||
|
||||
function stripTerminalControls(value: string): string {
|
||||
return value
|
||||
// 本函数的目的即去除 ANSI 控制序列(OSC \x1b]... / CSI \x1b[...),正则中的控制字符为设计意图。
|
||||
// pi-lens-ignore: lint/suspicious/noControlCharactersInRegex
|
||||
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
||||
// pi-lens-ignore: lint/suspicious/noControlCharactersInRegex
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '');
|
||||
}
|
||||
@@ -102,6 +108,13 @@ export class ShareManager {
|
||||
<option value="60" selected>60 ${t('share.minutes')}</option><option value="120">120 ${t('share.minutes')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-muted">${t('share.auditRetention')}
|
||||
<select id="share-audit-retention" class="terminal-input w-full mt-1">
|
||||
<option value="7">7 ${t('share.days')}</option><option value="30">30 ${t('share.days')}</option>
|
||||
<option value="90" selected>90 ${t('share.days')}</option><option value="180">180 ${t('share.days')}</option>
|
||||
<option value="365">365 ${t('share.days')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mb-4">${t('share.createWarning')}</p>
|
||||
<button id="share-create-btn" type="button" class="cyber-button text-primary px-4 py-2 text-xs font-bold flex items-center gap-2">
|
||||
@@ -141,12 +154,15 @@ export class ShareManager {
|
||||
const maxSessionMinutes = Number(
|
||||
(document.getElementById('share-session-duration') as HTMLSelectElement).value
|
||||
);
|
||||
const auditRetentionDays = Number(
|
||||
(document.getElementById('share-audit-retention') as HTMLSelectElement).value
|
||||
);
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${this.serverId}/shares`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expiresInMinutes, maxSessionMinutes }),
|
||||
body: JSON.stringify({ expiresInMinutes, maxSessionMinutes, auditRetentionDays }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
url?: string;
|
||||
@@ -199,11 +215,29 @@ export class ShareManager {
|
||||
container.innerHTML = `<p class="text-xs text-muted">${t('share.none')}</p>`;
|
||||
return;
|
||||
}
|
||||
const purged = shares.filter((s) => s.auditPurgedAt);
|
||||
// 已清理审计的分享等同删除效果:不再渲染卡片,仅在下方清理留痕区留一条记录
|
||||
const visible = shares.filter((s) => !s.auditPurgedAt);
|
||||
// 集中式清理留痕区:所有已清理的分享都汇总在这里(默认收起)
|
||||
const cleanupSection =
|
||||
purged.length > 0
|
||||
? `<details class="mb-3 border border-[var(--border)] p-2">
|
||||
<summary class="text-[10px] text-muted cursor-pointer select-none">
|
||||
${t('share.auditCleanupLog')}(${purged.length})
|
||||
</summary>
|
||||
<div class="space-y-1 mt-2">
|
||||
${purged.map((share) => `<div class="text-[10px] text-muted"><span class="text-dim">${escapeHtml(formatTime(share.auditPurgedAt ?? null))}</span> ${escapeHtml(t(`share.status.${share.status}` as never))} · ${share.auditPurgeType === 'auto' ? t('share.auditPurgeAuto') : t('share.auditPurgeManual')}</div>`).join('')}
|
||||
</div>
|
||||
<p class="text-[10px] text-dim mt-1">${t('share.auditRemovalHint')}</p>
|
||||
</details>`
|
||||
: '';
|
||||
// pi-lens-ignore: no-inner-html
|
||||
container.innerHTML = shares
|
||||
.map((share) => {
|
||||
const revocable = ['unused', 'claimed', 'active'].includes(share.status);
|
||||
return `<div class="border border-[var(--border)] p-3" data-share-id="${escapeHtml(share.id)}">
|
||||
container.innerHTML =
|
||||
cleanupSection +
|
||||
visible
|
||||
.map((share) => {
|
||||
const revocable = ['unused', 'claimed', 'active'].includes(share.status);
|
||||
return `<div class="border border-[var(--border)] p-3" data-share-id="${escapeHtml(share.id)}">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-on-surface">${t(`share.status.${share.status}` as never)}</div>
|
||||
@@ -216,8 +250,8 @@ export class ShareManager {
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
})
|
||||
.join('');
|
||||
for (const button of container.querySelectorAll<HTMLElement>('[data-share-audit]')) {
|
||||
const shareId = button.dataset.shareAudit;
|
||||
if (!shareId) continue;
|
||||
@@ -262,30 +296,7 @@ export class ShareManager {
|
||||
// pi-lens-ignore: no-inner-html
|
||||
view.innerHTML = `<p class="text-xs text-muted">${t('common.loading')}</p>`;
|
||||
try {
|
||||
const events: AuditEvent[] = [];
|
||||
let after = 0;
|
||||
let hasMore = true;
|
||||
let share: { status: string; auditBytes: number } | null = null;
|
||||
while (hasMore && events.length < 5000) {
|
||||
const response = await fetch(
|
||||
`/api/shares/${encodeURIComponent(shareId)}/audit?after=${after}&limit=500`
|
||||
);
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
share?: { status: string; auditBytes: number };
|
||||
events?: AuditEvent[];
|
||||
hasMore?: boolean;
|
||||
nextAfter?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (!response.ok || !payload.share || !payload.events)
|
||||
throw new Error(payload.error || t('share.auditFailed'));
|
||||
share = payload.share;
|
||||
events.push(...payload.events);
|
||||
hasMore = payload.hasMore === true;
|
||||
const next = payload.nextAfter ?? after;
|
||||
if (next <= after) break;
|
||||
after = next;
|
||||
}
|
||||
const { share, events } = await this.fetchAllAudit(shareId);
|
||||
const output = stripTerminalControls(
|
||||
events
|
||||
.filter((event) => event.eventType === 'terminal.output')
|
||||
@@ -293,24 +304,134 @@ export class ShareManager {
|
||||
.join('')
|
||||
);
|
||||
const structured = events.filter((event) => event.eventType !== 'terminal.output');
|
||||
const canPurge =
|
||||
share?.status === 'closed' || share?.status === 'revoked' || share?.status === 'expired';
|
||||
// pi-lens-ignore: no-inner-html
|
||||
view.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-xs font-bold text-primary">${t('share.auditTitle')}</h3>
|
||||
<span class="text-[10px] text-muted">${formatShareStatus(share?.status)} · ${Math.ceil((share?.auditBytes || 0) / 1024)} KiB</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-muted">${formatShareStatus(share?.status)} · ${Math.ceil((share?.auditBytes || 0) / 1024)} KiB</span>
|
||||
<button type="button" data-audit-export class="cyber-button px-2 py-1 text-[10px]">${t('share.auditExport')}</button>
|
||||
${canPurge ? `<button type="button" data-audit-purge class="cyber-button px-2 py-1 text-[10px] text-error">${t('share.auditPurge')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 mb-4 max-h-48 overflow-y-auto custom-scrollbar">
|
||||
${structured.length ? structured.map((event) => `<div class="text-[10px] text-muted"><span class="text-dim">${escapeHtml(formatTime(event.occurredAt))}</span> ${escapeHtml(this.describeEvent(event))}</div>`).join('') : `<p class="text-xs text-muted">${t('share.auditNone')}</p>`}
|
||||
${structured.map((event) => `<div class="text-[10px] text-muted"><span class="text-dim">${escapeHtml(formatTime(event.occurredAt))}</span> ${escapeHtml(this.describeEvent(event))}</div>`).join('') || `<p class="text-xs text-muted">${t('share.auditNone')}</p>`}
|
||||
</div>
|
||||
<h4 class="text-[11px] font-bold text-primary mb-2">${t('share.terminalRecord')}</h4>
|
||||
<pre class="bg-[var(--bg)] border border-[var(--border)] p-3 text-[11px] text-on-surface whitespace-pre-wrap break-words max-h-72 overflow-auto custom-scrollbar">${escapeHtml(output || t('share.noTerminalOutput'))}</pre>
|
||||
`;
|
||||
for (const button of view.querySelectorAll<HTMLElement>('[data-audit-export]')) {
|
||||
button.addEventListener('click', () => void this.exportAudit(shareId));
|
||||
}
|
||||
for (const button of view.querySelectorAll<HTMLElement>('[data-audit-purge]')) {
|
||||
button.addEventListener('click', () => void this.purgeAudit(shareId));
|
||||
}
|
||||
} catch (error) {
|
||||
// pi-lens-ignore: no-inner-html
|
||||
view.innerHTML = `<p class="text-xs text-error">${escapeHtml(error instanceof Error ? error.message : t('share.auditFailed'))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取指定分享的全部审计事件、清理记录与元信息(分页聚合)。 */
|
||||
private async fetchAllAudit(shareId: string): Promise<{
|
||||
share: { status: string; auditBytes: number } | null;
|
||||
events: AuditEvent[];
|
||||
removals: Array<{ occurredAt: number; eventType: string }>;
|
||||
}> {
|
||||
const events: AuditEvent[] = [];
|
||||
let removals: Array<{ occurredAt: number; eventType: string }> = [];
|
||||
let share: { status: string; auditBytes: number } | null = null;
|
||||
let after = 0;
|
||||
let hasMore = true;
|
||||
while (hasMore && events.length < 5000) {
|
||||
const response = await fetch(
|
||||
`/api/shares/${encodeURIComponent(shareId)}/audit?after=${after}&limit=500`
|
||||
);
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
share?: { status: string; auditBytes: number };
|
||||
events?: AuditEvent[];
|
||||
removals?: Array<{ occurredAt: number; eventType: string }>;
|
||||
hasMore?: boolean;
|
||||
nextAfter?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (!response.ok || !payload.share || !payload.events)
|
||||
throw new Error(localizedApiError(payload, 'share.auditFailed'));
|
||||
share = { status: payload.share.status, auditBytes: payload.share.auditBytes };
|
||||
events.push(...payload.events);
|
||||
// 清理记录每页都全量返回,直接覆盖避免重复累积
|
||||
if (payload.removals) removals = payload.removals;
|
||||
hasMore = payload.hasMore === true;
|
||||
const next = payload.nextAfter ?? after;
|
||||
if (next <= after) break;
|
||||
after = next;
|
||||
}
|
||||
return { share, events, removals };
|
||||
}
|
||||
|
||||
/** 导出全部审计事件为 JSON 归档(含生命周期与终端输出原文)。 */
|
||||
private async exportAudit(shareId: string): Promise<void> {
|
||||
try {
|
||||
const { share, events, removals } = await this.fetchAllAudit(shareId);
|
||||
const payload = {
|
||||
app: 'CloudSSH',
|
||||
kind: 'share-audit-export',
|
||||
exportedAt: new Date().toISOString(),
|
||||
shareId,
|
||||
status: share?.status ?? null,
|
||||
auditBytes: share?.auditBytes ?? 0,
|
||||
events,
|
||||
auditRemovals: removals,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `cloudssh-share-audit-${shareId}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
notify(t('share.auditFailed'), { variant: 'danger' });
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空终态会话的全部审计明细(服务端写入墓碑,不可恢复)。 */
|
||||
private async purgeAudit(shareId: string): Promise<void> {
|
||||
const confirmed = await confirmAction({
|
||||
title: t('share.auditPurgeTitle'),
|
||||
message: t('share.auditPurgeMessage'),
|
||||
confirmText: t('share.auditPurge'),
|
||||
cancelText: t('common.cancel'),
|
||||
variant: 'danger',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const response = await fetch(`/api/shares/${encodeURIComponent(shareId)}/audit`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
if (!response.ok) throw new Error(localizedApiError(payload, 'share.auditPurgeFailed'));
|
||||
notify(t('share.auditPurged'), { variant: 'success' });
|
||||
// 清理等同删除效果:立即收起审计视图并刷新列表,隐藏该分享的查看入口
|
||||
const view = document.getElementById('share-audit-view');
|
||||
if (view) {
|
||||
view.classList.add('hidden');
|
||||
view.innerHTML = '';
|
||||
}
|
||||
await this.loadShares();
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : t('share.auditPurgeFailed'), {
|
||||
variant: 'danger',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private describeEvent(event: AuditEvent): string {
|
||||
const details = event.details;
|
||||
if (event.eventType === 'sftp.request' || event.eventType === 'sftp.result') {
|
||||
@@ -323,5 +444,10 @@ export class ShareManager {
|
||||
return `SFTP ${operation}${path ? ` ${path}` : ''} · ${result}`;
|
||||
}
|
||||
return t(`share.event.${event.eventType}` as never);
|
||||
// 注意:share.event.share.audit_purged / audit_auto_purged 两条墓碑词条已移除——
|
||||
// 新版后端 ownerView 已把两类墓碑事件从事件列表过滤(removals 单独返回),
|
||||
// 正常路径不会命中;且前后端同源内联原子部署(build-html → 同一 Worker),
|
||||
// 不存在“新前端调用旧后端”的窗口。如未来启用渐近发布等新旧 Worker 共存
|
||||
// 方案,再按需补回词条。
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudssh",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"description": "纯 Cloudflare 架构 Web SSH 工具",
|
||||
"main": "src/worker/index.ts",
|
||||
@@ -11,7 +11,7 @@
|
||||
"sync:theme-editor": "node scripts/sync-theme-editor.js",
|
||||
"build:frontend": "node scripts/build-html.js",
|
||||
"typecheck": "pnpm run typecheck:worker && pnpm run typecheck:frontend",
|
||||
"typecheck:worker": "tsc --noEmit",
|
||||
"typecheck:worker": "tsc --noEmit && tsc -p tests/tsconfig.worker.json --noEmit && tsc -p tests/tsconfig.frontend.json --noEmit",
|
||||
"typecheck:frontend": "pnpm --dir frontend exec tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
@@ -36,6 +36,7 @@
|
||||
"@axe-core/playwright": "4.12.1",
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "4.1.10",
|
||||
|
||||
Generated
+26
-9
@@ -17,6 +17,9 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: 1.62.0
|
||||
version: 1.62.0
|
||||
'@types/node':
|
||||
specifier: ^26.2.0
|
||||
version: 26.2.0
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(vitest@4.1.10)
|
||||
@@ -25,7 +28,7 @@ importers:
|
||||
version: 5.7.2
|
||||
vitest:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7))
|
||||
version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))
|
||||
wrangler:
|
||||
specifier: ^4.106.0
|
||||
version: 4.106.0(@cloudflare/workers-types@4.20260630.1)
|
||||
@@ -77,7 +80,7 @@ importers:
|
||||
version: 5.7.2
|
||||
vite:
|
||||
specifier: ^6.0.0
|
||||
version: 6.4.3(jiti@1.21.7)
|
||||
version: 6.4.3(@types/node@26.2.0)(jiti@1.21.7)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -832,6 +835,9 @@ packages:
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@26.2.0':
|
||||
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -1386,6 +1392,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
@@ -1974,6 +1983,10 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@26.2.0':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
@@ -1989,7 +2002,7 @@ snapshots:
|
||||
obug: 2.1.3
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7))
|
||||
vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))
|
||||
|
||||
'@vitest/expect@4.1.10':
|
||||
dependencies:
|
||||
@@ -2000,13 +2013,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.10(vite@6.4.3(jiti@1.21.7))':
|
||||
'@vitest/mocker@4.1.10(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 6.4.3(jiti@1.21.7)
|
||||
vite: 6.4.3(@types/node@26.2.0)(jiti@1.21.7)
|
||||
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
dependencies:
|
||||
@@ -2579,6 +2592,8 @@ snapshots:
|
||||
|
||||
typescript@5.7.2: {}
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
undici@7.28.0: {}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
@@ -2593,7 +2608,7 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@6.4.3(jiti@1.21.7):
|
||||
vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7):
|
||||
dependencies:
|
||||
esbuild: 0.25.0
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -2602,13 +2617,14 @@ snapshots:
|
||||
rollup: 4.62.2
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.2.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
|
||||
vitest@4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7)):
|
||||
vitest@4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@6.4.3(jiti@1.21.7))
|
||||
'@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
@@ -2625,9 +2641,10 @@ snapshots:
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 6.4.3(jiti@1.21.7)
|
||||
vite: 6.4.3(@types/node@26.2.0)(jiti@1.21.7)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.2.0
|
||||
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -5,3 +5,6 @@ allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
blockExoticSubdeps: true
|
||||
minimumReleaseAge: 10080
|
||||
trustPolicy: no-downgrade
|
||||
@@ -73,6 +73,7 @@ function readObjectLiteral(source, marker) {
|
||||
const objectEnd = findMatchingBrace(source, objectStart);
|
||||
const literal = source.slice(objectStart, objectEnd + 1);
|
||||
// 仓库源码属于受信任输入;这里只执行抽取出的纯数据对象,供静态 Pages 使用。
|
||||
// pi-lens-ignore: no-global-eval-js
|
||||
return Function(`"use strict"; return (${literal});`)();
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ export class SSHAESGCMCipher {
|
||||
if (!this.key) throw new Error('Cipher not initialized');
|
||||
const algorithm = this.getAlgorithm(aad);
|
||||
|
||||
// SAFETY: getAlgorithm 返回的算法对象仅含 AesGcmParams 支持的分段;
|
||||
// WebCrypto 类型签名过窄,运行时与 TS 均接受该形状
|
||||
const encrypted = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
algorithm as unknown as SubtleCryptoEncryptAlgorithm,
|
||||
@@ -84,6 +86,8 @@ export class SSHAESGCMCipher {
|
||||
const algorithm = this.getAlgorithm(aad);
|
||||
|
||||
try {
|
||||
// SAFETY: getAlgorithm 返回的算法对象仅含 AesGcmParams 支持的分段;
|
||||
// WebCrypto 类型签名过窄,运行时与 TS 均接受该形状
|
||||
const decrypted = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
algorithm as unknown as SubtleCryptoEncryptAlgorithm,
|
||||
|
||||
@@ -23,6 +23,8 @@ type X25519SubtleCrypto = {
|
||||
};
|
||||
|
||||
function x25519Subtle(): X25519SubtleCrypto {
|
||||
// SAFETY: Workers 运行时基于 WebCrypto 扩展曲线算法;类型缺官方声明,
|
||||
// 接口按运行时实际形状定义,此转换仅为补全 TS 签名
|
||||
return crypto.subtle as unknown as X25519SubtleCrypto;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ type ECDHSubtleCrypto = {
|
||||
};
|
||||
|
||||
function ecdhSubtle(): ECDHSubtleCrypto {
|
||||
// SAFETY: Workers 运行时基于 WebCrypto 扩展曲线算法;类型缺官方声明,
|
||||
// 接口按运行时实际形状定义,此转换仅为补全 TS 签名
|
||||
return crypto.subtle as unknown as ECDHSubtleCrypto;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -77,6 +77,6 @@ export function extractRawECDHPoint(blob: Uint8Array): Uint8Array {
|
||||
return blob.subarray(offset, offset + pointLen);
|
||||
}
|
||||
|
||||
export function encodePrefixedString(str: string): Uint8Array {
|
||||
export function encodePrefixedString(str: string | Uint8Array): Uint8Array {
|
||||
return encodeString(str);
|
||||
}
|
||||
+15
-4
@@ -18,8 +18,13 @@ function parseCookies(request: Request): Record<string, string> {
|
||||
|
||||
function getBaseUrl(env: Env, request: Request): string {
|
||||
if (env.BASE_URL) return env.BASE_URL.replace(/\/$/, '');
|
||||
const url = new URL(request.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
// request.url 由 Workers 运行时保证为合法绝对 URL;防御性兜底避免抛错。
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 获取 UserDBDO stub ====================
|
||||
@@ -41,7 +46,7 @@ interface GitHubAccessPolicy {
|
||||
* - 已配置但为空:拒绝所有 GitHub 用户。
|
||||
* - 含非法值:配置无效并 fail closed,避免误开放实例。
|
||||
*/
|
||||
export function getGitHubAccessPolicy(env: Env): GitHubAccessPolicy {
|
||||
function getGitHubAccessPolicy(env: Env): GitHubAccessPolicy {
|
||||
const raw = env.GITHUB_ALLOWED_USER_IDS;
|
||||
if (raw === undefined) {
|
||||
return { restricted: false, valid: true, allowedIds: new Set() };
|
||||
@@ -154,7 +159,13 @@ export async function handleGitHubCallback(request: Request, env: Env): Promise<
|
||||
return Response.json({ error: 'GitHub OAuth not configured' }, { status: 501 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
// request.url 由 Workers 运行时保证为合法绝对 URL;防御性兜底避免抛出。
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(request.url);
|
||||
} catch {
|
||||
return new Response('Invalid callback URL', { status: 400 });
|
||||
}
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const baseUrl = getBaseUrl(env, request);
|
||||
|
||||
@@ -75,7 +75,9 @@ export class DirectTcpipStream {
|
||||
this.pendingReads = [];
|
||||
try {
|
||||
this.controller?.close();
|
||||
} catch {}
|
||||
} catch {
|
||||
/* 流可能已终断,关闭重复调用可忽略 */
|
||||
}
|
||||
this.controller = null;
|
||||
void this.closeChannel().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -153,7 +153,8 @@ export class SSHSessionDO {
|
||||
}
|
||||
|
||||
let prefilledConfig: SSHConnectionConfig | null = null;
|
||||
const sessionName = url.searchParams.get('session') || `session:${Date.now()}:${Math.random()}`;
|
||||
const sessionName =
|
||||
url.searchParams.get('session') || `session:${Date.now()}:${crypto.randomUUID()}`;
|
||||
|
||||
if (request.method === 'POST') {
|
||||
try {
|
||||
@@ -733,7 +734,7 @@ export class SSHSessionDO {
|
||||
this.sessions.set(ws, session);
|
||||
|
||||
// 生成 256 位加密随机 Token 并下发会话凭据
|
||||
const tokenSessionId = sessionName || `session:${Date.now()}:${Math.random()}`;
|
||||
const tokenSessionId = sessionName || `session:${Date.now()}:${crypto.randomUUID()}`;
|
||||
const resumeToken =
|
||||
crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
|
||||
this.sessionToSessionId.set(session, tokenSessionId);
|
||||
|
||||
+23
-3
@@ -413,15 +413,25 @@ async function handleServersRoute(request: Request, url: URL, env: Env): Promise
|
||||
);
|
||||
}
|
||||
if (request.method === 'POST') {
|
||||
const body = await request.json<{ expiresInMinutes?: number; maxSessionMinutes?: number }>();
|
||||
const body = await request.json<{
|
||||
expiresInMinutes?: number;
|
||||
maxSessionMinutes?: number;
|
||||
auditRetentionDays?: number;
|
||||
}>();
|
||||
const expiresInMinutes = Number(body.expiresInMinutes);
|
||||
const maxSessionMinutes = Number(body.maxSessionMinutes);
|
||||
// 审计保留天数:缺省 90;白名单与前端选项一致
|
||||
const auditRetentionDays =
|
||||
body.auditRetentionDays === undefined ? 90 : Number(body.auditRetentionDays);
|
||||
if (![5, 15, 30, 60].includes(expiresInMinutes)) {
|
||||
return Response.json({ error: 'Invalid share expiry' }, { status: 400 });
|
||||
}
|
||||
if (![15, 30, 60, 120].includes(maxSessionMinutes)) {
|
||||
return Response.json({ error: 'Invalid maximum session duration' }, { status: 400 });
|
||||
}
|
||||
if (![7, 30, 90, 180, 365].includes(auditRetentionDays)) {
|
||||
return Response.json({ error: 'Invalid audit retention' }, { status: 400 });
|
||||
}
|
||||
|
||||
const token = createShareToken();
|
||||
const shareRef = await hashShareToken(token);
|
||||
@@ -461,6 +471,7 @@ async function handleServersRoute(request: Request, url: URL, env: Env): Promise
|
||||
serverName: metadata.serverName,
|
||||
expiresAt,
|
||||
maxSessionSeconds: maxSessionMinutes * 60,
|
||||
auditRetentionDays,
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -864,7 +875,7 @@ async function handleSSHConnection(request: Request, env: Env): Promise<Response
|
||||
return Response.json({ error: 'GitHub authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionName = `session:${Date.now()}:${Math.random()}`;
|
||||
const sessionName = `session:${Date.now()}:${crypto.randomUUID()}`;
|
||||
const doId = env.SSH_SESSION.idFromName(sessionName);
|
||||
// 匿名路径不做自动推断(Worker 在 upgrade 时拿不到 host);
|
||||
// 仅尊重用户通过前端下拉手动传入的 ?region= 覆盖值
|
||||
@@ -1001,6 +1012,15 @@ async function handleShareOwnerRoute(request: Request, url: URL, env: Env): Prom
|
||||
)
|
||||
);
|
||||
}
|
||||
if (url.pathname.endsWith('/audit') && request.method === 'DELETE') {
|
||||
return shareStub.fetch(
|
||||
new Request('http://internal/internal/audit/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ownerUserId: user.id }),
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!url.pathname.endsWith('/audit') && request.method === 'DELETE') {
|
||||
return shareStub.fetch(new Request('http://internal/internal/revoke', { method: 'POST' }));
|
||||
}
|
||||
@@ -1126,7 +1146,7 @@ async function handleTokenSSHConnection(
|
||||
);
|
||||
}
|
||||
|
||||
const sessionName = `session:${Date.now()}:${Math.random()}`;
|
||||
const sessionName = `session:${Date.now()}:${crypto.randomUUID()}`;
|
||||
const doId = env.SSH_SESSION.idFromName(sessionName);
|
||||
// Token 路径:locationHint 由 user-db.handleConnectServer 按最外层直连节点计算并写入 config
|
||||
// (优先级:入口服务器手动 region → 入口 DB 持久化 inferred_hint → undefined)
|
||||
|
||||
+157
-10
@@ -2,6 +2,12 @@ import type { Env, SSHConnectionConfig } from '../types';
|
||||
|
||||
const MAX_AUDIT_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_AUDIT_EVENTS = 5000;
|
||||
|
||||
/** 审计保留期默认值(天):创建分享时可按链接自定义(7–365)。 */
|
||||
const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
const TERMINAL_SHARE_STATUSES: ReadonlySet<ShareStatus> = new Set(['closed', 'revoked', 'expired']);
|
||||
const CONNECT_TICKET_TTL_MS = 60_000;
|
||||
|
||||
type ShareStatus = 'unused' | 'claimed' | 'active' | 'closed' | 'revoked' | 'expired';
|
||||
@@ -25,6 +31,8 @@ interface ShareStateRow {
|
||||
ticket_expires_at: number | null;
|
||||
audit_bytes: number;
|
||||
device_pub_key: string | null;
|
||||
audit_purge_due: number | null;
|
||||
audit_retention_days: number | null;
|
||||
}
|
||||
|
||||
interface ShareInitBody {
|
||||
@@ -36,6 +44,8 @@ interface ShareInitBody {
|
||||
serverName: string;
|
||||
expiresAt: number;
|
||||
maxSessionSeconds: number;
|
||||
/** 审计明细保留天数(7–365);缺省时服务端取默认 90 天。 */
|
||||
auditRetentionDays?: number;
|
||||
}
|
||||
|
||||
function jsonError(error: string, status: number): Response {
|
||||
@@ -117,6 +127,18 @@ export class SSHShareDO {
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
// 迁移:审计保留期到期时间(终态后自动清理调度用)。
|
||||
try {
|
||||
this.db.exec('ALTER TABLE share_state ADD COLUMN audit_purge_due INTEGER');
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
// 迁移:审计保留天数(创建时可自定义;NULL 表示使用默认 90 天)。
|
||||
try {
|
||||
this.db.exec('ALTER TABLE share_state ADD COLUMN audit_retention_days INTEGER');
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
@@ -148,6 +170,9 @@ export class SSHShareDO {
|
||||
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 500));
|
||||
return this.ownerView(ownerUserId, after, limit);
|
||||
}
|
||||
if (url.pathname === '/internal/audit/purge' && request.method === 'POST') {
|
||||
return this.purgeAudit(await request.json<{ ownerUserId?: number }>());
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
} catch (error) {
|
||||
console.error('SSHShareDO error:', error instanceof Error ? error.message : String(error));
|
||||
@@ -159,8 +184,18 @@ export class SSHShareDO {
|
||||
const share = this.getShare();
|
||||
if (!share) return;
|
||||
const now = Date.now();
|
||||
// 审计保留期:终态满 90 天自动清除明细并写入自动清理墓碑
|
||||
if (
|
||||
TERMINAL_SHARE_STATUSES.has(share.status) &&
|
||||
share.audit_purge_due !== null &&
|
||||
now >= share.audit_purge_due
|
||||
) {
|
||||
await this.purgeAuditContent(share, 'share.audit_auto_purged');
|
||||
this.db.exec('UPDATE share_state SET audit_purge_due = NULL');
|
||||
return;
|
||||
}
|
||||
if (share.status === 'unused' && now >= share.expires_at) {
|
||||
this.updateStatus(share, 'expired', now);
|
||||
await this.updateStatus(share, 'expired', now);
|
||||
await this.syncOwnerMetadata(share, 'expired', { closedAt: now });
|
||||
return;
|
||||
}
|
||||
@@ -193,11 +228,24 @@ export class SSHShareDO {
|
||||
) {
|
||||
return jsonError('Invalid maximum session duration', 400);
|
||||
}
|
||||
// 审计保留天数:可选;未提供时存 NULL(运行时取默认 90 天)。
|
||||
let auditRetentionDays: number | null = null;
|
||||
if (body.auditRetentionDays !== undefined) {
|
||||
if (
|
||||
!Number.isInteger(body.auditRetentionDays) ||
|
||||
body.auditRetentionDays < 7 ||
|
||||
body.auditRetentionDays > 365
|
||||
) {
|
||||
return jsonError('Invalid audit retention', 400);
|
||||
}
|
||||
auditRetentionDays = body.auditRetentionDays;
|
||||
}
|
||||
this.db.exec(
|
||||
`INSERT INTO share_state (
|
||||
singleton, share_id, token_hash, owner_user_id, owner_github_id,
|
||||
server_id, server_name, expires_at, max_session_seconds, status
|
||||
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`,
|
||||
server_id, server_name, expires_at, max_session_seconds,
|
||||
audit_retention_days, status
|
||||
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`,
|
||||
body.shareId,
|
||||
body.tokenHash,
|
||||
body.ownerUserId,
|
||||
@@ -205,7 +253,8 @@ export class SSHShareDO {
|
||||
body.serverId,
|
||||
body.serverName,
|
||||
body.expiresAt,
|
||||
body.maxSessionSeconds
|
||||
body.maxSessionSeconds,
|
||||
auditRetentionDays
|
||||
);
|
||||
await this.state.storage.setAlarm(body.expiresAt);
|
||||
return Response.json({ success: true });
|
||||
@@ -225,7 +274,7 @@ export class SSHShareDO {
|
||||
if (share.status !== 'unused')
|
||||
return jsonError('This share link has already been used or revoked', 409);
|
||||
if (now >= share.expires_at) {
|
||||
this.updateStatus(share, 'expired', now);
|
||||
await this.updateStatus(share, 'expired', now);
|
||||
await this.syncOwnerMetadata(share, 'expired', { closedAt: now });
|
||||
return jsonError('This share link has expired', 410);
|
||||
}
|
||||
@@ -303,7 +352,7 @@ export class SSHShareDO {
|
||||
if (!configResponse.ok) {
|
||||
const error = await configResponse.text();
|
||||
await this.appendAudit('session.connection_failed', { status: configResponse.status }, now);
|
||||
this.updateStatus(active, 'closed', now);
|
||||
await this.updateStatus(active, 'closed', now);
|
||||
await this.syncOwnerMetadata(active, 'closed', { closedAt: now });
|
||||
return new Response(error, {
|
||||
status: configResponse.status,
|
||||
@@ -357,7 +406,7 @@ export class SSHShareDO {
|
||||
}
|
||||
const now = Date.now();
|
||||
await this.appendAudit('session.closed', { normal: body.normal === true }, now);
|
||||
this.updateStatus(share, 'closed', now);
|
||||
await this.updateStatus(share, 'closed', now);
|
||||
await this.syncOwnerMetadata(share, 'closed', { closedAt: now });
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
@@ -370,7 +419,7 @@ export class SSHShareDO {
|
||||
}
|
||||
const now = Date.now();
|
||||
await this.appendAudit(`share.${status}`, {}, now);
|
||||
this.updateStatus(share, status, now);
|
||||
await this.updateStatus(share, status, now);
|
||||
if (share.session_name) {
|
||||
const sessionStub = this.env.SSH_SESSION.get(
|
||||
this.env.SSH_SESSION.idFromName(share.session_name)
|
||||
@@ -389,17 +438,89 @@ export class SSHShareDO {
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
/** 分享者清空终态会话的全部审计明细,写入墓碑事件保留追责线索。 */
|
||||
private async purgeAudit(body: { ownerUserId?: number }): Promise<Response> {
|
||||
const share = this.getShare();
|
||||
if (!share) return jsonError('Share not found', 404);
|
||||
if (!Number.isInteger(body.ownerUserId) || body.ownerUserId !== share.owner_user_id) {
|
||||
return jsonError('Forbidden', 403);
|
||||
}
|
||||
if (!TERMINAL_SHARE_STATUSES.has(share.status)) {
|
||||
return jsonError('Share session is not finished', 409);
|
||||
}
|
||||
await this.purgeAuditContent(share, 'share.audit_purged');
|
||||
this.db.exec('UPDATE share_state SET audit_purge_due = NULL');
|
||||
// 手动清空即代表不再需要自动清理:取消已排期的唤醒,避免 90 天后一次无效唤起
|
||||
try {
|
||||
await this.state.storage.deleteAlarm();
|
||||
} catch {
|
||||
/* 当前无闹钟时忽略 */
|
||||
}
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
/** 清空审计明细并写入墓碑;重置 audit_bytes。手动清空与到期自动清理共用。 */
|
||||
private async purgeAuditContent(share: ShareStateRow, eventType: string): Promise<void> {
|
||||
const occurredAt = Date.now();
|
||||
this.db.exec('DELETE FROM audit_events');
|
||||
this.db.exec('UPDATE share_state SET audit_bytes = 0');
|
||||
await this.appendAudit(eventType, {}, occurredAt);
|
||||
await this.notifyOwnerAuditPurged(
|
||||
share,
|
||||
eventType === 'share.audit_auto_purged' ? 'auto' : 'manual',
|
||||
occurredAt
|
||||
);
|
||||
}
|
||||
|
||||
/** 尽力同步清理留痕到所有者 UserDBDO(管理端集中展示);失败不影响已完成的清理。 */
|
||||
private async notifyOwnerAuditPurged(
|
||||
share: ShareStateRow,
|
||||
purgeType: 'manual' | 'auto',
|
||||
occurredAt: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stub = this.env.USER_DB.get(this.env.USER_DB.idFromName(share.owner_github_id));
|
||||
const response = await stub.fetch(
|
||||
new Request(`http://internal/internal/shares/${share.share_id}/audit-purged`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: share.owner_user_id,
|
||||
purged_at: occurredAt,
|
||||
purge_type: purgeType,
|
||||
}),
|
||||
})
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.error('SSHShareDO: failed to sync audit purge trace:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SSHShareDO: failed to sync audit purge trace:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private ownerView(ownerUserId: number, after: number, limit: number): Response {
|
||||
const share = this.getShare();
|
||||
if (!share || share.owner_user_id !== ownerUserId) return jsonError('Forbidden', 403);
|
||||
// 清理墓碑事件(purgeAuditContent 写入的两种 event_type)不进入常规列表,
|
||||
// 单独作为 removals 返回供前端折叠面板展示;SQL 字面量为编译期常量。
|
||||
const events = this.db
|
||||
.exec(
|
||||
`SELECT id, occurred_at, event_type, details FROM audit_events
|
||||
WHERE id > ? ORDER BY id ASC LIMIT ?`,
|
||||
WHERE id > ?
|
||||
AND event_type NOT IN ('share.audit_purged', 'share.audit_auto_purged')
|
||||
ORDER BY id ASC LIMIT ?`,
|
||||
after,
|
||||
limit + 1
|
||||
)
|
||||
.toArray() as Array<{ id: number; occurred_at: number; event_type: string; details: string }>;
|
||||
const removalRows = this.db
|
||||
.exec(
|
||||
`SELECT occurred_at, event_type FROM audit_events
|
||||
WHERE event_type IN ('share.audit_purged', 'share.audit_auto_purged')
|
||||
ORDER BY occurred_at DESC`
|
||||
)
|
||||
.toArray() as Array<{ occurred_at: number; event_type: string }>;
|
||||
const hasMore = events.length > limit;
|
||||
const visible = events.slice(0, limit).map((event) => ({
|
||||
id: event.id,
|
||||
@@ -420,6 +541,10 @@ export class SSHShareDO {
|
||||
auditBytes: share.audit_bytes,
|
||||
},
|
||||
events: visible,
|
||||
removals: removalRows.map((row) => ({
|
||||
occurredAt: row.occurred_at,
|
||||
eventType: row.event_type,
|
||||
})),
|
||||
hasMore,
|
||||
nextAfter: visible.at(-1)?.id ?? after,
|
||||
});
|
||||
@@ -430,7 +555,11 @@ export class SSHShareDO {
|
||||
return rows.length ? (rows[0] as ShareStateRow) : null;
|
||||
}
|
||||
|
||||
private updateStatus(share: ShareStateRow, status: ShareStatus, closedAt: number): void {
|
||||
private async updateStatus(
|
||||
share: ShareStateRow,
|
||||
status: ShareStatus,
|
||||
closedAt: number
|
||||
): Promise<void> {
|
||||
this.db.exec(
|
||||
`UPDATE share_state SET status = ?, closed_at = ?, ticket_hash = NULL,
|
||||
ticket_expires_at = NULL WHERE singleton = 1`,
|
||||
@@ -439,6 +568,24 @@ export class SSHShareDO {
|
||||
);
|
||||
share.status = status;
|
||||
share.closed_at = closedAt;
|
||||
// 终态进入审计保留期:到期自动清理调度;无审计明细则跳过
|
||||
if (TERMINAL_SHARE_STATUSES.has(status)) {
|
||||
const count = Number(this.db.exec('SELECT COUNT(*) AS count FROM audit_events').one().count);
|
||||
if (count > 0) {
|
||||
const retentionDays = share.audit_retention_days ?? DEFAULT_AUDIT_RETENTION_DAYS;
|
||||
const due = Date.now() + retentionDays * MS_PER_DAY;
|
||||
this.db.exec('UPDATE share_state SET audit_purge_due = ?', due);
|
||||
try {
|
||||
await this.state.storage.setAlarm(due);
|
||||
share.audit_purge_due = due;
|
||||
} catch (error) {
|
||||
// 排期失败必须回滚:否则库里留下“有排期但无闹钟”的幽灵状态,自动清理将永不触发
|
||||
console.error('SSHShareDO: failed to schedule audit purge alarm:', error);
|
||||
this.db.exec('UPDATE share_state SET audit_purge_due = NULL');
|
||||
share.audit_purge_due = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async appendAudit(
|
||||
|
||||
+257
-195
@@ -2,12 +2,10 @@ import { normalizeSnippetInput, SNIPPET_MAX_COUNT } from '../snippet-schema';
|
||||
import {
|
||||
ALLOWED_LOCATION_HINTS,
|
||||
type Env,
|
||||
type ServerConfig,
|
||||
type SSHConnectionConfig,
|
||||
type SSHJumpHostConfig,
|
||||
type UserInfo,
|
||||
} from '../types';
|
||||
import { type InferResult, inferLocationHint } from './ip-geo';
|
||||
import { inferLocationHint } from './ip-geo';
|
||||
import { isDetectedOS } from './os-detect';
|
||||
import { deserializeServerRow, serializeServerTags } from './server-tags';
|
||||
|
||||
@@ -29,6 +27,42 @@ interface StoredServerRow {
|
||||
jump_server_id: number | null;
|
||||
}
|
||||
|
||||
// ====== 行形状帮助类型:与 UserDBDO 内各 SELECT 列一一对应(配合 query<T> 消除逐处 as unknown as) ======
|
||||
type UserRow = { id: number; github_id: number; username: string; avatar_url: string };
|
||||
type ServerRow = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: string;
|
||||
region: string | null;
|
||||
inferred_hint: string | null;
|
||||
tags: string;
|
||||
os: string | null;
|
||||
jump_server_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
type UserIdRow = { user_id: number };
|
||||
type JumpRow = { user_id: number; jump_server_id: number | null };
|
||||
type ServerEditRow = {
|
||||
user_id: number;
|
||||
host: string;
|
||||
port: number;
|
||||
auth_method: string;
|
||||
region: string | null;
|
||||
inferred_hint: string | null;
|
||||
jump_server_id: number | null;
|
||||
};
|
||||
type ServerNameRow = { name: string };
|
||||
type ShareMetaRow = { user_id: number; server_id: number; share_ref: string; status: string };
|
||||
type ThemeRow = { theme_data: string };
|
||||
type FingerprintRow = { fingerprint: string };
|
||||
type AIConfigRow = { base_url: string; model: string; api_key_last4: string; updated_at: string };
|
||||
type AIConfigSecretRow = { base_url: string; model: string; api_key_enc: string };
|
||||
|
||||
/**
|
||||
* UserDBDO — 按 GitHub 用户 ID 命名并隔离的用户数据库 Durable Object
|
||||
*
|
||||
@@ -39,7 +73,6 @@ interface StoredServerRow {
|
||||
* - One-time-token 生成与消费(安全传递凭据)
|
||||
*/
|
||||
export class UserDBDO {
|
||||
private state: DurableObjectState;
|
||||
private env: Env;
|
||||
private db: any; // SqlStorage (DO SQLite)
|
||||
// one-time-token 内存存储:token → { config, expiresAt }
|
||||
@@ -49,12 +82,24 @@ export class UserDBDO {
|
||||
private derivedKeyCache: Map<number, CryptoKey> = new Map();
|
||||
|
||||
constructor(state: DurableObjectState, env: Env) {
|
||||
this.state = state;
|
||||
this.env = env;
|
||||
this.db = (state.storage as any).sql;
|
||||
this.initSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* SqlStorage 无类型句柄的薄封装:行形状由调用方按 SELECT 列声明(T),
|
||||
* 消除逐处 as unknown as。仅编译期 cast,运行时行为与 toArray() 完全一致。
|
||||
*/
|
||||
private query<T>(sql: string, ...params: unknown[]): T[] {
|
||||
return this.db.exec(sql, ...params).toArray() as T[];
|
||||
}
|
||||
|
||||
private one<T>(sql: string, ...params: unknown[]): T | null {
|
||||
const rows = this.query<T>(sql, ...params);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
}
|
||||
|
||||
private initSchema(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS system_config (
|
||||
@@ -175,6 +220,16 @@ export class UserDBDO {
|
||||
this.db.exec('ALTER TABLE servers ADD COLUMN jump_server_id INTEGER DEFAULT NULL');
|
||||
}
|
||||
this.db.exec('CREATE INDEX IF NOT EXISTS idx_servers_jump ON servers(jump_server_id)');
|
||||
|
||||
// === Migration: 给既有 ssh_shares 表追加审计清理留痕列(幂等) ===
|
||||
// SSHShareDO 清理审计后同步写入;管理端据此隐藏查看入口并集中展示清理记录
|
||||
const shareCols = this.db.exec('PRAGMA table_info(ssh_shares)').toArray();
|
||||
if (!shareCols.some((c: any) => c.name === 'audit_purged_at')) {
|
||||
this.db.exec('ALTER TABLE ssh_shares ADD COLUMN audit_purged_at INTEGER DEFAULT NULL');
|
||||
}
|
||||
if (!shareCols.some((c: any) => c.name === 'audit_purge_type')) {
|
||||
this.db.exec('ALTER TABLE ssh_shares ADD COLUMN audit_purge_type TEXT DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
@@ -250,6 +305,11 @@ export class UserDBDO {
|
||||
if (shareStatusMatch && request.method === 'PUT') {
|
||||
return this.handleUpdateShareStatus(shareStatusMatch[1], request);
|
||||
}
|
||||
// /internal/shares/:id/audit-purged —— 仅由 SSHShareDO 清理审计后调用
|
||||
const sharePurgedMatch = path.match(/^\/internal\/shares\/([^/]+)\/audit-purged$/);
|
||||
if (sharePurgedMatch && request.method === 'POST') {
|
||||
return this.handleShareAuditPurged(sharePurgedMatch[1], request);
|
||||
}
|
||||
|
||||
// /internal/servers/:id/os —— 仅由 SSHSession(可信会话)通过 DO stub 调用
|
||||
const osMatch = path.match(/^\/internal\/servers\/(\d+)\/os$/);
|
||||
@@ -346,10 +406,11 @@ export class UserDBDO {
|
||||
avatar_url: string;
|
||||
}>();
|
||||
|
||||
// Upsert 用户
|
||||
const existing = this.db
|
||||
.exec('SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', github_id)
|
||||
.toArray();
|
||||
// Upsert 用户(行形状:users 的 id/github_id/username/avatar_url 列)
|
||||
const existing = this.query<UserRow>(
|
||||
'SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?',
|
||||
github_id
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 更新用户信息
|
||||
@@ -359,7 +420,7 @@ export class UserDBDO {
|
||||
avatar_url,
|
||||
github_id
|
||||
);
|
||||
const user = existing[0] as unknown as UserInfo;
|
||||
const user = existing[0];
|
||||
user.username = username;
|
||||
user.avatar_url = avatar_url;
|
||||
return Response.json(user);
|
||||
@@ -373,9 +434,10 @@ export class UserDBDO {
|
||||
avatar_url
|
||||
);
|
||||
|
||||
const newUser = this.db
|
||||
.exec('SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', github_id)
|
||||
.toArray()[0] as unknown as UserInfo;
|
||||
const newUser = this.query<UserRow>(
|
||||
'SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?',
|
||||
github_id
|
||||
)[0];
|
||||
|
||||
return Response.json(newUser);
|
||||
}
|
||||
@@ -422,20 +484,18 @@ export class UserDBDO {
|
||||
private async handleSessionVerify(request: Request): Promise<Response> {
|
||||
const { token } = await request.json<{ token: string }>();
|
||||
|
||||
const rows = this.db
|
||||
.exec(
|
||||
`SELECT u.id, u.github_id, u.username, u.avatar_url
|
||||
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token = ? AND s.expires_at > datetime('now')`,
|
||||
token
|
||||
)
|
||||
.toArray();
|
||||
const rows = this.query<UserRow>(
|
||||
`SELECT u.id, u.github_id, u.username, u.avatar_url
|
||||
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token = ? AND s.expires_at > datetime('now')`,
|
||||
token
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return Response.json({ error: 'Invalid or expired session' }, { status: 401 });
|
||||
}
|
||||
|
||||
return Response.json(rows[0] as unknown as UserInfo);
|
||||
return Response.json(rows[0]);
|
||||
}
|
||||
|
||||
private async handleSessionDelete(request: Request): Promise<Response> {
|
||||
@@ -447,19 +507,13 @@ export class UserDBDO {
|
||||
// ==================== 服务器 CRUD ====================
|
||||
|
||||
private handleGetServers(userId: number): Response {
|
||||
const rows = this.db
|
||||
.exec(
|
||||
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
|
||||
FROM servers WHERE user_id = ? ORDER BY updated_at DESC`,
|
||||
userId
|
||||
)
|
||||
.toArray();
|
||||
|
||||
return Response.json(
|
||||
rows.map((row: Record<string, unknown>) =>
|
||||
deserializeServerRow(row)
|
||||
) as unknown as ServerConfig[]
|
||||
const rows = this.query<ServerRow>(
|
||||
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
|
||||
FROM servers WHERE user_id = ? ORDER BY updated_at DESC`,
|
||||
userId
|
||||
);
|
||||
|
||||
return Response.json(rows.map((row) => deserializeServerRow(row)));
|
||||
}
|
||||
|
||||
private validateJumpChain(
|
||||
@@ -481,11 +535,12 @@ export class UserDBDO {
|
||||
depth++;
|
||||
if (depth > MAX_JUMP_HOSTS) return `最多允许 ${MAX_JUMP_HOSTS} 级 SSH 跳转`;
|
||||
|
||||
const rows = this.db
|
||||
.exec('SELECT user_id, jump_server_id FROM servers WHERE id = ?', currentId)
|
||||
.toArray();
|
||||
const rows: JumpRow[] = this.query<JumpRow>(
|
||||
'SELECT user_id, jump_server_id FROM servers WHERE id = ?',
|
||||
currentId
|
||||
);
|
||||
if (rows.length === 0) return '所选跳板服务器不存在';
|
||||
const row = rows[0] as unknown as { user_id: number; jump_server_id: number | null };
|
||||
const row = rows[0];
|
||||
if (row.user_id !== userId) return '不能使用其他用户的服务器作为跳板';
|
||||
currentId = row.jump_server_id ?? null;
|
||||
}
|
||||
@@ -572,18 +627,15 @@ export class UserDBDO {
|
||||
);
|
||||
|
||||
// 获取新创建的记录
|
||||
const rows = this.db
|
||||
.exec(
|
||||
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
|
||||
FROM servers WHERE user_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
body.user_id
|
||||
)
|
||||
.toArray();
|
||||
const rows = this.query<ServerRow>(
|
||||
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
|
||||
FROM servers WHERE user_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
body.user_id
|
||||
);
|
||||
|
||||
// DEBUG_MODE 开启时,在响应中附带调试信息
|
||||
const server = deserializeServerRow(
|
||||
rows[0] as Record<string, unknown>
|
||||
) as unknown as ServerConfig;
|
||||
// 行形状由上方 SELECT 的 servers 全列定义;deserializeServerRow 归一化 tags 后直接序列化
|
||||
const server = deserializeServerRow(rows[0]);
|
||||
if (this.env.DEBUG_MODE === 'true') {
|
||||
return Response.json({ ...server, _debug: inferDebug }, { status: 201 });
|
||||
}
|
||||
@@ -604,23 +656,13 @@ export class UserDBDO {
|
||||
jump_server_id?: number | null;
|
||||
}>();
|
||||
|
||||
// 验证服务器属于该用户
|
||||
const existing = this.db
|
||||
.exec(
|
||||
'SELECT user_id, host, port, auth_method, region, inferred_hint, jump_server_id FROM servers WHERE id = ?',
|
||||
serverId
|
||||
)
|
||||
.toArray();
|
||||
// 验证服务器属于该用户(行形状:user_id/host/port/auth_method/region/inferred_hint/jump_server_id)
|
||||
const existing = this.query<ServerEditRow>(
|
||||
'SELECT user_id, host, port, auth_method, region, inferred_hint, jump_server_id FROM servers WHERE id = ?',
|
||||
serverId
|
||||
);
|
||||
if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
|
||||
const current = existing[0] as unknown as {
|
||||
user_id: number;
|
||||
host: string;
|
||||
port: number;
|
||||
auth_method: string;
|
||||
region: string | null;
|
||||
inferred_hint: string | null;
|
||||
jump_server_id: number | null;
|
||||
};
|
||||
const current = existing[0];
|
||||
if (current.user_id !== body.user_id)
|
||||
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
@@ -645,14 +687,15 @@ export class UserDBDO {
|
||||
|
||||
// 历史下游节点可能残留区域值;从跳板切回直连且请求未显式指定区域时,
|
||||
// 应回到 Auto,而不是复用一个此前从未参与连接调度的旧值。
|
||||
const requestedRegion =
|
||||
body.region === undefined
|
||||
? current.jump_server_id === null
|
||||
? current.region
|
||||
: null
|
||||
: (ALLOWED_LOCATION_HINTS as readonly string[]).includes(body.region)
|
||||
? body.region
|
||||
: null;
|
||||
// 未提供且直连:沿用旧区域;跳板连接:清空;提供但非法:视为未提供(Auto)
|
||||
let requestedRegion: string | null = null;
|
||||
if (body.region !== undefined) {
|
||||
if ((ALLOWED_LOCATION_HINTS as readonly string[]).includes(body.region)) {
|
||||
requestedRegion = body.region;
|
||||
}
|
||||
} else if (current.jump_server_id === null) {
|
||||
requestedRegion = current.region;
|
||||
}
|
||||
const normalizedRegion = nextJumpServerId === null ? requestedRegion : null;
|
||||
const isDirect = nextJumpServerId === null;
|
||||
const becameDirect = current.jump_server_id !== null && isDirect;
|
||||
@@ -744,38 +787,34 @@ export class UserDBDO {
|
||||
if (currentJumpError) return Response.json({ error: currentJumpError }, { status: 400 });
|
||||
updates.push("updated_at = datetime('now')");
|
||||
values.push(serverId);
|
||||
// SET 片段仅来自本函数硬编码白名单(列名 + '?' 或固定字面量),动态值全部经 values 参数绑定
|
||||
// pi-lens-ignore: sql-injection
|
||||
this.db.exec(`UPDATE servers SET ${updates.join(', ')} WHERE id = ?`, ...values);
|
||||
}
|
||||
|
||||
const row = this.db
|
||||
.exec(
|
||||
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
|
||||
FROM servers WHERE id = ?`,
|
||||
serverId
|
||||
)
|
||||
.toArray();
|
||||
|
||||
return Response.json(
|
||||
deserializeServerRow(row[0] as Record<string, unknown>) as unknown as ServerConfig
|
||||
const row = this.query<ServerRow>(
|
||||
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
|
||||
FROM servers WHERE id = ?`,
|
||||
serverId
|
||||
);
|
||||
|
||||
return Response.json(deserializeServerRow(row[0]));
|
||||
}
|
||||
|
||||
private async handleDeleteServer(serverId: number, request: Request): Promise<Response> {
|
||||
const body = await request.json<{ user_id: number }>();
|
||||
|
||||
// 验证服务器属于该用户
|
||||
const existing = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray();
|
||||
const existing = this.query<UserIdRow>('SELECT user_id FROM servers WHERE id = ?', serverId);
|
||||
if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
|
||||
if ((existing[0] as unknown as { user_id: number }).user_id !== body.user_id)
|
||||
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const ownerUserId = existing[0].user_id;
|
||||
if (ownerUserId !== body.user_id) return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const references = this.db
|
||||
.exec(
|
||||
'SELECT name FROM servers WHERE user_id = ? AND jump_server_id = ? ORDER BY name LIMIT 5',
|
||||
body.user_id,
|
||||
serverId
|
||||
)
|
||||
.toArray() as unknown as Array<{ name: string }>;
|
||||
const references = this.query<ServerNameRow>(
|
||||
'SELECT name FROM servers WHERE user_id = ? AND jump_server_id = ? ORDER BY name LIMIT 5',
|
||||
body.user_id,
|
||||
serverId
|
||||
);
|
||||
if (references.length > 0) {
|
||||
return Response.json(
|
||||
{
|
||||
@@ -786,14 +825,12 @@ export class UserDBDO {
|
||||
}
|
||||
|
||||
const activeShares = Number(
|
||||
this.db
|
||||
.exec(
|
||||
`SELECT COUNT(*) AS count FROM ssh_shares
|
||||
(this.one<{ count: number }>(
|
||||
`SELECT COUNT(*) AS count FROM ssh_shares
|
||||
WHERE user_id = ? AND server_id = ? AND status IN ('unused', 'claimed', 'active')`,
|
||||
body.user_id,
|
||||
serverId
|
||||
)
|
||||
.one().count
|
||||
body.user_id,
|
||||
serverId
|
||||
) ?? { count: 0 }).count
|
||||
);
|
||||
if (activeShares > 0) {
|
||||
return Response.json(
|
||||
@@ -814,9 +851,10 @@ export class UserDBDO {
|
||||
const body = await request.json<{ user_id: number; os: string }>();
|
||||
|
||||
// 验证服务器属于该用户
|
||||
const existing = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray();
|
||||
const existing = this.query<UserIdRow>('SELECT user_id FROM servers WHERE id = ?', serverId);
|
||||
if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
|
||||
if ((existing[0] as unknown as { user_id: number }).user_id !== body.user_id) {
|
||||
const ownerUserId = existing[0].user_id;
|
||||
if (ownerUserId !== body.user_id) {
|
||||
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -834,9 +872,10 @@ export class UserDBDO {
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return Response.json({ error: 'Invalid user_id' }, { status: 400 });
|
||||
}
|
||||
const server = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray();
|
||||
const server = this.query<UserIdRow>('SELECT user_id FROM servers WHERE id = ?', serverId);
|
||||
if (server.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
|
||||
if ((server[0] as unknown as { user_id: number }).user_id !== userId) {
|
||||
const ownerUserId = server[0].user_id;
|
||||
if (ownerUserId !== userId) {
|
||||
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
const now = Date.now();
|
||||
@@ -851,7 +890,8 @@ export class UserDBDO {
|
||||
const rows = this.db
|
||||
.exec(
|
||||
`SELECT id, server_id, expires_at, max_session_seconds, status,
|
||||
claimed_at, active_at, closed_at, created_at
|
||||
claimed_at, active_at, closed_at, created_at,
|
||||
audit_purged_at, audit_purge_type
|
||||
FROM ssh_shares WHERE user_id = ? AND server_id = ?
|
||||
ORDER BY created_at DESC LIMIT 50`,
|
||||
userId,
|
||||
@@ -869,6 +909,8 @@ export class UserDBDO {
|
||||
activeAt: row.active_at,
|
||||
closedAt: row.closed_at,
|
||||
createdAt: row.created_at,
|
||||
auditPurgedAt: row.audit_purged_at ?? null,
|
||||
auditPurgeType: row.audit_purge_type ?? null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -915,14 +957,12 @@ export class UserDBDO {
|
||||
const validation = this.validateShareableServer(serverId, body.user_id);
|
||||
if (validation instanceof Response) return validation;
|
||||
const activeCount = Number(
|
||||
this.db
|
||||
.exec(
|
||||
`SELECT COUNT(*) AS count FROM ssh_shares
|
||||
(this.one<{ count: number }>(
|
||||
`SELECT COUNT(*) AS count FROM ssh_shares
|
||||
WHERE user_id = ? AND status IN ('unused', 'claimed', 'active') AND expires_at > ?`,
|
||||
body.user_id,
|
||||
now
|
||||
)
|
||||
.one().count
|
||||
body.user_id,
|
||||
now
|
||||
) ?? { count: 0 }).count
|
||||
);
|
||||
if (activeCount >= 10) {
|
||||
return Response.json(
|
||||
@@ -967,9 +1007,9 @@ export class UserDBDO {
|
||||
return Response.json({ error: '跳板服务器关系存在循环' }, { status: 400 });
|
||||
}
|
||||
seen.add(currentId);
|
||||
const rows = this.db.exec('SELECT * FROM servers WHERE id = ?', currentId).toArray();
|
||||
const rows: StoredServerRow[] = this.query<StoredServerRow>('SELECT * FROM servers WHERE id = ?', currentId);
|
||||
if (rows.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
|
||||
const row = rows[0] as unknown as StoredServerRow;
|
||||
const row = rows[0];
|
||||
if (row.user_id !== userId) return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
reversed.push(row);
|
||||
if (reversed.length > MAX_JUMP_HOSTS + 1) {
|
||||
@@ -1046,9 +1086,10 @@ export class UserDBDO {
|
||||
if (!Number.isInteger(body.user_id) || !allowed.has(body.status)) {
|
||||
return Response.json({ error: 'Invalid share status update' }, { status: 400 });
|
||||
}
|
||||
const rows = this.db.exec('SELECT user_id FROM ssh_shares WHERE id = ?', shareId).toArray();
|
||||
const rows = this.query<UserIdRow>('SELECT user_id FROM ssh_shares WHERE id = ?', shareId);
|
||||
if (rows.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 });
|
||||
if ((rows[0] as unknown as { user_id: number }).user_id !== body.user_id) {
|
||||
const ownerUserId = rows[0].user_id;
|
||||
if (ownerUserId !== body.user_id) {
|
||||
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
this.db.exec(
|
||||
@@ -1066,6 +1107,35 @@ export class UserDBDO {
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
/** SSHShareDO 清理审计后同步留痕:记录清理时间与方式,供管理端集中展示。 */
|
||||
private async handleShareAuditPurged(shareId: string, request: Request): Promise<Response> {
|
||||
const body = await request.json<{
|
||||
user_id: number;
|
||||
purged_at: number;
|
||||
purge_type: string;
|
||||
}>();
|
||||
if (
|
||||
!Number.isInteger(body.user_id) ||
|
||||
!Number.isFinite(body.purged_at) ||
|
||||
(body.purge_type !== 'manual' && body.purge_type !== 'auto')
|
||||
) {
|
||||
return Response.json({ error: 'Invalid audit purge update' }, { status: 400 });
|
||||
}
|
||||
const rows = this.query<UserIdRow>('SELECT user_id FROM ssh_shares WHERE id = ?', shareId);
|
||||
if (rows.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 });
|
||||
const ownerUserId = rows[0].user_id;
|
||||
if (ownerUserId !== body.user_id) {
|
||||
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
this.db.exec(
|
||||
'UPDATE ssh_shares SET audit_purged_at = ?, audit_purge_type = ? WHERE id = ?',
|
||||
body.purged_at,
|
||||
body.purge_type,
|
||||
shareId
|
||||
);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
private async handleShareConnectionConfig(serverId: number, request: Request): Promise<Response> {
|
||||
const body = await request.json<{
|
||||
user_id: number;
|
||||
@@ -1076,19 +1146,12 @@ export class UserDBDO {
|
||||
if (!body.share_id || !body.share_ref || !Number.isFinite(body.session_expires_at)) {
|
||||
return Response.json({ error: 'Invalid share session policy' }, { status: 400 });
|
||||
}
|
||||
const metadata = this.db
|
||||
.exec(
|
||||
`SELECT user_id, server_id, share_ref, status FROM ssh_shares WHERE id = ?`,
|
||||
body.share_id
|
||||
)
|
||||
.toArray();
|
||||
const metadata = this.query<ShareMetaRow>(
|
||||
`SELECT user_id, server_id, share_ref, status FROM ssh_shares WHERE id = ?`,
|
||||
body.share_id
|
||||
);
|
||||
if (metadata.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 });
|
||||
const row = metadata[0] as unknown as {
|
||||
user_id: number;
|
||||
server_id: number;
|
||||
share_ref: string;
|
||||
status: string;
|
||||
};
|
||||
const row = metadata[0];
|
||||
if (
|
||||
row.user_id !== body.user_id ||
|
||||
row.server_id !== serverId ||
|
||||
@@ -1147,9 +1210,7 @@ export class UserDBDO {
|
||||
// ==================== 用户自定义主题 ====================
|
||||
|
||||
private handleGetTheme(userId: number): Response {
|
||||
const rows = this.db
|
||||
.exec('SELECT theme_data FROM user_themes WHERE user_id = ?', userId)
|
||||
.toArray();
|
||||
const rows = this.query<ThemeRow>('SELECT theme_data FROM user_themes WHERE user_id = ?', userId);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return Response.json({ theme: null });
|
||||
@@ -1157,7 +1218,8 @@ export class UserDBDO {
|
||||
|
||||
try {
|
||||
return Response.json({
|
||||
theme: JSON.parse((rows[0] as unknown as { theme_data: string }).theme_data),
|
||||
// 行形状由上方 SELECT('theme_data') 定义,非法 JSON 由 catch 兜底为 null
|
||||
theme: JSON.parse(rows[0].theme_data),
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ theme: null });
|
||||
@@ -1191,9 +1253,9 @@ export class UserDBDO {
|
||||
return Response.json({ error: '跳板服务器关系存在循环' }, { status: 400 });
|
||||
}
|
||||
seen.add(currentId);
|
||||
const rows = this.db.exec('SELECT * FROM servers WHERE id = ?', currentId).toArray();
|
||||
const rows: StoredServerRow[] = this.query<StoredServerRow>('SELECT * FROM servers WHERE id = ?', currentId);
|
||||
if (rows.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
|
||||
const row = rows[0] as unknown as StoredServerRow;
|
||||
const row = rows[0];
|
||||
if (row.user_id !== body.user_id)
|
||||
return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
reversed.push(row);
|
||||
@@ -1212,22 +1274,18 @@ export class UserDBDO {
|
||||
index === 0 ? server.host : `jump:${pathSegments.join('>')}|${server.host}`;
|
||||
pathSegments.push(`${server.id}@${server.host}:${server.port}`);
|
||||
const credential = await this.decryptCredential(server.credential, body.user_id);
|
||||
const khRows = this.db
|
||||
.exec(
|
||||
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
|
||||
body.user_id,
|
||||
identity,
|
||||
server.port
|
||||
)
|
||||
.toArray();
|
||||
const khRows = this.query<FingerprintRow>(
|
||||
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
|
||||
body.user_id,
|
||||
identity,
|
||||
server.port
|
||||
);
|
||||
return {
|
||||
server,
|
||||
identity,
|
||||
credential,
|
||||
expectedFingerprint:
|
||||
khRows.length > 0
|
||||
? (khRows[0] as unknown as { fingerprint: string }).fingerprint
|
||||
: undefined,
|
||||
khRows.length > 0 ? khRows[0].fingerprint : undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -1412,20 +1470,18 @@ export class UserDBDO {
|
||||
private handleGetKnownHosts(userId: number, host: string | null, port: string | null): Response {
|
||||
if (host && port) {
|
||||
// 查询特定 host:port 的指纹
|
||||
const rows = this.db
|
||||
.exec(
|
||||
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
|
||||
userId,
|
||||
host,
|
||||
parseInt(port)
|
||||
)
|
||||
.toArray();
|
||||
if (rows.length === 0) {
|
||||
return Response.json({ fingerprint: null });
|
||||
}
|
||||
return Response.json({
|
||||
fingerprint: (rows[0] as unknown as { fingerprint: string }).fingerprint,
|
||||
});
|
||||
const rows = this.query<FingerprintRow>(
|
||||
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
|
||||
userId,
|
||||
host,
|
||||
parseInt(port)
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return Response.json({ fingerprint: null });
|
||||
}
|
||||
return Response.json({
|
||||
fingerprint: rows[0].fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
// 列出所有已知主机
|
||||
@@ -1483,10 +1539,12 @@ export class UserDBDO {
|
||||
// ==================== 命令片段管理 ====================
|
||||
|
||||
private handleGetSnippets(userId: number): Response {
|
||||
const rows = this.db.exec(
|
||||
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id ASC',
|
||||
userId,
|
||||
).toArray();
|
||||
const rows = this.db
|
||||
.exec(
|
||||
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id ASC',
|
||||
userId
|
||||
)
|
||||
.toArray();
|
||||
return Response.json(rows);
|
||||
}
|
||||
|
||||
@@ -1496,10 +1554,9 @@ export class UserDBDO {
|
||||
if (!normalized.ok) {
|
||||
return Response.json({ error: normalized.error }, { status: 400 });
|
||||
}
|
||||
const countRows = this.db.exec(
|
||||
'SELECT COUNT(*) AS count FROM command_snippets WHERE user_id = ?',
|
||||
body.user_id,
|
||||
).toArray();
|
||||
const countRows = this.db
|
||||
.exec('SELECT COUNT(*) AS count FROM command_snippets WHERE user_id = ?', body.user_id)
|
||||
.toArray();
|
||||
const count = Number((countRows[0] as { count?: unknown } | undefined)?.count ?? 0);
|
||||
if (count >= SNIPPET_MAX_COUNT) {
|
||||
return Response.json({ error: 'limitReached' }, { status: 400 });
|
||||
@@ -1508,12 +1565,14 @@ export class UserDBDO {
|
||||
`INSERT INTO command_snippets (user_id, name, command, created_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now'))`,
|
||||
body.user_id,
|
||||
normalized.value.name,
|
||||
normalized.value.command,
|
||||
normalized.value.command
|
||||
);
|
||||
const rows = this.db.exec(
|
||||
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id DESC LIMIT 1',
|
||||
body.user_id,
|
||||
).toArray();
|
||||
const rows = this.db
|
||||
.exec(
|
||||
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id DESC LIMIT 1',
|
||||
body.user_id
|
||||
)
|
||||
.toArray();
|
||||
return Response.json(rows[0] ?? { success: true }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -1523,12 +1582,26 @@ export class UserDBDO {
|
||||
if (!normalized.ok) {
|
||||
return Response.json({ error: normalized.error }, { status: 400 });
|
||||
}
|
||||
const existing = this.db.exec('SELECT id FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId).toArray();
|
||||
const existing = this.db
|
||||
.exec('SELECT id FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId)
|
||||
.toArray();
|
||||
if (existing.length === 0) {
|
||||
return Response.json({ error: 'notFound' }, { status: 404 });
|
||||
}
|
||||
this.db.exec(`UPDATE command_snippets SET name = ?, command = ?, updated_at = datetime('now') WHERE user_id = ? AND id = ?`, normalized.value.name, normalized.value.command, body.user_id, snippetId);
|
||||
const rows = this.db.exec('SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId).toArray();
|
||||
this.db.exec(
|
||||
`UPDATE command_snippets SET name = ?, command = ?, updated_at = datetime('now') WHERE user_id = ? AND id = ?`,
|
||||
normalized.value.name,
|
||||
normalized.value.command,
|
||||
body.user_id,
|
||||
snippetId
|
||||
);
|
||||
const rows = this.db
|
||||
.exec(
|
||||
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? AND id = ?',
|
||||
body.user_id,
|
||||
snippetId
|
||||
)
|
||||
.toArray();
|
||||
return Response.json(rows[0] ?? { success: true });
|
||||
}
|
||||
|
||||
@@ -1541,23 +1614,16 @@ export class UserDBDO {
|
||||
// ==================== AI 配置管理 ====================
|
||||
|
||||
private handleGetAIConfig(userId: number): Response {
|
||||
const rows = this.db
|
||||
.exec(
|
||||
'SELECT base_url, model, api_key_last4, updated_at FROM ai_configs WHERE user_id = ?',
|
||||
userId
|
||||
)
|
||||
.toArray();
|
||||
const rows = this.query<AIConfigRow>(
|
||||
'SELECT base_url, model, api_key_last4, updated_at FROM ai_configs WHERE user_id = ?',
|
||||
userId
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return Response.json({ configured: false });
|
||||
}
|
||||
|
||||
const row = rows[0] as unknown as {
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key_last4: string;
|
||||
updated_at: string;
|
||||
};
|
||||
const row = rows[0];
|
||||
return Response.json({
|
||||
configured: true,
|
||||
base_url: row.base_url,
|
||||
@@ -1631,19 +1697,15 @@ export class UserDBDO {
|
||||
}
|
||||
|
||||
private async handleGetAIConfigDecrypted(userId: number): Promise<Response> {
|
||||
const rows = this.db
|
||||
.exec('SELECT base_url, model, api_key_enc FROM ai_configs WHERE user_id = ?', userId)
|
||||
.toArray();
|
||||
const rows = this.query<AIConfigSecretRow>(
|
||||
'SELECT base_url, model, api_key_enc FROM ai_configs WHERE user_id = ?',
|
||||
userId
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return Response.json({ error: 'No AI config found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const row = rows[0] as unknown as {
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key_enc: string;
|
||||
};
|
||||
const row = rows[0];
|
||||
|
||||
if (!row.api_key_enc) {
|
||||
return Response.json({ error: 'No API key configured' }, { status: 404 });
|
||||
|
||||
@@ -55,6 +55,12 @@ pnpm run test:e2e
|
||||
pnpm run verify
|
||||
```
|
||||
|
||||
> **测试类型检查**:`pnpm run typecheck` 的 worker 阶段会依次执行
|
||||
> `tsc --noEmit`(src)、`tsc -p tests/tsconfig.worker.json`(tests/worker|ssh|build,
|
||||
> workers-types 环境)与 `tsc -p tests/tsconfig.frontend.json`(tests 根目录 *.test.ts + e2e,
|
||||
> DOM 环境)。两个测试配置分开的原因:`@cloudflare/workers-types` 声明的基础 DOM 接口
|
||||
> (如 Element.append)与 DOM lib 合并冲突,不能在同一项目里同时加载。
|
||||
|
||||
## 当前覆盖范围
|
||||
|
||||
### SSH 协议
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
|
||||
) as {
|
||||
// 以测试文件本身为锚点解析仓库相对路径(workers-types 的全局 URL 与 node:url.URL
|
||||
// 存在类型差异,这里统一走 fileURLToPath + path.join,避免跨类型使用)。
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const readRootFile = (relative: string): string =>
|
||||
readFileSync(join(rootDir, relative), 'utf8');
|
||||
|
||||
const packageJson = JSON.parse(readRootFile('package.json')) as {
|
||||
packageManager?: string;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
const buildScript = readFileSync(new URL('../../scripts/build-html.js', import.meta.url), 'utf8');
|
||||
const deployWorkflow = readFileSync(
|
||||
new URL('../../.github/workflows/deploy.yml', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const buildScript = readRootFile('scripts/build-html.js');
|
||||
const deployWorkflow = readRootFile('.github/workflows/deploy.yml');
|
||||
|
||||
describe('reproducible build and deployment gate', () => {
|
||||
it('pins the package manager and matching Vitest packages', () => {
|
||||
|
||||
@@ -128,9 +128,12 @@ test('Turnstile 跟随 Standard Light 和后续主题切换', async ({ page }) =
|
||||
.toEqual([{ id: 'widget-1', theme: 'light' }]);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const selector = document.getElementById('theme-selector') as HTMLSelectElement;
|
||||
selector.value = 'standard-dark';
|
||||
selector.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
const themeSelector = document.getElementById('theme-selector');
|
||||
if (!(themeSelector instanceof HTMLSelectElement)) {
|
||||
throw new Error('theme-selector not found');
|
||||
}
|
||||
themeSelector.value = 'standard-dark';
|
||||
themeSelector.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
await expect
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 仅为 DOM 配置的测试类型检查提供最小 shape:src/types.ts 的 Env 接口
|
||||
* 引用 Workers 全局类 DurableObjectNamespace。该全局类型来自
|
||||
* @cloudflare/workers-types,但它声明的基础 DOM 接口(如 Element.append)
|
||||
* 与 DOM lib 合并冲突,不能在 DOM 配置中同时加载,故以最小声明替代。
|
||||
* Env 在测试中仅作类型引用、从不实例化,不影响检查真实性。
|
||||
*/
|
||||
declare class DurableObjectNamespace<T = unknown> {}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"types": ["node", "vitest/globals"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["./*.test.ts", "./e2e/**/*.ts", "./global-shim.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "../frontend"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"types": ["@cloudflare/workers-types", "node", "vitest/globals"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["./worker/**/*.ts", "./ssh/**/*.ts", "./build/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "../frontend"]
|
||||
}
|
||||
@@ -17,7 +17,9 @@ function makeExecutor(execResult: {
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}) {
|
||||
const execCommand = vi.fn(async () => execResult);
|
||||
const execCommand = vi.fn(
|
||||
async (command: string): Promise<typeof execResult> => execResult
|
||||
);
|
||||
const askConfirmation = vi.fn(async () => true);
|
||||
const executor = new ToolExecutor(
|
||||
new TerminalContext(),
|
||||
|
||||
@@ -3,7 +3,10 @@ import { SSHSession } from '../../src/worker/ssh-session';
|
||||
|
||||
function createSession(stdout: string) {
|
||||
const send = vi.fn();
|
||||
const fetch = vi.fn(async () => Response.json({ success: true }));
|
||||
const fetch = vi.fn(
|
||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> =>
|
||||
Response.json({ success: true })
|
||||
);
|
||||
const env = {
|
||||
USER_DB: {
|
||||
idFromName: vi.fn(() => 'user-db-id'),
|
||||
|
||||
@@ -290,7 +290,9 @@ describe('安全 — 强制 GitHub 登录模式', () => {
|
||||
REQUIRE_GITHUB_AUTH: value,
|
||||
})
|
||||
);
|
||||
expect((await res.json()).githubAuthRequired).toBe(expected);
|
||||
expect(
|
||||
((await res.json()) as { githubAuthRequired?: boolean }).githubAuthRequired
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('强制登录时拒绝匿名 SSH WebSocket', async () => {
|
||||
@@ -408,7 +410,6 @@ describe('安全 — 越权防护(IDOR)', () => {
|
||||
}
|
||||
// DO 收到 PUT /internal/servers/:id,检查 belong,属于他人 → 403
|
||||
if (req.url.match(/\/internal\/servers\/\d+$/) && req.method === 'PUT') {
|
||||
const body = await req.json();
|
||||
// 模拟:服务器 record.user_id=2 !== body.user_id=1
|
||||
return new Response(JSON.stringify({ error: 'Server does not belong to user' }), {
|
||||
status: 403,
|
||||
@@ -623,7 +624,7 @@ describe('安全 — SSRF 接缝(AI base_url)', () => {
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeTruthy(); // validateBaseUrl 返回的中文 reason
|
||||
expect((data as { error?: string }).error).toBeTruthy(); // validateBaseUrl 返回的中文 reason
|
||||
// 不应到达 DO 持久化
|
||||
expect(env.USER_DB.get({} as any).fetch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: expect.stringContaining('/internal/ai-config') })
|
||||
@@ -971,7 +972,7 @@ describe('安全 — 一次性 token 与接缝鉴权', () => {
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/token|无效|expired/i);
|
||||
expect((data as { error?: string }).error).toMatch(/token|无效|expired/i);
|
||||
});
|
||||
|
||||
it('SFTP attach 缺 session 参数 → 403 Missing SFTP attach token', async () => {
|
||||
@@ -990,7 +991,7 @@ describe('安全 — 一次性 token 与接缝鉴权', () => {
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/token|missing/i);
|
||||
expect((data as { error?: string }).error).toMatch(/token|missing/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -998,13 +999,17 @@ describe('安全 — 一次性 SSH 分享边界', () => {
|
||||
it('分享功能默认关闭,仅显式配置 true 时公开', async () => {
|
||||
const worker = await loadWorker();
|
||||
const disabled = await worker.fetch(makeRequest('/api/config'), makeEnv());
|
||||
expect((await disabled.json()).sshSharingEnabled).toBe(false);
|
||||
expect(((await disabled.json()) as { sshSharingEnabled?: boolean }).sshSharingEnabled).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const enabled = await worker.fetch(
|
||||
makeRequest('/api/config'),
|
||||
makeEnv({ ENABLE_SSH_SHARING: 'true' })
|
||||
);
|
||||
expect((await enabled.json()).sshSharingEnabled).toBe(true);
|
||||
expect(((await enabled.json()) as { sshSharingEnabled?: boolean }).sshSharingEnabled).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('公开领取接口对非法 JSON 和非 URL-safe 凭证返回 400', async () => {
|
||||
|
||||
@@ -30,7 +30,8 @@ if (typeof (globalThis as any).WebSocketPair === 'undefined') {
|
||||
const OriginalResponse = globalThis.Response;
|
||||
class MockResponse extends OriginalResponse {
|
||||
private _mockStatus: number;
|
||||
public webSocket?: any;
|
||||
// 基类 Response 要求 webSocket 为必填只读属性;此 Mock 在 101 升级时赋值。
|
||||
public webSocket: any;
|
||||
|
||||
constructor(body?: any, init?: any) {
|
||||
if (init && init.status === 101) {
|
||||
@@ -91,7 +92,7 @@ describe('SSHSession Re-attach & Backpressure Flow Control', () => {
|
||||
const internal = session as any;
|
||||
|
||||
internal.state = 'ready';
|
||||
const channel = new SSHChannel(0, 1000, 1024 * 1024, 32768);
|
||||
const channel = new SSHChannel();
|
||||
internal.shellChannel = channel;
|
||||
internal.channels.set(0, channel);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SSHChannel } from '../../src/ssh/channel';
|
||||
import {
|
||||
@@ -37,10 +39,8 @@ function createHandler(sftpOverrides: Record<string, unknown>) {
|
||||
|
||||
describe('SFTP 同名上传保护', () => {
|
||||
it('SSH 会话仅接受布尔 true 作为显式覆盖授权', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../../src/worker/ssh-session.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const source = readFileSync(join(rootDir, 'src/worker/ssh-session.ts'), 'utf8');
|
||||
expect(source).toContain(
|
||||
'this.sftpHandler.uploadStart(msg.path, msg.size || 0, msg.overwrite === true)'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SSHShareDO } from '../../src/worker/share-do';
|
||||
import { UserDBDO } from '../../src/worker/user-db';
|
||||
|
||||
// ==================== SSHShareDO 侧:内存伪 SQL ====================
|
||||
|
||||
interface FakeAuditEvent {
|
||||
id: number;
|
||||
occurred_at: number;
|
||||
event_type: string;
|
||||
details: string;
|
||||
byte_size: number;
|
||||
}
|
||||
|
||||
function seedShare(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
singleton: 1,
|
||||
share_id: 'share-1',
|
||||
token_hash: 'token-hash',
|
||||
owner_user_id: 7,
|
||||
owner_github_id: 'gh-7',
|
||||
server_id: 1,
|
||||
server_name: 'srv-1',
|
||||
expires_at: Date.now() - 60_000,
|
||||
max_session_seconds: 600,
|
||||
status: 'closed',
|
||||
claimed_at: 1000,
|
||||
active_at: 2000,
|
||||
closed_at: 3000,
|
||||
session_expires_at: null,
|
||||
session_name: null,
|
||||
ticket_hash: null,
|
||||
ticket_expires_at: null,
|
||||
audit_bytes: 500,
|
||||
device_pub_key: null,
|
||||
audit_purge_due: null,
|
||||
audit_retention_days: 30,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class ShareFakeSql {
|
||||
share: Record<string, unknown> | null;
|
||||
events: FakeAuditEvent[];
|
||||
calls: string[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
constructor(share: Record<string, unknown> | null, events: FakeAuditEvent[] = []) {
|
||||
this.share = share ? { ...share } : null;
|
||||
this.events = events.map((event) => ({ ...event }));
|
||||
}
|
||||
|
||||
exec(sql: string, ...values: unknown[]): { toArray: () => unknown[]; one: () => unknown } {
|
||||
this.calls.push(sql);
|
||||
const tableRead = {
|
||||
toArray: () => [] as unknown[],
|
||||
one: () => ({}) as unknown,
|
||||
};
|
||||
|
||||
if (sql.includes('SELECT * FROM share_state WHERE singleton = 1')) {
|
||||
return { toArray: () => (this.share ? [{ ...this.share }] : []), one: () => ({}) };
|
||||
}
|
||||
if (sql.includes('SELECT COUNT(*) AS count FROM audit_events')) {
|
||||
return { toArray: () => [], one: () => ({ count: this.events.length }) };
|
||||
}
|
||||
if (sql.includes('event_type NOT IN')) {
|
||||
const after = Number(values[0]);
|
||||
const limit = Number(values[1]);
|
||||
const rows = this.events
|
||||
.filter(
|
||||
(event) =>
|
||||
event.id > after &&
|
||||
event.event_type !== 'share.audit_purged' &&
|
||||
event.event_type !== 'share.audit_auto_purged'
|
||||
)
|
||||
.slice(0, limit);
|
||||
return { toArray: () => rows, one: () => ({}) };
|
||||
}
|
||||
if (sql.includes('WHERE event_type IN')) {
|
||||
const rows = this.events.filter(
|
||||
(event) =>
|
||||
event.event_type === 'share.audit_purged' ||
|
||||
event.event_type === 'share.audit_auto_purged'
|
||||
);
|
||||
return { toArray: () => rows, one: () => ({}) };
|
||||
}
|
||||
if (sql.includes('DELETE FROM audit_events')) {
|
||||
this.events = [];
|
||||
return tableRead;
|
||||
}
|
||||
if (sql.includes('UPDATE share_state SET audit_bytes = 0')) {
|
||||
if (this.share) this.share.audit_bytes = 0;
|
||||
return tableRead;
|
||||
}
|
||||
if (sql.includes('UPDATE share_state SET audit_bytes = audit_bytes +')) {
|
||||
if (this.share) this.share.audit_bytes = Number(this.share.audit_bytes ?? 0) + Number(values[0]);
|
||||
return tableRead;
|
||||
}
|
||||
if (sql.includes('UPDATE share_state SET audit_purge_due = NULL')) {
|
||||
if (this.share) this.share.audit_purge_due = null;
|
||||
return tableRead;
|
||||
}
|
||||
if (sql.includes('UPDATE share_state SET audit_purge_due = ?')) {
|
||||
if (this.share) this.share.audit_purge_due = Number(values[0]);
|
||||
return tableRead;
|
||||
}
|
||||
if (sql.includes('UPDATE share_state SET status = ?')) {
|
||||
if (this.share) {
|
||||
this.share.status = String(values[0]);
|
||||
this.share.closed_at = Number(values[1]);
|
||||
}
|
||||
return tableRead;
|
||||
}
|
||||
if (sql.includes('INSERT INTO audit_events')) {
|
||||
this.events.push({
|
||||
id: this.nextId++,
|
||||
occurred_at: Number(values[0]),
|
||||
event_type: String(values[1]),
|
||||
details: String(values[2]),
|
||||
byte_size: Number(values[3]),
|
||||
});
|
||||
return tableRead;
|
||||
}
|
||||
if (sql.startsWith('CREATE') || sql.startsWith('ALTER')) {
|
||||
// 建表/迁移语句:伪 SQL 中直接忽略
|
||||
return tableRead;
|
||||
}
|
||||
return tableRead;
|
||||
}
|
||||
}
|
||||
|
||||
function makeShareDo(options: {
|
||||
share: Record<string, unknown> | null;
|
||||
events?: FakeAuditEvent[];
|
||||
userDbFetch?: (request: Request) => Promise<Response> | Response;
|
||||
deleteAlarm?: () => Promise<void>;
|
||||
}): {
|
||||
doInstance: SSHShareDO;
|
||||
fakeSql: ShareFakeSql;
|
||||
userDbFetch: ReturnType<typeof vi.fn>;
|
||||
deleteAlarm: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const fakeSql = new ShareFakeSql(options.share, options.events ?? []);
|
||||
const userDbFetch = vi.fn(
|
||||
options.userDbFetch ?? (async () => new Response('{}', { status: 200 }) as Response)
|
||||
);
|
||||
const deleteAlarm = vi.fn(options.deleteAlarm ?? (async () => {}));
|
||||
const state = {
|
||||
storage: {
|
||||
sql: fakeSql,
|
||||
setAlarm: vi.fn(async () => {}),
|
||||
deleteAlarm,
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
USER_DB: {
|
||||
get: vi.fn(() => ({ fetch: userDbFetch })),
|
||||
idFromName: vi.fn((name: string) => name),
|
||||
},
|
||||
};
|
||||
const doInstance = new SSHShareDO(state as unknown as DurableObjectState, env as never);
|
||||
return { doInstance, fakeSql, userDbFetch, deleteAlarm };
|
||||
}
|
||||
|
||||
function ownerViewRequest(ownerUserId: number): Request {
|
||||
return new Request(
|
||||
`http://internal/internal/owner-view?owner_user_id=${ownerUserId}&after=0&limit=500`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
}
|
||||
|
||||
function purgeRequest(ownerUserId: number): Request {
|
||||
return new Request('http://internal/internal/audit/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ownerUserId }),
|
||||
});
|
||||
}
|
||||
|
||||
async function readOwnerView(response: Response): Promise<{
|
||||
share: { auditBytes: number; status: string };
|
||||
events: Array<{ eventType: string }>;
|
||||
removals: Array<{ occurredAt: number; eventType: string }>;
|
||||
}> {
|
||||
return (await response.json()) as {
|
||||
share: { auditBytes: number; status: string };
|
||||
events: Array<{ eventType: string }>;
|
||||
removals: Array<{ occurredAt: number; eventType: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('SSHShareDO 审计清理留痕', () => {
|
||||
const sampleEvents: FakeAuditEvent[] = [
|
||||
{ id: 1, occurred_at: 1000, event_type: 'share.claimed', details: '{}', byte_size: 2 },
|
||||
{ id: 2, occurred_at: 2000, event_type: 'session.connecting', details: '{}', byte_size: 2 },
|
||||
{ id: 3, occurred_at: 3000, event_type: 'terminal.output', details: '{}', byte_size: 2 },
|
||||
];
|
||||
|
||||
it('手动清空后:墓碑不进常规事件列表,removals 返回 1 条,audit_bytes 归零', async () => {
|
||||
const { doInstance } = makeShareDo({ share: seedShare(), events: sampleEvents });
|
||||
const purge = await doInstance.fetch(purgeRequest(7));
|
||||
expect(purge.status).toBe(200);
|
||||
|
||||
const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7)));
|
||||
// 清理后 audit_bytes 归零后仅剩墓碑本身('{}' 恰为 2 字节)
|
||||
expect(view.share.auditBytes).toBe(2);
|
||||
expect(view.events).toHaveLength(0);
|
||||
expect(view.events.some((event) => event.eventType === 'share.audit_purged')).toBe(false);
|
||||
expect(view.removals).toHaveLength(1);
|
||||
expect(view.removals[0].eventType).toBe('share.audit_purged');
|
||||
});
|
||||
|
||||
it('手动清空失败时不产生任何清理留痕(归属校验 403、非终态 409)', async () => {
|
||||
const { doInstance } = makeShareDo({ share: seedShare(), events: sampleEvents });
|
||||
expect((await doInstance.fetch(purgeRequest(8))).status).toBe(403);
|
||||
expect((await doInstance.fetch(purgeRequest(7))).status).toBe(200);
|
||||
|
||||
const unused = makeShareDo({
|
||||
share: seedShare({ status: 'unused', server_name: 'srv-x' }),
|
||||
events: [],
|
||||
});
|
||||
expect((await unused.doInstance.fetch(purgeRequest(7))).status).toBe(409);
|
||||
});
|
||||
|
||||
it('部署窗口期 UserDBDO 同步失败不影响清理成功(尽力而为)', async () => {
|
||||
const { doInstance } = makeShareDo({
|
||||
share: seedShare(),
|
||||
events: sampleEvents,
|
||||
userDbFetch: async () => {
|
||||
throw new Error('old UserDBDO: route not found');
|
||||
},
|
||||
});
|
||||
const purge = await doInstance.fetch(purgeRequest(7));
|
||||
expect(purge.status).toBe(200);
|
||||
const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7)));
|
||||
expect(view.removals).toHaveLength(1);
|
||||
expect(view.removals[0].eventType).toBe('share.audit_purged');
|
||||
});
|
||||
|
||||
it('到期自动清理:走 alarm 路径、写 auto 墓碑、清空 purge_due 并同步 auto 留痕', async () => {
|
||||
const { doInstance, userDbFetch } = makeShareDo({
|
||||
share: seedShare({ audit_purge_due: Date.now() - 1000 }),
|
||||
events: sampleEvents,
|
||||
});
|
||||
await doInstance.alarm();
|
||||
|
||||
const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7)));
|
||||
expect(view.removals).toHaveLength(1);
|
||||
expect(view.removals[0].eventType).toBe('share.audit_auto_purged');
|
||||
// 清除后仅剩自动清理墓碑自身的字节数
|
||||
expect(view.share.auditBytes).toBe(2);
|
||||
|
||||
// notifyOwnerAuditPurged 应以 auto 方式同步到 UserDBDO
|
||||
expect(userDbFetch).toHaveBeenCalledTimes(1);
|
||||
const [callRequest] = userDbFetch.mock.calls[0] as [Request];
|
||||
expect(callRequest.url).toContain('/internal/shares/share-1/audit-purged');
|
||||
const callBody = JSON.parse(await callRequest.text()) as { purge_type: string };
|
||||
expect(callBody.purge_type).toBe('auto');
|
||||
});
|
||||
|
||||
it('updateStatus 排期闹钟失败时回滚 audit_purge_due(修复幽灵状态)', async () => {
|
||||
const { doInstance, fakeSql } = makeShareDo({
|
||||
share: seedShare({ status: 'claimed', session_expires_at: 1, audit_retention_days: 30 }),
|
||||
events: sampleEvents,
|
||||
});
|
||||
// setAlarm 拒绝:模拟闹钟设置静默失败
|
||||
const state = (doInstance as unknown as { state: { storage: { setAlarm: ReturnType<typeof vi.fn> } } })
|
||||
.state;
|
||||
state.storage.setAlarm = vi.fn(async () => {
|
||||
throw new Error('alarm quota exceeded');
|
||||
});
|
||||
const res = await doInstance.fetch(
|
||||
new Request('http://internal/internal/session/closed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ normal: true }),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// 失败必须回滚:库里不留“有排期但无闹钟”的幽灵状态
|
||||
expect(fakeSql.share?.status).toBe('closed');
|
||||
expect(fakeSql.share?.audit_purge_due).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== UserDBDO 侧 ====================
|
||||
|
||||
class UserDbFakeSql {
|
||||
shareColumns: string[];
|
||||
serverColumns = ['region', 'inferred_hint', 'tags', 'os', 'jump_server_id'];
|
||||
shareRows: Array<{ user_id: number }>;
|
||||
updates: Array<{ sql: string; values: unknown[] }> = [];
|
||||
|
||||
constructor(shareColumns: string[], shareRows: Array<{ user_id: number }> = []) {
|
||||
this.shareColumns = shareColumns;
|
||||
this.shareRows = shareRows;
|
||||
}
|
||||
|
||||
exec(sql: string, ...values: unknown[]): { toArray: () => unknown[]; one: () => unknown } {
|
||||
if (sql.includes('PRAGMA table_info(servers)')) {
|
||||
return { toArray: () => this.serverColumns.map((name) => ({ name })), one: () => ({}) };
|
||||
}
|
||||
if (sql.includes('PRAGMA table_info(ssh_shares)')) {
|
||||
return { toArray: () => this.shareColumns.map((name) => ({ name })), one: () => ({}) };
|
||||
}
|
||||
if (sql === 'SELECT user_id FROM ssh_shares WHERE id = ?') {
|
||||
return { toArray: () => this.shareRows, one: () => ({}) };
|
||||
}
|
||||
if (sql.startsWith('ALTER TABLE ssh_shares')) {
|
||||
this.updates.push({ sql, values });
|
||||
return { toArray: () => [], one: () => ({}) };
|
||||
}
|
||||
if (sql.startsWith('UPDATE ssh_shares SET audit_purged_at')) {
|
||||
this.updates.push({ sql, values });
|
||||
return { toArray: () => [], one: () => ({}) };
|
||||
}
|
||||
return { toArray: () => [], one: () => ({}) };
|
||||
}
|
||||
}
|
||||
|
||||
function makeUserDb(sql: UserDbFakeSql): UserDBDO {
|
||||
return new UserDBDO(
|
||||
{ storage: { sql } } as unknown as DurableObjectState,
|
||||
{} as never
|
||||
);
|
||||
}
|
||||
|
||||
function auditPurgedRequest(body: unknown): Request {
|
||||
return new Request('http://internal/internal/shares/share-1/audit-purged', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('UserDBDO 审计清理留痕接缝', () => {
|
||||
it('同步留痕:归属匹配时更新 audit_purged_at/audit_purge_type', async () => {
|
||||
const sql = new UserDbFakeSql(
|
||||
['id', 'user_id', 'audit_purged_at', 'audit_purge_type'],
|
||||
[{ user_id: 7 }]
|
||||
);
|
||||
const db = makeUserDb(sql);
|
||||
const res = await db.fetch(
|
||||
auditPurgedRequest({ user_id: 7, purged_at: 123456, purge_type: 'manual' })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(sql.updates).toHaveLength(1);
|
||||
expect(sql.updates[0].values).toEqual([123456, 'manual', 'share-1']);
|
||||
});
|
||||
|
||||
it('归属不匹配拒绝写入(403),非法 purge_type 拒绝(400)', async () => {
|
||||
const sql = new UserDbFakeSql(
|
||||
['id', 'user_id', 'audit_purged_at', 'audit_purge_type'],
|
||||
[{ user_id: 7 }]
|
||||
);
|
||||
const db = makeUserDb(sql);
|
||||
expect((await db.fetch(auditPurgedRequest({ user_id: 8, purged_at: 1, purge_type: 'manual' }))).status).toBe(403);
|
||||
expect((await db.fetch(auditPurgedRequest({ user_id: 7, purged_at: 1, purge_type: 'manual' }))).status).toBe(200);
|
||||
const sql2 = new UserDbFakeSql(['id', 'user_id'], [{ user_id: 7 }]);
|
||||
const db2 = makeUserDb(sql2);
|
||||
expect((await db2.fetch(auditPurgedRequest({ user_id: 7, purged_at: 1, purge_type: 'weird' }))).status).toBe(400);
|
||||
expect((await db2.fetch(auditPurgedRequest({ user_id: '7', purged_at: 1, purge_type: 'manual' }))).status).toBe(400);
|
||||
expect((await db2.fetch(auditPurgedRequest({ user_id: 7, purged_at: Number.NaN, purge_type: 'manual' }))).status).toBe(400);
|
||||
});
|
||||
|
||||
it('ssh_shares audit 留痕迁移幂等:缺列时补列,已有时不再 ALTER', async () => {
|
||||
const oldSql = new UserDbFakeSql(['id', 'user_id'], []);
|
||||
makeUserDb(oldSql);
|
||||
const alterCount = oldSql.updates.filter((update) => update.sql.startsWith('ALTER')).length;
|
||||
expect(alterCount).toBe(2); // audit_purged_at + audit_purge_type
|
||||
|
||||
const newSql = new UserDbFakeSql(['id', 'user_id', 'audit_purged_at', 'audit_purge_type'], []);
|
||||
makeUserDb(newSql);
|
||||
expect(newSql.updates.filter((update) => update.sql.startsWith('ALTER')).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,8 @@ const globalShim = globalThis as any;
|
||||
if (!globalShim.__cloudsshWsTestShimInstalled) {
|
||||
globalShim.__cloudsshWsTestShimInstalled = true;
|
||||
|
||||
if (typeof globalThis.WebSocketPair === 'undefined') {
|
||||
globalThis.WebSocketPair = class {
|
||||
if (typeof (globalThis as any).WebSocketPair === 'undefined') {
|
||||
(globalThis as any).WebSocketPair = class {
|
||||
0: any;
|
||||
1: any;
|
||||
constructor() {
|
||||
@@ -29,7 +29,8 @@ if (!globalShim.__cloudsshWsTestShimInstalled) {
|
||||
const OriginalResponse = globalThis.Response;
|
||||
class MockResponse extends OriginalResponse {
|
||||
private _mockStatus: number;
|
||||
public webSocket?: any;
|
||||
// 基类 Response 要求 webSocket 为必填只读属性;此 Mock 在 101 升级时赋值。
|
||||
public webSocket: any;
|
||||
|
||||
constructor(body?: any, init?: any) {
|
||||
if (init && init.status === 101) {
|
||||
@@ -70,7 +71,9 @@ async function makeDeviceKeyPair(): Promise<DeviceKeyPair> {
|
||||
'sign',
|
||||
'verify',
|
||||
])) as CryptoKeyPair;
|
||||
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', kp.publicKey));
|
||||
const spki = new Uint8Array(
|
||||
(await crypto.subtle.exportKey('spki', kp.publicKey)) as ArrayBuffer
|
||||
);
|
||||
return { privateKey: kp.privateKey as CryptoKey, publicKeyB64: toBase64Url(spki) };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user