From 4021e58d5d23cec53bd9c4aafeb1060f8bb30799 Mon Sep 17 00:00:00 2001 From: ymy Date: Thu, 23 Jul 2026 23:41:11 +0800 Subject: [PATCH] feat(database): init database folder frontend and permission --- .../ses_0705f8afbffePWIq7GrFfwfFwV.json | 10 + .../ses_07252990dffeRFmXnvdyynty2z.json | 10 + .../ses_075d214a0ffe6mU8VNgukUfH5L.json | 10 + .../ses_075d3282bffejz0HFE00taOaGM.json | 10 + .../ses_0781484caffeKJHXVKiD4BqEJw.json | 10 + .../ses_07b163c13ffeudULxSfIfK986T.json | 10 + .../ses_07b1e6c8effeq7bdmj6NgO0LqC.json | 10 + .../ses_07b932dddffecSipFLwlJOvoJ9.json | 10 + .../ses_07b9c4268ffeic4mMXCPUt225j.json | 10 + .../ses_07b9c4346ffeVBdV5pf4h6s9PW.json | 10 + .../ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json | 10 + .../ses_07cdbac0effeiBrdcHLUUUfwuF.json | 10 + .omo/文件库-开工计划.md | 114 ++ hub/filelib-web/index.html | 12 + hub/filelib-web/package-lock.json | 1621 +++++++++++++++++ hub/filelib-web/package.json | 21 + hub/filelib-web/src/App.svelte | 30 + hub/filelib-web/src/app.css | 66 + hub/filelib-web/src/lib/BrowserShell.svelte | 141 ++ hub/filelib-web/src/lib/FileEditor.svelte | 165 ++ hub/filelib-web/src/lib/FilesPanel.svelte | 137 ++ hub/filelib-web/src/lib/LoginView.svelte | 47 + hub/filelib-web/src/lib/Modal.svelte | 16 + .../src/lib/NodeDetailPanel.svelte | 144 ++ hub/filelib-web/src/lib/OverviewPanel.svelte | 123 ++ hub/filelib-web/src/lib/Toasts.svelte | 11 + hub/filelib-web/src/lib/TreeNode.svelte | 82 + hub/filelib-web/src/lib/api.ts | 49 + hub/filelib-web/src/lib/browser.ts | 34 + hub/filelib-web/src/lib/stores.ts | 26 + hub/filelib-web/src/lib/types.ts | 85 + hub/filelib-web/src/main.ts | 5 + hub/filelib-web/tsconfig.json | 17 + hub/filelib-web/vite.config.ts | 25 + hub/package-lock.json | 215 ++- hub/package.json | 7 +- .../20260721120000_file_library/migration.sql | 92 + .../migration.sql | 2 + hub/prisma/schema.prisma | 125 ++ hub/src/database/README.md | 46 +- hub/src/database/filelib/audit.ts | 72 + hub/src/database/filelib/exportService.ts | 195 ++ hub/src/database/filelib/fileService.ts | 275 +++ hub/src/database/filelib/grantService.ts | 319 ++++ hub/src/database/filelib/groupResolver.ts | 48 + hub/src/database/filelib/groupResolverHttp.ts | 49 + hub/src/database/filelib/guards.ts | 47 + hub/src/database/filelib/model.ts | 59 + hub/src/database/filelib/permission.ts | 74 + hub/src/database/filelib/routeShared.ts | 89 + hub/src/database/filelib/treeService.ts | 598 ++++++ hub/src/database/filelib/versionStore.ts | 280 +++ hub/src/database/routes/adminPanels.ts | 227 +++ hub/src/database/routes/databaseRoutes.ts | 385 ++-- hub/src/database/routes/fileRoutes.ts | 235 +++ hub/src/database/routes/filelibRoutes.ts | 310 ++++ hub/src/database/routes/libraryBrowser.ts | 596 ++++++ hub/src/database/routes/libraryPage.ts | 801 ++++++++ hub/src/database/routes/teacherApp.ts | 129 ++ hub/src/database/routes/uiTheme.ts | 138 ++ hub/test/integration/filelib-routes.test.ts | 227 +++ hub/test/integration/filelib-tree.test.ts | 183 ++ hub/test/unit/filelib-filepath.test.ts | 40 + hub/test/unit/filelib-model.test.ts | 49 + hub/test/unit/filelib-permission.test.ts | 159 ++ hub/test/unit/filelib-version-store.test.ts | 120 ++ 文件库-接口契约.md | 304 ++++ 67 files changed, 9436 insertions(+), 150 deletions(-) create mode 100644 .omo/run-continuation/ses_0705f8afbffePWIq7GrFfwfFwV.json create mode 100644 .omo/run-continuation/ses_07252990dffeRFmXnvdyynty2z.json create mode 100644 .omo/run-continuation/ses_075d214a0ffe6mU8VNgukUfH5L.json create mode 100644 .omo/run-continuation/ses_075d3282bffejz0HFE00taOaGM.json create mode 100644 .omo/run-continuation/ses_0781484caffeKJHXVKiD4BqEJw.json create mode 100644 .omo/run-continuation/ses_07b163c13ffeudULxSfIfK986T.json create mode 100644 .omo/run-continuation/ses_07b1e6c8effeq7bdmj6NgO0LqC.json create mode 100644 .omo/run-continuation/ses_07b932dddffecSipFLwlJOvoJ9.json create mode 100644 .omo/run-continuation/ses_07b9c4268ffeic4mMXCPUt225j.json create mode 100644 .omo/run-continuation/ses_07b9c4346ffeVBdV5pf4h6s9PW.json create mode 100644 .omo/run-continuation/ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json create mode 100644 .omo/run-continuation/ses_07cdbac0effeiBrdcHLUUUfwuF.json create mode 100644 .omo/文件库-开工计划.md create mode 100644 hub/filelib-web/index.html create mode 100644 hub/filelib-web/package-lock.json create mode 100644 hub/filelib-web/package.json create mode 100644 hub/filelib-web/src/App.svelte create mode 100644 hub/filelib-web/src/app.css create mode 100644 hub/filelib-web/src/lib/BrowserShell.svelte create mode 100644 hub/filelib-web/src/lib/FileEditor.svelte create mode 100644 hub/filelib-web/src/lib/FilesPanel.svelte create mode 100644 hub/filelib-web/src/lib/LoginView.svelte create mode 100644 hub/filelib-web/src/lib/Modal.svelte create mode 100644 hub/filelib-web/src/lib/NodeDetailPanel.svelte create mode 100644 hub/filelib-web/src/lib/OverviewPanel.svelte create mode 100644 hub/filelib-web/src/lib/Toasts.svelte create mode 100644 hub/filelib-web/src/lib/TreeNode.svelte create mode 100644 hub/filelib-web/src/lib/api.ts create mode 100644 hub/filelib-web/src/lib/browser.ts create mode 100644 hub/filelib-web/src/lib/stores.ts create mode 100644 hub/filelib-web/src/lib/types.ts create mode 100644 hub/filelib-web/src/main.ts create mode 100644 hub/filelib-web/tsconfig.json create mode 100644 hub/filelib-web/vite.config.ts create mode 100644 hub/prisma/migrations/20260721120000_file_library/migration.sql create mode 100644 hub/prisma/migrations/20260723130000_filelib_description/migration.sql create mode 100644 hub/src/database/filelib/audit.ts create mode 100644 hub/src/database/filelib/exportService.ts create mode 100644 hub/src/database/filelib/fileService.ts create mode 100644 hub/src/database/filelib/grantService.ts create mode 100644 hub/src/database/filelib/groupResolver.ts create mode 100644 hub/src/database/filelib/groupResolverHttp.ts create mode 100644 hub/src/database/filelib/guards.ts create mode 100644 hub/src/database/filelib/model.ts create mode 100644 hub/src/database/filelib/permission.ts create mode 100644 hub/src/database/filelib/routeShared.ts create mode 100644 hub/src/database/filelib/treeService.ts create mode 100644 hub/src/database/filelib/versionStore.ts create mode 100644 hub/src/database/routes/adminPanels.ts create mode 100644 hub/src/database/routes/fileRoutes.ts create mode 100644 hub/src/database/routes/filelibRoutes.ts create mode 100644 hub/src/database/routes/libraryBrowser.ts create mode 100644 hub/src/database/routes/libraryPage.ts create mode 100644 hub/src/database/routes/teacherApp.ts create mode 100644 hub/src/database/routes/uiTheme.ts create mode 100644 hub/test/integration/filelib-routes.test.ts create mode 100644 hub/test/integration/filelib-tree.test.ts create mode 100644 hub/test/unit/filelib-filepath.test.ts create mode 100644 hub/test/unit/filelib-model.test.ts create mode 100644 hub/test/unit/filelib-permission.test.ts create mode 100644 hub/test/unit/filelib-version-store.test.ts create mode 100644 文件库-接口契约.md diff --git a/.omo/run-continuation/ses_0705f8afbffePWIq7GrFfwfFwV.json b/.omo/run-continuation/ses_0705f8afbffePWIq7GrFfwfFwV.json new file mode 100644 index 0000000..35dd765 --- /dev/null +++ b/.omo/run-continuation/ses_0705f8afbffePWIq7GrFfwfFwV.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_0705f8afbffePWIq7GrFfwfFwV", + "updatedAt": "2026-07-23T15:40:15.630Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-23T15:40:15.630Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07252990dffeRFmXnvdyynty2z.json b/.omo/run-continuation/ses_07252990dffeRFmXnvdyynty2z.json new file mode 100644 index 0000000..13afc86 --- /dev/null +++ b/.omo/run-continuation/ses_07252990dffeRFmXnvdyynty2z.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07252990dffeRFmXnvdyynty2z", + "updatedAt": "2026-07-23T06:33:33.086Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-23T06:33:33.086Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_075d214a0ffe6mU8VNgukUfH5L.json b/.omo/run-continuation/ses_075d214a0ffe6mU8VNgukUfH5L.json new file mode 100644 index 0000000..7c08344 --- /dev/null +++ b/.omo/run-continuation/ses_075d214a0ffe6mU8VNgukUfH5L.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_075d214a0ffe6mU8VNgukUfH5L", + "updatedAt": "2026-07-22T14:15:01.741Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-22T14:15:01.741Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_075d3282bffejz0HFE00taOaGM.json b/.omo/run-continuation/ses_075d3282bffejz0HFE00taOaGM.json new file mode 100644 index 0000000..1ab24cd --- /dev/null +++ b/.omo/run-continuation/ses_075d3282bffejz0HFE00taOaGM.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_075d3282bffejz0HFE00taOaGM", + "updatedAt": "2026-07-22T14:13:54.785Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-22T14:13:54.785Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_0781484caffeKJHXVKiD4BqEJw.json b/.omo/run-continuation/ses_0781484caffeKJHXVKiD4BqEJw.json new file mode 100644 index 0000000..5220743 --- /dev/null +++ b/.omo/run-continuation/ses_0781484caffeKJHXVKiD4BqEJw.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_0781484caffeKJHXVKiD4BqEJw", + "updatedAt": "2026-07-22T06:17:18.238Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-22T06:17:18.238Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b163c13ffeudULxSfIfK986T.json b/.omo/run-continuation/ses_07b163c13ffeudULxSfIfK986T.json new file mode 100644 index 0000000..a20e325 --- /dev/null +++ b/.omo/run-continuation/ses_07b163c13ffeudULxSfIfK986T.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07b163c13ffeudULxSfIfK986T", + "updatedAt": "2026-07-21T13:55:58.878Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-21T13:55:58.878Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b1e6c8effeq7bdmj6NgO0LqC.json b/.omo/run-continuation/ses_07b1e6c8effeq7bdmj6NgO0LqC.json new file mode 100644 index 0000000..e11862c --- /dev/null +++ b/.omo/run-continuation/ses_07b1e6c8effeq7bdmj6NgO0LqC.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07b1e6c8effeq7bdmj6NgO0LqC", + "updatedAt": "2026-07-21T13:38:01.132Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-21T13:38:01.132Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b932dddffecSipFLwlJOvoJ9.json b/.omo/run-continuation/ses_07b932dddffecSipFLwlJOvoJ9.json new file mode 100644 index 0000000..c0350d6 --- /dev/null +++ b/.omo/run-continuation/ses_07b932dddffecSipFLwlJOvoJ9.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07b932dddffecSipFLwlJOvoJ9", + "updatedAt": "2026-07-21T11:27:10.420Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-21T11:27:10.420Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b9c4268ffeic4mMXCPUt225j.json b/.omo/run-continuation/ses_07b9c4268ffeic4mMXCPUt225j.json new file mode 100644 index 0000000..6e90994 --- /dev/null +++ b/.omo/run-continuation/ses_07b9c4268ffeic4mMXCPUt225j.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07b9c4268ffeic4mMXCPUt225j", + "updatedAt": "2026-07-21T11:18:53.449Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-21T11:18:53.449Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b9c4346ffeVBdV5pf4h6s9PW.json b/.omo/run-continuation/ses_07b9c4346ffeVBdV5pf4h6s9PW.json new file mode 100644 index 0000000..15ecee9 --- /dev/null +++ b/.omo/run-continuation/ses_07b9c4346ffeVBdV5pf4h6s9PW.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07b9c4346ffeVBdV5pf4h6s9PW", + "updatedAt": "2026-07-21T11:18:29.979Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-21T11:18:29.979Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json b/.omo/run-continuation/ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json new file mode 100644 index 0000000..49b51ec --- /dev/null +++ b/.omo/run-continuation/ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07b9c9a75ffeOyJ4ewp3DBvXhz", + "updatedAt": "2026-07-21T11:21:09.776Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-21T11:21:09.776Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07cdbac0effeiBrdcHLUUUfwuF.json b/.omo/run-continuation/ses_07cdbac0effeiBrdcHLUUUfwuF.json new file mode 100644 index 0000000..9a0ffe1 --- /dev/null +++ b/.omo/run-continuation/ses_07cdbac0effeiBrdcHLUUUfwuF.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_07cdbac0effeiBrdcHLUUUfwuF", + "updatedAt": "2026-07-23T15:37:59.918Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-23T15:37:59.918Z" + } + } +} \ No newline at end of file diff --git a/.omo/文件库-开工计划.md b/.omo/文件库-开工计划.md new file mode 100644 index 0000000..945d2d0 --- /dev/null +++ b/.omo/文件库-开工计划.md @@ -0,0 +1,114 @@ +# 文件库 · 开工计划(v1.0) + +> 配套文档:《文件库-接口契约.md》(v0.1,仓库根目录)。本计划已吸收规划顾问评审意见,包含开工前必须先冻结的**架构收口决策 D11–D19**(回写契约 v0.2 时并入)。 +> 工期按 1 人全栈估算;前端从 Phase 2 后可并行。 + +## 开工判定 + +**可以开工。** 四个外部依赖(版本包 / Group / 审计 / 平台身份)全部有契约可依、可 mock 并行开发;无阻塞项。唯一前置:本文件的 D11–D19 决策在写第一行 schema 前过一遍(半天,自查即可,有异议再升级)。 + +## 架构收口决策(新增,先冻结再动工) + +| 编号 | 决策 | 理由 | +|---|---|---| +| D11 | `creator` 是 Node 的**不可变列**,创建时同时落一条 Manage grant;独立权限开关关闭时,**creator 的 Manage 仍生效**,其余项目级 grant 冻结不参与计算 | 否则 creator 可能被自己关掉的开关锁死 | +| D12 | 移动节点授权 = **本节点 Manage + 目标父 Edit+**;移动在事务 + Postgres 咨询锁内完成,防并发成环 | 预检环检测在并发下不安全 | +| D13 | Group resolve 失败返回 **503**(不伪装 404/403);每次 HTTP 请求 fresh resolve,不跨请求缓存;单请求内多次校验合并为一次调用(此点与 Group 团队确认,OPEN-9) | 依赖故障 ≠ 无权限;契约 G4 要求实时 | +| D14 | 命名规则:NFC 归一化、trim、≤128 字符、禁 `/` 与控制字符;**活跃兄弟节点大小写不敏感唯一**;根节点全局唯一 | 缺命名规则必有脏数据 | +| D15 | 删除 = 只给被删节点打标,后代**不递归打标**,靠"任一祖先已删"过滤;所有查询链路强制此过滤(含递归 SQL,不只靠 Prisma middleware) | 递归打标的写放大与恢复复杂度不可控 | +| D16 | `baseVersion` 为**文件级版本**(GET file 返回的 version);无关文件的提交不产生冲突 | 与 C1 `head(dir, filePath)` 语义一致 | +| D17 | breadcrumb 只暴露用户有 View 的祖先名称;无权限祖先显示占位符 `…`,不暴露名字 | 404 不泄露语义(D8)延伸到面包屑 | +| D18 | v1 **单副本部署**(git 仓库在本地磁盘);多副本需共享存储或分片,后置 | 不解决不存在的扩展问题 | +| D19 | 网站管理员**不隐式读内容**;force_adjust 凭 node id 操作(id 从审计或用户上报获得) | C4 已定的最小权限原则 | + +## 阶段计划 + +### Phase 0 · 骨架与身份先行(1–2 天) + +- [ ] 过 D11–D19,回写契约 v0.2 +- [ ] repo 骨架 `server/` + `web/`,Postgres docker-compose,CI(lint / typecheck / vitest / 集成测试用真实 Postgres service container) +- [ ] **JWT 中间件 + 本地 JWKS 测试服务 + 签名 token fixtures**(有效/过期/错 issuer/错 audience/错算法/未知 kid/轮换)——身份先行,不做"宽松 mock 身份" +- [ ] 4 个 port 接口定义 + mock:`VersionStore`、`GroupResolver`、`AuditSink`(直写 outbox 表的实现就是真的,mock 的是对端服务)、`ExportAdapter` +- [ ] fastify-swagger 接入,OpenAPI 骨架——**OpenAPI 随每个端点同步产出,不留到最后** +- **验收**:骨架可 `pnpm dev` 起服务;本地 JWKS 六类 token fixture(有效/过期/错 issuer/错 audience/错算法/未知 kid)中间件行为全对;CI 流水线绿 + +### Phase 1 · 数据模型与权限引擎(2–3 天)⚑ 心脏 + +- [ ] Prisma schema:`Node`(parentId **权威** + id 编码的 materialized path 派生列,name 不入 path)、`Grant`、`ProjectIndependentSettings`、`OutboxEvent`(含 attempts/nextAttemptAt/lease/lastError,relay 字段一次到位)、`ExportJob` +- [ ] 数据库约束:kind 枚举、项目无子节点、活跃兄弟名唯一(部分唯一索引,root 的 NULL parent 特殊处理)、活跃 grant 按 (nodeId, principalType, principalId) 唯一、独立设置仅项目 +- [ ] **纯权限 reducer**:输入已解析的 grants + 祖先链 + 用户组集合,输出 role|null;不碰 DB/网络。fast-check 属性测试:max 单调、加 grant 只升不降、祖先继承、toggle 行为、顺序无关、软删过滤 +- [ ] 数据获取层 + `effective(user, node)` 组装 +- [ ] 树操作服务:create(creator 自动 Manage、root 仅管理员且必带 ≥1 Manage)、rename、move(D12 事务+咨询锁)、soft delete(D15)、breadcrumb(D17) +- **验收**:并发对移(A→B 与 B→A)、移动+并发建子、property tests、删除祖先过滤,真实 Postgres 全绿 + +### Phase 2 · 树与授权 API(1–2 天) + +- [ ] nodes 端点组 + grants 端点组 + independent-permission 开关 + effective-permission 自查 +- [ ] 授权矩阵校验(契约 8.1:creator-only 授 Manage、Manage 不可动 creator、force_adjust 独立通道) +- [ ] 全部写操作落 outbox 事件(C3 词汇表) +- **验收**:契约 8.1 矩阵逐格 API 测试;D8 的 404/403/503 三分语义测试 + +### Phase 3 · 仓库生命周期与文件 API(2 天) + +- [ ] 项目 provisioning 状态机 `PROVISIONING → READY | FAILED`:先建 DB 行(PROVISIONING)→ 调 `VersionStore.init`(幂等)→ 置 READY;失败可重试;孤儿仓库对账 job +- [ ] 每项目写锁(Postgres 咨询锁,跨进程安全)——不假设版本包能跨进程串行 +- [ ] 路径安全:canonical 后必须落在项目根内、拒绝 `.git`、禁 symlink 逃逸、路径长度上限 +- [ ] 上传限额(单文件 10MB / 单项目配额,数值 OPEN-5)+ 流式接收 +- [ ] files 端点组全量(list/read/upload/commits→409/diff/history/delete)+ 双客户端冲突 e2e +- **验收**:DB/git 故障注入(init 失败、提交后崩溃)→ 对账能收敛;路径穿越语料库测试全拒 + +### Phase 4 · 外部集成(1–2 天) + +- [ ] `GroupResolver` HTTP client:超时/5xx/429/畸形 JSON/万级 group 的故障注入测试;失败 → 503(D13) +- [ ] Audit relay worker:租约领取、指数退避、eventId 幂等、重启恢复、积压告警 +- [ ] 导出 job:状态机 + 轮询 + 下载权限校验;`ExportAdapter` 桩(参数 OPEN-6 未定前**不计入完成标准**) +- **验收**:relay 重启不丢不重(对端幂等);group 故障时写操作 503、读操作按 D13 + +### Phase 5 · 前端(3–5 天,Phase 2 后并行启动) + +- [ ] 登录(平台 SSO 占位 + 本地 JWKS 直通开关) +- [ ] 树浏览(懒加载 + 分页)、breadcrumb(D17 占位符)、创建对话框(类型 + 批量授权选择器,Group search 接 C2) +- [ ] 权限管理面板(按 8.1 矩阵控制可选项,creator 标识) +- [ ] 文件页:查看/上传/下载/历史列表(编辑 UI 不归我方,留对接位) +- [ ] 导出按钮 + job 轮询 +- **验收**:无权限节点全链路不可见;授权面板不会送出矩阵禁止的组合 + +### Phase 6 · 硬化与交付(1–2 天) + +- [ ] 404/403/503 全端点核对;并发与重试幂等抽查 +- [ ] 备份脚本(DB + git 仓库一致性快照)、健康检查(DB/JWKS/磁盘/Group)、磁盘水位告警 +- [ ] OpenAPI 终稿 + 部署脚本 + 一页运维手册 + +## 里程碑 + +| 里程碑 | 内容 | 累计工期 | +|---|---|---| +| M1 | Phase 0–1:骨架 + 权限引擎 + 树操作可跑 | ~4 天 | +| M2 | Phase 2–3:后端 API 全量(含文件冲突流) | ~7 天 | +| M3 | Phase 4:外部集成(导出不计) | ~9 天 | +| M4 | Phase 5–6:前端 + 硬化,可交付 | ~14–18 天 | + +## Mock 保真红线(mock 可以顶,但不许骗) + +| Mock | 不许掩盖的事 | 真身到位后的契约测试 | +|---|---|---| +| VersionStore | 磁盘延迟、半初始化、锁残留、跨进程并发 | 真包跑临时仓库:并发 commit/init 重试/遍历与 symlink 语料 | +| GroupResolver | 超时、5xx、畸形响应、大规模组集 | 故障注入 HTTP 桩全过;我方绝不自己推祖先 | +| GroupSearch(前端选择器) | 与 resolve 是**两个独立端点**,不可用 resolve mock 顶替 | C2 `/groups/search` 真身到位后跑分页/空结果/超时 | +| Audit 对端 | 重复投递、乱序、长时间不可用 | relay 重启恢复 + 对端幂等 | +| JWT | issuer/audience/算法/轮换 | 本地 JWKS 全用例 | +| ExportAdapter | 参数、幂等、耗时 | 参数定了再写,之前不算完成 | + +## Top 5 风险 + +1. **DB/git 分裂**(崩溃导致元数据、仓库、审计三边不一致)→ provisioning 状态机 + 对账 job,Phase 3 前置 +2. **移动子树改变整树权限** → D12 事务+锁+源目标双授权,并发测试 +3. **creator/独立权限开关交互** → D11 先冻结 +4. **外部契约缺口**(导出参数、请求内 resolve 合并)→ OPEN 清单跟踪,mock 保真红线不许掩盖 +5. **存储滥用/路径逃逸** → 路径安全 + 配额进 Phase 3 验收,不拖到硬化 + +## OPEN 清单(在契约文档基础上增补) + +- OPEN-9:单请求内合并多次 resolve 是否符合 G4"实时"语义(找 Group 团队确认) +- OPEN-10:恶意软件扫描归属(我方/平台/不做) +- 其余 OPEN-1~8 见《文件库-接口契约.md》第 10 节 diff --git a/hub/filelib-web/index.html b/hub/filelib-web/index.html new file mode 100644 index 0000000..e395a5c --- /dev/null +++ b/hub/filelib-web/index.html @@ -0,0 +1,12 @@ + + + + + + 文件库 + + +
+ + + diff --git a/hub/filelib-web/package-lock.json b/hub/filelib-web/package-lock.json new file mode 100644 index 0000000..8a6f9c7 --- /dev/null +++ b/hub/filelib-web/package-lock.json @@ -0,0 +1,1621 @@ +{ + "name": "filelib-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "filelib-web", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.2", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.2", + "typescript": "^5.7.0", + "vite": "^8.0.16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz", + "integrity": "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/hub/filelib-web/package.json b/hub/filelib-web/package.json new file mode 100644 index 0000000..43c706a --- /dev/null +++ b/hub/filelib-web/package.json @@ -0,0 +1,21 @@ +{ + "name": "filelib-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.2", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.2", + "typescript": "^5.7.0", + "vite": "^8.0.16" + } +} diff --git a/hub/filelib-web/src/App.svelte b/hub/filelib-web/src/App.svelte new file mode 100644 index 0000000..cc69f92 --- /dev/null +++ b/hub/filelib-web/src/App.svelte @@ -0,0 +1,30 @@ + + +{#if !$authChecked} +
加载中…
+{:else if $me} + +{:else} + +{/if} + + diff --git a/hub/filelib-web/src/app.css b/hub/filelib-web/src/app.css new file mode 100644 index 0000000..1beee79 --- /dev/null +++ b/hub/filelib-web/src/app.css @@ -0,0 +1,66 @@ +@import "tailwindcss"; + +/* 全局 UI 主题令牌(与 hub 端 uiTheme.ts 同源) */ +@theme { + --color-bg: #fcfcfb; + --color-panel: #ffffff; + --color-sidebar: #f7f7f5; + --color-ink: #1a1a18; + --color-ink-2: #6b6a66; + --color-ink-3: #9c9b96; + --color-line: #ecece8; + --color-line-soft: #f1f1ee; + --color-hover: #f4f4f1; + --color-selected: #ebebe7; + --color-accent: #1a1a18; + --color-accent-hover: #333330; + --color-danger: #a13a33; + --color-guide: #e9e9e5; + --color-diff-add-bg: #f3f6f2; + --color-diff-add-text: #4a6741; + --color-diff-del-bg: #f8f2f1; + --color-diff-del-text: #a13a33; +} + +html, +body, +#app { + height: 100%; +} + +body { + background: var(--color-bg); + color: var(--color-ink); + font-family: + "Inter", + -apple-system, + "Segoe UI", + "PingFang SC", + "Microsoft YaHei", + sans-serif; + font-size: 14px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* diff 渲染 */ +pre.diff { + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; + line-height: 1.75; +} +pre.diff .add { + display: block; + color: var(--color-diff-add-text); + background: var(--color-diff-add-bg); +} +pre.diff .del { + display: block; + color: var(--color-diff-del-text); + background: var(--color-diff-del-bg); +} diff --git a/hub/filelib-web/src/lib/BrowserShell.svelte b/hub/filelib-web/src/lib/BrowserShell.svelte new file mode 100644 index 0000000..87d841f --- /dev/null +++ b/hub/filelib-web/src/lib/BrowserShell.svelte @@ -0,0 +1,141 @@ + + +
+ + + + +
+ +
+ + + {#if $selectedFilePath && $currentNode?.kind === "PROJECT"} +
+ +
+ {/if} +
+ +{#if showCreateRoot} + (showCreateRoot = false)}> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+{/if} diff --git a/hub/filelib-web/src/lib/FileEditor.svelte b/hub/filelib-web/src/lib/FileEditor.svelte new file mode 100644 index 0000000..a046e1e --- /dev/null +++ b/hub/filelib-web/src/lib/FileEditor.svelte @@ -0,0 +1,165 @@ + + +{#if loadError} +
{loadError}
+{:else if file} +
+
+ {file.path} @ {file.version} +
+ 下载 + + {#if canEdit} + + {/if} + {#if onclose} + + {/if} +
+
+ + {#if file.encoding === "base64"} +
二进制文件({file.size} B),不支持在线编辑
+ {:else} + + {/if} + + {#if canEdit && file.encoding !== "base64"} +
+ +
+ {/if} + + {#if conflict} +
+
冲突:他人已提交 {conflict.currentVersion},差异如下(你的基版 → 最新版)
+
{@html renderDiff(conflict.diff)}
+
请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})
+
+ +
+
+ {/if} +
+{:else} +
加载中…
+{/if} + +{#if showHistory} + (showHistory = false)}> +
+ {#each history as v (v.version)} +
+ {v.version} {v.message} +
{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}
+
+ {/each} +
+
+ +
+
+{/if} diff --git a/hub/filelib-web/src/lib/FilesPanel.svelte b/hub/filelib-web/src/lib/FilesPanel.svelte new file mode 100644 index 0000000..8d3b7ca --- /dev/null +++ b/hub/filelib-web/src/lib/FilesPanel.svelte @@ -0,0 +1,137 @@ + + +
+
+
项目文件({files?.length ?? 0})
+ {#if canEdit} +
+ + + +
+ {/if} +
+ + {#if files === null && loadError === null} +
加载中…
+ {:else if loadError} +
{loadError}
+ {:else if files && files.length === 0} +
空仓库 · 可新建或上传文件
+ {:else if files} + + + {#each files as f (f.path)} + selectedFilePath.set(f.path)} + > + + + + {/each} + +
{f.path}{f.size} B
+ {/if} +
+ +{#if showNewFile} + (showNewFile = false)}> +
+ + +
+
+ + +
+
+ + +
+
+{/if} diff --git a/hub/filelib-web/src/lib/LoginView.svelte b/hub/filelib-web/src/lib/LoginView.svelte new file mode 100644 index 0000000..a5cf7f2 --- /dev/null +++ b/hub/filelib-web/src/lib/LoginView.svelte @@ -0,0 +1,47 @@ + + +
+
+
文件库
+

课程资源与教研文件,一处安放,随处可查

+ + {#if info} + 使用飞书登录 + + {#if info.devLoginEnabled} +
+ 开发模式 + +
+ ⚡ 一键登录(老师) +

仅开发环境可见 · 跳过飞书 OAuth

+ {/if} + {:else if loadFailed} +

无法加载登录配置,请稍后重试

+ {:else} +

加载中…

+ {/if} +
+
diff --git a/hub/filelib-web/src/lib/Modal.svelte b/hub/filelib-web/src/lib/Modal.svelte new file mode 100644 index 0000000..233736a --- /dev/null +++ b/hub/filelib-web/src/lib/Modal.svelte @@ -0,0 +1,16 @@ + + + diff --git a/hub/filelib-web/src/lib/NodeDetailPanel.svelte b/hub/filelib-web/src/lib/NodeDetailPanel.svelte new file mode 100644 index 0000000..13209d3 --- /dev/null +++ b/hub/filelib-web/src/lib/NodeDetailPanel.svelte @@ -0,0 +1,144 @@ + + +{#if node === null} +
从左侧选择一个文件夹或项目
+{:else} +
+
+ {#each crumbs as c, i (i)} + {#if i > 0}/{/if} + {c.name ?? "…"} + {/each} +
+ +
+
+ {node.name} + {node.kind === "PROJECT" ? "项目" : "文件夹"} +
+
+ {#if canEdit && node.kind === "FOLDER"} + + {/if} + {#if canManage} + + + {/if} +
+
+ + {#if node.kind === "FOLDER"} +
点击左侧树展开以浏览子内容
+ {:else} +
+ {#each [["detail", "概览"], ["files", "文件"]] as [id, label] (id)} + + {/each} +
+ {#if tab === "detail"} + + {:else} + + {/if} + {/if} +
+{/if} + +{#if showCreateChild && node} + (showCreateChild = false)}> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+{/if} diff --git a/hub/filelib-web/src/lib/OverviewPanel.svelte b/hub/filelib-web/src/lib/OverviewPanel.svelte new file mode 100644 index 0000000..cf5a6f7 --- /dev/null +++ b/hub/filelib-web/src/lib/OverviewPanel.svelte @@ -0,0 +1,123 @@ + + +
+
+
简介
+
+ {#if node.description} + {node.description} + {:else} + 暂无简介 + {/if} + {#if canEdit} + + {/if} +
+
+ +
+ +
+
我的角色 {roleLabel}
+
创建时间 {new Date(node.createdAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}
+
最近修改 {new Date(node.updatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}
+
+ +
+ +
导出
+
+ + {#if exportJob} + + {#if exportJob.status === "DONE"} + 完成 · 下载 + {:else if exportJob.status === "FAILED"} + 失败:{exportJob.error ?? ""} + {:else} + {exportJob.status}… + {/if} + + {/if} +
+
+ +{#if showEditDesc} + (showEditDesc = false)}> +
+ + +
+
+ + +
+
+{/if} diff --git a/hub/filelib-web/src/lib/Toasts.svelte b/hub/filelib-web/src/lib/Toasts.svelte new file mode 100644 index 0000000..bf99161 --- /dev/null +++ b/hub/filelib-web/src/lib/Toasts.svelte @@ -0,0 +1,11 @@ + + +
+ {#each $toasts as t (t.id)} +
+ {t.message} +
+ {/each} +
diff --git a/hub/filelib-web/src/lib/TreeNode.svelte b/hub/filelib-web/src/lib/TreeNode.svelte new file mode 100644 index 0000000..cb46268 --- /dev/null +++ b/hub/filelib-web/src/lib/TreeNode.svelte @@ -0,0 +1,82 @@ + + +
+
e.key === "Enter" && select()} + > + + {#if node.kind === "FOLDER"} + + {#if isOpen}{:else}{/if} + + {/if} + + + {#if node.kind === "PROJECT"} + + {:else} + + {/if} + + {node.name} + {#if node.role !== "MANAGE"} + {node.role} + {/if} +
+ + {#if node.kind === "FOLDER" && isOpen} +
+ {#if children === null} +
+ {:else} + {#each children as child (child.id)} + + {/each} + {/if} +
+ {/if} +
diff --git a/hub/filelib-web/src/lib/api.ts b/hub/filelib-web/src/lib/api.ts new file mode 100644 index 0000000..b57ba6a --- /dev/null +++ b/hub/filelib-web/src/lib/api.ts @@ -0,0 +1,49 @@ +/** 与 /database/api/* 的约定一致;401 时清空会话(回到登录视图)。 */ + +import { me } from "./stores.js"; + +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly details?: Record, + ) { + super(message); + this.name = "ApiError"; + } +} + +export class UnauthenticatedError extends Error { + constructor() { + super("unauthenticated"); + this.name = "UnauthenticatedError"; + } +} + +interface RequestOpts { + readonly method?: string; + readonly body?: unknown; +} + +export async function api(path: string, opts: RequestOpts = {}): Promise { + const res = await fetch(path, { + credentials: "same-origin", + method: opts.method ?? "GET", + ...(opts.body !== undefined + ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts.body) } + : {}), + }); + if (res.status === 401) { + me.set(null); + throw new UnauthenticatedError(); + } + if (res.status === 204) return null as T; + const text = await res.text(); + const data = text === "" ? null : (JSON.parse(text) as unknown); + if (!res.ok) { + const err = (data as { error?: { code?: string; message?: string } } | null)?.error ?? {}; + throw new ApiError(res.status, err.code ?? "unknown", err.message ?? res.statusText, err as Record); + } + return data as T; +} diff --git a/hub/filelib-web/src/lib/browser.ts b/hub/filelib-web/src/lib/browser.ts new file mode 100644 index 0000000..b80fb81 --- /dev/null +++ b/hub/filelib-web/src/lib/browser.ts @@ -0,0 +1,34 @@ +import { writable } from "svelte/store"; +import type { BreadcrumbEntry, NodeDetail } from "./types.js"; + +/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */ +export const expanded = writable>(new Set()); +export const currentNode = writable(null); +export const breadcrumb = writable([]); +export const treeVersion = writable(0); + +/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */ +export const selectedFilePath = writable(null); +/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */ +export const filesVersion = writable(0); + +export function bumpTree(): void { + treeVersion.update((v) => v + 1); +} + +export function bumpFiles(): void { + filesVersion.update((v) => v + 1); +} + +export function clearSelectedFile(): void { + selectedFilePath.set(null); +} + +export function toggleExpanded(id: string): void { + expanded.update((set) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); +} diff --git a/hub/filelib-web/src/lib/stores.ts b/hub/filelib-web/src/lib/stores.ts new file mode 100644 index 0000000..cb11432 --- /dev/null +++ b/hub/filelib-web/src/lib/stores.ts @@ -0,0 +1,26 @@ +import { writable } from "svelte/store"; +import type { MeResponse } from "./types.js"; + +/** 当前登录身份;null = 未登录(显示登录视图)。 */ +export const me = writable(null); +export const authChecked = writable(false); + +export interface ToastItem { + readonly id: number; + readonly message: string; + readonly kind: "info" | "err"; +} + +let nextToastId = 1; +export const toasts = writable([]); + +export function toast(message: string, kind: ToastItem["kind"] = "info"): void { + const id = nextToastId++; + toasts.update((list) => [...list, { id, message, kind }]); + setTimeout(() => { + toasts.update((list) => list.filter((t) => t.id !== id)); + }, 3600); +} + +export const toastOk = (m: string): void => toast(m, "info"); +export const toastErr = (m: string): void => toast(m, "err"); diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts new file mode 100644 index 0000000..60bfc35 --- /dev/null +++ b/hub/filelib-web/src/lib/types.ts @@ -0,0 +1,85 @@ +/** 与后端 /database/api/* 响应形状对齐。 */ + +export type NodeKind = "FOLDER" | "PROJECT"; +export type Role = "VIEW" | "EDIT" | "MANAGE"; + +export interface NodeChild { + readonly id: string; + readonly parentId: string | null; + readonly kind: NodeKind; + readonly name: string; + readonly role: Role; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface BreadcrumbEntry { + readonly depth: number; + readonly id: string | null; + readonly name: string | null; + readonly kind: NodeKind; +} + +export interface NodeDetail { + readonly id: string; + readonly parentId: string | null; + readonly kind: NodeKind; + readonly name: string; + readonly description: string | null; + readonly role: Role; + readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED"; + readonly independentPermission: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface MeResponse { + readonly userId: string; + readonly isWebsiteAdmin: boolean; +} + +export interface FileEntry { + readonly path: string; + readonly size: number; +} + +export type FileContentEncoding = "utf8" | "base64"; + +export interface FileContent { + readonly path: string; + readonly version: string; + readonly encoding: FileContentEncoding; + readonly content: string; + readonly size: number; +} + +export interface VersionInfo { + readonly version: string; + readonly message: string; + readonly author?: string; + readonly committedAt: string; +} + +export interface ExportJob { + readonly id: string; + readonly nodeId: string; + readonly target: string; + readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED"; + readonly error: string | null; + readonly createdAt: string; +} + +export interface Grant { + readonly id: string; + readonly principalType: "USER" | "GROUP"; + readonly principalId: string; + readonly role: Role; + readonly isCreatorGrant: boolean; + readonly createdAt: string; +} + +export interface GroupSearchResult { + readonly id: string; + readonly name: string; + readonly breadcrumb: string; +} diff --git a/hub/filelib-web/src/main.ts b/hub/filelib-web/src/main.ts new file mode 100644 index 0000000..727d356 --- /dev/null +++ b/hub/filelib-web/src/main.ts @@ -0,0 +1,5 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./app.css"; + +export default mount(App, { target: document.getElementById("app")! }); diff --git a/hub/filelib-web/tsconfig.json b/hub/filelib-web/tsconfig.json new file mode 100644 index 0000000..80e1189 --- /dev/null +++ b/hub/filelib-web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/hub/filelib-web/vite.config.ts b/hub/filelib-web/vite.config.ts new file mode 100644 index 0000000..9e4b9c6 --- /dev/null +++ b/hub/filelib-web/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import tailwindcss from "@tailwindcss/vite"; + +// 老师端独立 SPA:构建产物由 hub 后端静态托管于 /app; +// 开发时 vite dev(:5173)把 API/认证请求代理到后端(:8788)。 +const backend = "http://127.0.0.1:8788"; + +export default defineConfig({ + plugins: [svelte(), tailwindcss()], + base: "/app/", + build: { + outDir: "dist", + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + "/database/api": backend, + "/auth": backend, + "/app/dev-login": backend, + "/app/dev-login-teacher": backend, + }, + }, +}); diff --git a/hub/package-lock.json b/hub/package-lock.json index 384f9ce..4a8ce48 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -13,6 +13,7 @@ "@alicloud/tea-util": "^1.4.11", "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", + "@fastify/static": "^10.1.2", "@larksuiteoapi/node-sdk": "^1.70.0", "@prisma/client": "^6.19.3", "ai": "^7.0.16", @@ -902,6 +903,22 @@ "node": ">=18" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -1033,6 +1050,83 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz", + "integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/static/node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@fastify/static/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1068,6 +1162,15 @@ "ws": "^8.19.0" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -1934,6 +2037,15 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1973,6 +2085,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2254,7 +2378,6 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2447,8 +2570,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/estree-walker": { "version": "3.0.3", @@ -2947,6 +3069,23 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3013,7 +3152,6 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", - "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -3096,8 +3234,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -3546,6 +3683,15 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3588,6 +3734,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3609,6 +3767,30 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -3792,6 +3974,22 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -4370,8 +4568,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -4570,7 +4767,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -4658,7 +4854,6 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.6" } diff --git a/hub/package.json b/hub/package.json index f507746..d70975e 100644 --- a/hub/package.json +++ b/hub/package.json @@ -12,6 +12,7 @@ "@alicloud/tea-util": "^1.4.11", "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", + "@fastify/static": "^10.1.2", "@larksuiteoapi/node-sdk": "^1.70.0", "@prisma/client": "^6.19.3", "ai": "^7.0.16", @@ -33,7 +34,7 @@ "description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).", "scripts": { "dev": "npm run prisma:migrate && tsx watch src/server.ts", - "build": "tsc -p tsconfig.json && npm run admin:build", + "build": "tsc -p tsconfig.json && npm run admin:build && npm run filelib:build", "start": "npm run prisma:migrate && node dist/server.js", "check": "tsc -p tsconfig.json --noEmit", "audit:production": "npm audit --omit=dev --audit-level=high", @@ -48,6 +49,8 @@ "test": "vitest run", "test:watch": "vitest", "admin:dev": "npm run dev --prefix admin-web", - "admin:build": "npm run build --prefix admin-web" + "admin:build": "npm run build --prefix admin-web", + "filelib:dev": "npm run dev --prefix filelib-web", + "filelib:build": "npm run build --prefix filelib-web" } } diff --git a/hub/prisma/migrations/20260721120000_file_library/migration.sql b/hub/prisma/migrations/20260721120000_file_library/migration.sql new file mode 100644 index 0000000..025d69a --- /dev/null +++ b/hub/prisma/migrations/20260721120000_file_library/migration.sql @@ -0,0 +1,92 @@ +-- File library (文件库) — 语义锚定:仓库根《文件库-接口契约.md》、.omo/文件库-开工计划.md (D11–D19)。 +-- 本地无 PG 时手写;有 PG 后可用 `prisma migrate diff` 核对与 schema 的一致性。 + +-- CreateEnum +CREATE TYPE "FileLibNodeKind" AS ENUM ('FOLDER', 'PROJECT'); +CREATE TYPE "FileLibProvisionStatus" AS ENUM ('PROVISIONING', 'READY', 'FAILED'); +CREATE TYPE "FileLibRole" AS ENUM ('VIEW', 'EDIT', 'MANAGE'); +CREATE TYPE "FileLibPrincipalType" AS ENUM ('USER', 'GROUP'); +CREATE TYPE "FileLibExportStatus" AS ENUM ('QUEUED', 'RUNNING', 'DONE', 'FAILED'); + +-- CreateTable +CREATE TABLE "FileLibNode" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "parentId" TEXT, + "kind" "FileLibNodeKind" NOT NULL, + "name" TEXT NOT NULL, + "nameLower" TEXT NOT NULL, + "pathIds" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "provisionStatus" "FileLibProvisionStatus" NOT NULL DEFAULT 'READY', + "storageDir" TEXT, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FileLibNode_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "FileLibGrant" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "principalType" "FileLibPrincipalType" NOT NULL, + "principalId" TEXT NOT NULL, + "role" "FileLibRole" NOT NULL, + "isCreatorGrant" BOOLEAN NOT NULL DEFAULT false, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "FileLibGrant_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "FileLibProjectSettings" ( + "nodeId" TEXT NOT NULL, + "independentPermissionsEnabled" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "FileLibProjectSettings_pkey" PRIMARY KEY ("nodeId") +); + +CREATE TABLE "FileLibExportJob" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "target" TEXT NOT NULL, + "params" JSONB NOT NULL, + "status" "FileLibExportStatus" NOT NULL DEFAULT 'QUEUED', + "downloadUrl" TEXT, + "error" TEXT, + "createdByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FileLibExportJob_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex (schema-declared) +CREATE INDEX "FileLibNode_organizationId_parentId_deletedAt_idx" ON "FileLibNode"("organizationId", "parentId", "deletedAt"); +CREATE INDEX "FileLibNode_organizationId_pathIds_idx" ON "FileLibNode"("organizationId", "pathIds"); +CREATE INDEX "FileLibNode_creatorId_idx" ON "FileLibNode"("creatorId"); +CREATE INDEX "FileLibGrant_organizationId_revokedAt_idx" ON "FileLibGrant"("organizationId", "revokedAt"); +CREATE INDEX "FileLibGrant_principalType_principalId_revokedAt_idx" ON "FileLibGrant"("principalType", "principalId", "revokedAt"); +CREATE INDEX "FileLibGrant_nodeId_revokedAt_idx" ON "FileLibGrant"("nodeId", "revokedAt"); +CREATE INDEX "FileLibExportJob_organizationId_status_idx" ON "FileLibExportJob"("organizationId", "status"); + +-- D14:活跃兄弟节点大小写不敏感唯一。root 的 parentId 为 NULL,用 COALESCE 归入同一键空间。 +CREATE UNIQUE INDEX "FileLibNode_active_sibling_name_key" + ON "FileLibNode"("organizationId", COALESCE("parentId", ''), "nameLower") + WHERE "deletedAt" IS NULL; + +-- 契约 2.3:同一节点上同一 principal 至多一条活跃授权(Postgres 原生 UNIQUE 无法约束 NULL revokedAt)。 +CREATE UNIQUE INDEX "FileLibGrant_active_unique" + ON "FileLibGrant"("nodeId", "principalType", "principalId") + WHERE "revokedAt" IS NULL; + +-- AddForeignKey +ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileLibNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "FileLibGrant" ADD CONSTRAINT "FileLibGrant_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FileLibProjectSettings" ADD CONSTRAINT "FileLibProjectSettings_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FileLibExportJob" ADD CONSTRAINT "FileLibExportJob_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/prisma/migrations/20260723130000_filelib_description/migration.sql b/hub/prisma/migrations/20260723130000_filelib_description/migration.sql new file mode 100644 index 0000000..bb88639 --- /dev/null +++ b/hub/prisma/migrations/20260723130000_filelib_description/migration.sql @@ -0,0 +1,2 @@ +-- 为 FileLibNode 加简介字段,老师在创建/概览页填写。 +ALTER TABLE "FileLibNode" ADD COLUMN "description" TEXT; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 9e619ea..4818b16 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -50,6 +50,7 @@ model Organization { projectGroupBindings ProjectGroupBinding[] auditEntries AuditEntry[] @relation("organizationAudit") projectSearchDocuments ProjectSearchDocument[] + fileLibNodes FileLibNode[] @@index([status]) } @@ -923,3 +924,127 @@ model CapabilityCredentialVersion { @@index([keyId]) @@index([createdByUserId]) } + +// --- File library (文件库) ------------------------------------------------- +// +// 独立模块,语义由仓库根《文件库-接口契约.md》(C/D 编号)与 .omo/文件库-开工计划.md +// (D11–D19)锚定。与上面的 Folder/Project(hub 自己的 explorer,ADR-0021)是两套 +// 体系,不复用、不互相引用。 + +/// 文件库目录树节点:文件夹(容器)或项目(叶子,关联 git 仓库)。 +/// parentId 是树的权威关系;pathIds 是 id 编码的物化路径(派生),随 create/move +/// 在事务内维护(计划 D12;name 不入路径,rename 不重写后代)。 +model FileLibNode { + id String @id + organizationId String + parentId String? + kind FileLibNodeKind + name String + /// D14:NFC+trim 的小写形式;活跃兄弟节点大小写不敏感唯一(部分唯一索引在迁移 SQL)。 + nameLower String + /// id 编码物化路径,形如 "/rootId/childId/selfId"。祖先展开与前缀查询都用它。 + pathIds String + /// D11:创建者不可变,自动持有 isCreatorGrant=true 的 MANAGE grant。 + /// 故意不建 FK:这是不可变历史事实,不随 User 生命周期变化。 + creatorId String + /// 老师可填写的简介,创建/概览页展示,通俗易懂地说明这个节点的用途。 + description String? + /// 项目 provisioning 状态机(DB/git 双写协调,Metis 风险#1);文件夹恒 READY。 + provisionStatus FileLibProvisionStatus @default(READY) + /// 项目仓库目录(绝对路径);文件夹为 null。 + storageDir String? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + parent FileLibNode? @relation("fileLibTree", fields: [parentId], references: [id], onDelete: Restrict) + children FileLibNode[] @relation("fileLibTree") + grants FileLibGrant[] + projectSettings FileLibProjectSettings? + exportJobs FileLibExportJob[] + + @@index([organizationId, parentId, deletedAt]) + @@index([organizationId, pathIds]) + @@index([creatorId]) +} + +enum FileLibNodeKind { + FOLDER + PROJECT +} + +enum FileLibProvisionStatus { + PROVISIONING + READY + FAILED +} + +/// 文件库权限级别:MANAGE > EDIT > VIEW(契约 2.2,只取最高、无降权)。 +enum FileLibRole { + VIEW + EDIT + MANAGE +} + +enum FileLibPrincipalType { + USER + GROUP +} + +/// 契约 2.3:grant 直接挂在节点上,最终权限 = max(个人, 递归 Group, 祖先继承)。 +/// 活跃授权唯一性由迁移里的部分唯一索引保证(revokedAt IS NULL)。 +model FileLibGrant { + id String @id @default(cuid()) + organizationId String + nodeId String + principalType FileLibPrincipalType + principalId String + role FileLibRole + /// D11:创建者自动 grant;独立权限开关关闭时,项目级 grant 里只有它仍生效。 + isCreatorGrant Boolean @default(false) + createdByUserId String? + createdAt DateTime @default(now()) + revokedAt DateTime? + + node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@index([organizationId, revokedAt]) + @@index([principalType, principalId, revokedAt]) + @@index([nodeId, revokedAt]) +} + +/// 契约 P5/D11:项目独立权限开关。默认关闭(仅继承);关闭时项目级非创建者 +/// grant 冻结不删除,重新开启即恢复。 +model FileLibProjectSettings { + nodeId String @id + independentPermissionsEnabled Boolean @default(false) + + node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) +} + +enum FileLibExportStatus { + QUEUED + RUNNING + DONE + FAILED +} + +/// 契约 D10:导出为异步任务。外部导出工具参数 OPEN-6,adapter 就位前先建模型。 +model FileLibExportJob { + id String @id @default(cuid()) + organizationId String + nodeId String + target String + params Json + status FileLibExportStatus @default(QUEUED) + downloadUrl String? + error String? + createdByUserId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@index([organizationId, status]) +} diff --git a/hub/src/database/README.md b/hub/src/database/README.md index 61d5975..101f2bf 100644 --- a/hub/src/database/README.md +++ b/hub/src/database/README.md @@ -58,12 +58,56 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 | 文件 | 职责 | |------|------| | `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` | -| `routes/databaseRoutes.ts` | 路由 + 页面渲染,**你主要在这里加内容** | +| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 | +| `routes/filelibRoutes.ts` | 文件库 树/授权/搜索 API | +| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API | +| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 | +| `filelib/` | 文件库领域层(见下) | 新增一类端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`, 要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})` 注册一次。 +## 文件库(filelib/) + +独立文件库模块,语义由仓库根《文件库-接口契约.md》(C/D 编号)与 +`.omo/文件库-开工计划.md`(D11–D19)锚定。**与 hub 自己的 Folder/Project +(ADR-0021 explorer)是两套体系,不复用。** + +| 文件 | 职责 | +|------|------| +| `filelib/model.ts` | 角色秩(MANAGE>EDIT>VIEW)、D14 命名规则、FileLibError | +| `filelib/permission.ts` | 纯权限 reducer(取最高/不降权/祖先继承/D11 冻结),不碰 IO | +| `filelib/treeService.ts` | 树增删改查;每个写操作同事务落审计 | +| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust | +| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) | +| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port | +| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) | +| `filelib/groupResolver.ts` | 契约 C2 port + Team 过渡实现 | +| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) | +| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 | +| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) | +| `filelib/routeShared.ts` | 路由共享件(依赖装配/错误映射/请求体校验) | + +环境变量: + +- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`) +- `HUB_GROUP_SERVICE_URL` — Group 团队服务地址(C2);未配置时读 hub Team(扁平) + +> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**, +> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的 +> 持久化 git 包到位后此问题消失。 + +关键语义速查: + +- **D8**:无权限 → 404(不泄露存在性);越权 → 403;Group 服务故障 → 503 +- **D11**:creator 不可变 + 自动 MANAGE;独立权限关闭时项目级非创建者 grant 冻结 +- **D12**:move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁 +- **D15**:删除只打标本节点,"任一祖先已删"即整支不可见 +- **8.1**:MANAGE 仅创建者可授/收;creator grant 不可动 +- **审计**:一切写操作在业务事务内写 AuditEntry(同事务,失败即回滚); + 文件内容写先 versionStore.commit 再审计(宁多版本,不造假审计) + ## 约定(与 admin 面一致) 1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。 diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts new file mode 100644 index 0000000..f58a92c --- /dev/null +++ b/hub/src/database/filelib/audit.ts @@ -0,0 +1,72 @@ +/** + * 文件库审计 sink(契约 C3 的入驻适配)。 + * + * 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。 + * 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务 + * 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强。 + * 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。 + */ + +import type { Prisma } from "@prisma/client"; + +/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */ +export const FILE_LIB_AUDIT_ACTIONS = { + folderCreate: "folder.create", + folderRename: "folder.rename", + folderMove: "folder.move", + folderDelete: "folder.delete", + projectCreate: "project.create", + projectRename: "project.rename", + projectMove: "project.move", + projectDelete: "project.delete", + permissionGrant: "permission.grant", + permissionUpdate: "permission.update", + permissionRevoke: "permission.revoke", + independentEnable: "project.independent_permission.enable", + independentDisable: "project.independent_permission.disable", + independentChange: "project.independent_permission.change", + fileUpload: "file.upload", + fileRename: "file.rename", + fileDelete: "file.delete", + fileCommit: "file.commit", + fileConflictDetected: "file.conflict_detected", + exportRun: "export.run", + adminForceAdjust: "admin.force_adjust", +} as const; + +export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job"; + +export interface FileLibAuditEntry { + readonly action: string; + readonly actorUserId: string; + readonly organizationId: string; + readonly objectType: FileLibAuditObjectType; + readonly objectId: string; + /** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */ + readonly objectPath: string; + readonly detail?: Record | undefined; +} + +/** + * 在调用方的事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚 + * (需求 5.1"操作成功则日志必存在"的强保证)。 + */ +export async function writeFileLibAudit( + tx: Prisma.TransactionClient, + entry: FileLibAuditEntry, +): Promise { + const metadata: Record = { + objectType: entry.objectType, + objectId: entry.objectId, + objectPath: entry.objectPath, + ...(entry.detail ?? {}), + }; + await tx.auditEntry.create({ + data: { + action: entry.action, + actorUserId: entry.actorUserId, + organizationId: entry.organizationId, + metadata: metadata as Prisma.InputJsonValue, + }, + }); +} diff --git a/hub/src/database/filelib/exportService.ts b/hub/src/database/filelib/exportService.ts new file mode 100644 index 0000000..0ba4ec0 --- /dev/null +++ b/hub/src/database/filelib/exportService.ts @@ -0,0 +1,195 @@ +/** + * 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。 + * + * ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。 + * 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑; + * 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。 + */ + +import { randomUUID } from "node:crypto"; +import { FileLibError } from "./model.js"; +import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; +import { requireAccessInTx, type FileLibActor } from "./treeService.js"; +import type { FileDeps } from "./fileService.js"; + +export interface ExportAdapterInput { + readonly storageDir: string; + readonly target: string; + readonly params: Record; + readonly listFiles: (prefix?: string) => Promise; + readonly readFile: (path: string) => Promise; +} + +export interface ExportArtifact { + readonly filename: string; + readonly content: Buffer; +} + +export interface ExportAdapter { + readonly target: string; + run(input: ExportAdapterInput): Promise; +} + +/** stub 适配器:生成项目文件清单,端到端验证 job 状态机。OPEN-6 后换真导出工具。 */ +export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]): ExportAdapter { + return { + target: "manifest", + async run(input) { + const files = await input.listFiles(); + const lines = [ + `# Export manifest (stub adapter)`, + `target: ${input.target}`, + `storageDir: ${input.storageDir}`, + `files: ${files.length}`, + ``, + ...files.map((f) => `${String(f.size).padStart(10)} ${f.path}`), + ]; + return { filename: "manifest.txt", content: Buffer.from(lines.join("\n"), "utf8") }; + }, + }; +} + +// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。 +const artifacts = new Map(); + +export interface ExportDeps extends FileDeps { + readonly adapters: readonly ExportAdapter[]; +} + +export interface ExportJobDto { + readonly id: string; + readonly nodeId: string; + readonly target: string; + readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED"; + readonly error: string | null; + readonly createdAt: Date; +} + +/** 提交导出(需 VIEW):建行(QUEUED)+ export.run 审计,同事务;异步执行。 */ +export async function submitExport( + deps: ExportDeps, + actor: FileLibActor, + projectId: string, + target: string, + params: Record, +): Promise { + const { node } = await deps.prisma.$transaction(async (tx) => + requireAccessInTx(tx, deps, actor, projectId, "VIEW"), + ); + if (node.kind !== "PROJECT") { + throw new FileLibError(400, "invalid_node_kind", "export applies to projects only"); + } + const adapter = deps.adapters.find((a) => a.target === target); + if (adapter === undefined) { + throw new FileLibError(400, "unknown_target", `no export adapter for target "${target}"`); + } + if (node.storageDir === null) { + throw new FileLibError(409, "project_not_ready", "project repository is not ready"); + } + + const jobId = randomUUID(); + const job = await deps.prisma.$transaction(async (tx) => { + const created = await tx.fileLibExportJob.create({ + data: { + id: jobId, + organizationId: deps.organizationId, + nodeId: node.id, + target, + params: params as never, + status: "QUEUED", + createdByUserId: actor.userId, + }, + }); + await writeFileLibAudit(tx, { + action: FILE_LIB_AUDIT_ACTIONS.exportRun, + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: "export_job", + objectId: jobId, + objectPath: node.pathIds, + detail: { target, params }, + }); + return created; + }); + + const storageDir = node.storageDir; + setImmediate(() => { + void runExportJob(deps, adapter, jobId, storageDir, target, params).catch(() => undefined); + }); + return toDto(job); +} + +async function runExportJob( + deps: ExportDeps, + adapter: ExportAdapter, + jobId: string, + storageDir: string, + target: string, + params: Record, +): Promise { + await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "RUNNING" } }); + try { + const artifact = await adapter.run({ + storageDir, + target, + params, + listFiles: (prefix) => deps.versionStore.list(storageDir, prefix), + readFile: (path) => deps.versionStore.read(storageDir, path), + }); + artifacts.set(jobId, artifact); + await deps.prisma.fileLibExportJob.update({ + where: { id: jobId }, + data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` }, + }); + } catch (error) { + await deps.prisma.fileLibExportJob.update({ + where: { id: jobId }, + data: { status: "FAILED", error: String(error) }, + }); + } +} + +export async function getExportJob( + deps: ExportDeps, + actor: FileLibActor, + jobId: string, +): Promise { + const job = await deps.prisma.fileLibExportJob.findFirst({ + where: { id: jobId, organizationId: deps.organizationId }, + }); + if (job === null) throw new FileLibError(404, "export_not_found", "export job not found"); + // D8:对源项目无 View → 404(不泄露 job 存在性)。 + await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, job.nodeId, "VIEW")); + return toDto(job); +} + +export async function downloadExport( + deps: ExportDeps, + actor: FileLibActor, + jobId: string, +): Promise { + await getExportJob(deps, actor, jobId); + const artifact = artifacts.get(jobId); + if (artifact === undefined) { + throw new FileLibError(409, "export_not_ready", "export artifact is not available"); + } + return artifact; +} + +function toDto(job: { + id: string; + nodeId: string; + target: string; + status: "QUEUED" | "RUNNING" | "DONE" | "FAILED"; + error: string | null; + createdAt: Date; +}): ExportJobDto { + return { + id: job.id, + nodeId: job.nodeId, + target: job.target, + status: job.status, + error: job.error, + createdAt: job.createdAt, + }; +} diff --git a/hub/src/database/filelib/fileService.ts b/hub/src/database/filelib/fileService.ts new file mode 100644 index 0000000..f6cac66 --- /dev/null +++ b/hub/src/database/filelib/fileService.ts @@ -0,0 +1,275 @@ +/** + * 文件内容服务(Phase 3):路径安全 + 版本化文件操作 + 审计。 + * + * 顺序铁律(Metis 风险#1 的落地): + * - 内容写:先 versionStore.commit(业务事实本体)→ 再 DB 事务(审计)。 + * 宁多一个无审计的版本,不造一条假审计。 + * - conflict:写 file.conflict_detected(冲突本身就是事件),再抛 409。 + * - 读:随取随读,不写审计(需求 5.2 未列读操作)。 + */ + +import { FileLibError } from "./model.js"; +import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; +import type { CommitResult, FileEntry, VersionInfo, VersionStore } from "./versionStore.js"; +import type { AccessDeps, FileLibActor } from "./treeService.js"; +import { requireAccessInTx } from "./treeService.js"; +import type { FileLibNode, PrismaClient } from "@prisma/client"; + +export const FILE_PATH_MAX_LENGTH = 512; +export const FILE_PATH_MAX_DEPTH = 32; +export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值 + +const CONTROL_CHARS = /[\p{C}]/u; +const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]); + +/** + * 路径安全(Metis 安全红线):NFC;拒绝反斜杠、控制字符、空段、 + * "." / ".." / ".git" 段、绝对路径、超长/超深。返回规范化相对路径。 + */ +export function validateFilePath(raw: string): string { + const normalized = raw.normalize("NFC"); + if (normalized.length === 0 || normalized.length > FILE_PATH_MAX_LENGTH) { + throw new FileLibError(400, "invalid_path", `path must be 1..${FILE_PATH_MAX_LENGTH} characters`); + } + if (normalized.includes("\\")) { + throw new FileLibError(400, "invalid_path", "path must use '/' separators"); + } + if (CONTROL_CHARS.test(normalized)) { + throw new FileLibError(400, "invalid_path", "path must not contain control characters"); + } + const segments = normalized.split("/"); + if (segments.length > FILE_PATH_MAX_DEPTH) { + throw new FileLibError(400, "invalid_path", `path depth exceeds ${FILE_PATH_MAX_DEPTH}`); + } + for (const segment of segments) { + if (FORBIDDEN_SEGMENTS.has(segment)) { + throw new FileLibError(400, "invalid_path", `forbidden path segment: "${segment}"`); + } + } + return normalized; +} + +export interface FileDeps extends AccessDeps { + readonly prisma: PrismaClient; + readonly versionStore: VersionStore; +} + +type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string }; + +async function requireProject( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + minRole: "VIEW" | "EDIT", +): Promise { + const { node } = await deps.prisma.$transaction(async (tx) => + requireAccessInTx(tx, deps, actor, projectId, minRole), + ); + if (node.kind !== "PROJECT") { + throw new FileLibError(400, "invalid_node_kind", "file operations apply to projects only"); + } + if (node.provisionStatus === "PROVISIONING") { + throw new FileLibError(409, "project_not_ready", "project repository is still provisioning"); + } + if (node.provisionStatus === "FAILED") { + throw new FileLibError(409, "project_not_ready", "project repository provisioning failed"); + } + if (node.storageDir === null) { + throw new FileLibError(500, "storage_missing", "project has no storage directory"); + } + return { node, storageDir: node.storageDir }; +} + +export type FileContentEncoding = "utf8" | "base64"; + +export interface FileContentDto { + readonly path: string; + readonly version: string; + readonly encoding: FileContentEncoding; + readonly content: string; + readonly size: number; +} + +export function decodeContent(content: string, encoding: FileContentEncoding): string | Buffer { + return encoding === "base64" ? Buffer.from(content, "base64") : content; +} + +function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding; readonly content: string } { + // 粗判二进制:含 NUL 字节即按 base64 返回(需求 2.5 在线编辑仅针对文本)。 + return buffer.includes(0) + ? { encoding: "base64", content: buffer.toString("base64") } + : { encoding: "utf8", content: buffer.toString("utf8") }; +} + +function checkSize(content: string | Buffer): void { + const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength; + if (bytes > FILE_CONTENT_MAX_BYTES) { + throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`); + } +} + +async function auditFile( + deps: FileDeps, + actor: FileLibActor, + action: string, + project: ProjectChain, + filePath: string, + detail: Record, +): Promise { + await deps.prisma.$transaction(async (tx) => { + await writeFileLibAudit(tx, { + action, + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: "file", + objectId: project.node.id, + objectPath: `${project.node.pathIds}:${filePath}`, + detail, + }); + }); +} + +/* ---------------------------------------------------------------- 读操作 */ + +export async function listFiles( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + prefix?: string, +): Promise { + const project = await requireProject(deps, actor, projectId, "VIEW"); + return deps.versionStore.list(project.storageDir, prefix === undefined ? undefined : validateFilePath(prefix)); +} + +export async function readFile( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + rawPath: string, +): Promise { + const filePath = validateFilePath(rawPath); + const project = await requireProject(deps, actor, projectId, "VIEW"); + const [version, buffer] = await Promise.all([ + deps.versionStore.head(project.storageDir, filePath), + deps.versionStore.read(project.storageDir, filePath), + ]); + const { encoding, content } = encodeContent(buffer); + return { path: filePath, version, encoding, content, size: buffer.byteLength }; +} + +/** 原始字节下载(浏览器 save-as 用):JSON 之外的第二条读取通道,同样的 VIEW 门禁。 */ +export async function readFileRaw( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + rawPath: string, +): Promise<{ readonly buffer: Buffer; readonly version: string; readonly filename: string }> { + const filePath = validateFilePath(rawPath); + const project = await requireProject(deps, actor, projectId, "VIEW"); + const [version, buffer] = await Promise.all([ + deps.versionStore.head(project.storageDir, filePath), + deps.versionStore.read(project.storageDir, filePath), + ]); + return { buffer, version, filename: filePath.split("/").pop() ?? "download" }; +} + +export async function fileHistory( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + rawPath: string, + limit?: number, +): Promise { + const filePath = validateFilePath(rawPath); + const project = await requireProject(deps, actor, projectId, "VIEW"); + return deps.versionStore.history(project.storageDir, filePath, limit); +} + +export async function diffFile( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + rawPath: string, + from: string, + to: string, +): Promise<{ readonly diff: string }> { + const filePath = validateFilePath(rawPath); + const project = await requireProject(deps, actor, projectId, "VIEW"); + return { diff: await deps.versionStore.diff(project.storageDir, filePath, from, to) }; +} + +/* ---------------------------------------------------------------- 写操作 */ + +export interface CommitInput { + readonly path: string; + /** null = 新建(已存在则 409);编辑时传 readFile 拿到的 version。 */ + readonly baseVersion: string | null; + readonly content: string; + readonly encoding?: FileContentEncoding | undefined; + readonly message?: string | undefined; +} + +/** + * 统一写入口(上传/编辑共用):先 commit,成功写 file.commit / file.upload 审计; + * 冲突写 file.conflict_detected 后抛 409(details 带 currentVersion,编辑 UI 用它拉 diff)。 + */ +export async function commitFile( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + input: CommitInput, +): Promise<{ readonly version: string }> { + const filePath = validateFilePath(input.path); + const content = decodeContent(input.content, input.encoding ?? "utf8"); + checkSize(content); + const project = await requireProject(deps, actor, projectId, "EDIT"); + + const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, { + baseVersion: input.baseVersion, + content, + message: input.message, + author: actor.userId, + }); + + if (result.status === "conflict") { + await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, { + baseVersion: input.baseVersion, + currentVersion: result.currentVersion, + }); + throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", { + currentVersion: result.currentVersion, + }); + } + + await auditFile( + deps, + actor, + input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit, + project, + filePath, + { version: result.version, message: input.message ?? null }, + ); + return { version: result.version }; +} + +export async function deleteFile( + deps: FileDeps, + actor: FileLibActor, + projectId: string, + rawPath: string, + baseVersion: string, +): Promise { + const filePath = validateFilePath(rawPath); + const project = await requireProject(deps, actor, projectId, "EDIT"); + const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion); + if (result.status === "conflict") { + await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, { + baseVersion, + currentVersion: result.currentVersion, + }); + throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", { + currentVersion: result.currentVersion, + }); + } + await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion }); +} diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts new file mode 100644 index 0000000..cbb9f05 --- /dev/null +++ b/hub/src/database/filelib/grantService.ts @@ -0,0 +1,319 @@ +/** + * 授权管理(契约 8.1 矩阵的服务端强制)。 + * + * 矩阵: + * - 创建者(creatorId 不可变):可授/改/收 MANAGE、EDIT、VIEW;自身 creator grant 不可动 + * - MANAGE 持有者:可授/改/收 EDIT、VIEW;不可碰 MANAGE;不可动创建者 + * - EDIT/VIEW:无授权能力(requireAccess MANAGE 已挡) + * - 网站管理员:走 forceAdjustGrants(不查节点权限,D19),全部留 admin.force_adjust 审计 + * + * D8:目标节点不可见/无权限 → 404;有权限但矩阵禁止 → 403。 + */ + +import { Prisma } from "@prisma/client"; +import type { FileLibGrant, FileLibNode, PrismaClient } from "@prisma/client"; +import { FileLibError, type FileLibRole } from "./model.js"; +import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; +import { + requireAccessInTx, + type AccessDeps, + type FileLibActor, + type InitialGrant, +} from "./treeService.js"; + +export interface GrantDto { + readonly id: string; + readonly principalType: "USER" | "GROUP"; + readonly principalId: string; + readonly role: FileLibRole; + readonly isCreatorGrant: boolean; + readonly createdAt: Date; +} + +function toDto(grant: FileLibGrant): GrantDto { + return { + id: grant.id, + principalType: grant.principalType, + principalId: grant.principalId, + role: grant.role, + isCreatorGrant: grant.isCreatorGrant, + createdAt: grant.createdAt, + }; +} + +type Tx = Prisma.TransactionClient; +type Deps = AccessDeps & { readonly prisma: PrismaClient }; + +/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */ +async function requireManage( + deps: Deps, + actor: FileLibActor, + nodeId: string, + tx?: Tx, +): Promise<{ readonly node: FileLibNode; readonly role: FileLibRole }> { + if (tx !== undefined) return requireAccessInTx(tx, deps, actor, nodeId, "MANAGE"); + return deps.prisma.$transaction(async (inner) => requireAccessInTx(inner, deps, actor, nodeId, "MANAGE")); +} + +/** 列出节点活跃授权(需 MANAGE)。 */ +export async function listGrants( + deps: Deps, + actor: FileLibActor, + nodeId: string, +): Promise { + await requireManage(deps, actor, nodeId); + const grants = await deps.prisma.fileLibGrant.findMany({ + where: { organizationId: deps.organizationId, nodeId, revokedAt: null }, + orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], + }); + return grants.map(toDto); +} + +export interface PutGrantsResult { + readonly granted: number; + readonly updated: number; + readonly grants: readonly GrantDto[]; +} + +/** + * 批量授予/修改(upsert 语义):同 principal 已有活跃授权 → 改级别(permission.update); + * 没有 → 新建(permission.grant)。8.1 矩阵在写之前整体校验。 + */ +export async function putGrants( + deps: Deps, + actor: FileLibActor, + nodeId: string, + items: readonly InitialGrant[], +): Promise { + validateGrantItems(items); + return deps.prisma.$transaction(async (tx) => { + const { node } = await requireManage(deps, actor, nodeId, tx); + const isCreator = node.creatorId === actor.userId; + for (const item of items) { + if (item.role === "MANAGE" && !isCreator) { + throw new FileLibError(403, "only_creator_can_grant_manage", "only the creator can grant MANAGE"); + } + if (item.principalType === "USER" && item.principalId === node.creatorId) { + throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable"); + } + } + + let granted = 0; + let updated = 0; + for (const item of items) { + const existing = await tx.fileLibGrant.findFirst({ + where: { + nodeId: node.id, + principalType: item.principalType, + principalId: item.principalId, + revokedAt: null, + }, + }); + if (existing !== null) { + if (existing.isCreatorGrant) { + throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable"); + } + if (existing.role !== item.role) { + await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } }); + updated += 1; + await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item }); + } + } else { + await tx.fileLibGrant.create({ + data: { + organizationId: deps.organizationId, + nodeId: node.id, + principalType: item.principalType, + principalId: item.principalId, + role: item.role, + createdByUserId: actor.userId, + }, + }); + granted += 1; + await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item }); + } + } + const grants = await tx.fileLibGrant.findMany({ + where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, + orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], + }); + return { granted, updated, grants: grants.map(toDto) }; + }); +} + +/** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */ +export async function revokeGrant( + deps: Deps, + actor: FileLibActor, + nodeId: string, + grantId: string, +): Promise { + await deps.prisma.$transaction(async (tx) => { + const { node } = await requireManage(deps, actor, nodeId, tx); + const grant = await tx.fileLibGrant.findFirst({ + where: { id: grantId, nodeId: node.id, revokedAt: null }, + }); + if (grant === null) throw new FileLibError(404, "grant_not_found", "grant not found"); + if (grant.isCreatorGrant) { + throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable"); + } + if (grant.role === "MANAGE" && node.creatorId !== actor.userId) { + throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE"); + } + await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } }); + await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, { + principalType: grant.principalType, + principalId: grant.principalId, + role: grant.role, + }); + }); +} + +/** + * 网站管理员强制调整(D19):凭 node id 操作,不查操作者节点权限;矩阵豁免; + * 每一条变更都落 admin.force_adjust 审计(高危留痕)。 + */ +export async function forceAdjustGrants( + deps: Deps, + actor: FileLibActor, + nodeId: string, + items: readonly InitialGrant[], +): Promise { + if (!actor.isWebsiteAdmin) { + throw new FileLibError(403, "forbidden", "force adjust requires website administrator"); + } + validateGrantItems(items); + return deps.prisma.$transaction(async (tx) => { + const node = await tx.fileLibNode.findFirst({ + where: { id: nodeId, organizationId: deps.organizationId, deletedAt: null }, + }); + if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); + + let granted = 0; + let updated = 0; + for (const item of items) { + const existing = await tx.fileLibGrant.findFirst({ + where: { + nodeId: node.id, + principalType: item.principalType, + principalId: item.principalId, + revokedAt: null, + }, + }); + if (existing !== null) { + if (existing.role !== item.role) { + await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } }); + updated += 1; + await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, { + change: "update", + principalType: item.principalType, + principalId: item.principalId, + from: existing.role, + to: item.role, + }); + } + } else { + await tx.fileLibGrant.create({ + data: { + organizationId: deps.organizationId, + nodeId: node.id, + principalType: item.principalType, + principalId: item.principalId, + role: item.role, + createdByUserId: actor.userId, + }, + }); + granted += 1; + await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, { + change: "grant", + principalType: item.principalType, + principalId: item.principalId, + role: item.role, + }); + } + } + const grants = await tx.fileLibGrant.findMany({ + where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, + orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], + }); + return { granted, updated, grants: grants.map(toDto) }; + }); +} + +/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */ +export async function setIndependentPermission( + deps: Deps, + actor: FileLibActor, + nodeId: string, + enabled: boolean, +): Promise<{ readonly enabled: boolean }> { + return deps.prisma.$transaction(async (tx) => { + const { node } = await requireManage(deps, actor, nodeId, tx); + if (node.kind !== "PROJECT") { + throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only"); + } + const current = await tx.fileLibProjectSettings.findUnique({ + where: { nodeId: node.id }, + select: { independentPermissionsEnabled: true }, + }); + if ((current?.independentPermissionsEnabled ?? false) === enabled) { + return { enabled }; // 状态未变:空操作,不产生审计 + } + await tx.fileLibProjectSettings.upsert({ + where: { nodeId: node.id }, + update: { independentPermissionsEnabled: enabled }, + create: { nodeId: node.id, independentPermissionsEnabled: enabled }, + }); + await writeFileLibAudit(tx, { + action: enabled + ? FILE_LIB_AUDIT_ACTIONS.independentEnable + : FILE_LIB_AUDIT_ACTIONS.independentDisable, + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: "project", + objectId: node.id, + objectPath: node.pathIds, + detail: { enabled }, + }); + return { enabled }; + }); +} + +function validateGrantItems(items: readonly InitialGrant[]): void { + if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty"); + const seen = new Set(); + for (const item of items) { + if (item.principalType !== "USER" && item.principalType !== "GROUP") { + throw new FileLibError(400, "invalid_request", `bad principalType: ${String(item.principalType)}`); + } + if (item.role !== "VIEW" && item.role !== "EDIT" && item.role !== "MANAGE") { + throw new FileLibError(400, "invalid_request", `bad role: ${String(item.role)}`); + } + if (item.principalId.trim() === "") { + throw new FileLibError(400, "invalid_request", "principalId must not be empty"); + } + const key = `${item.principalType}:${item.principalId}`; + if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate principal: ${key}`); + seen.add(key); + } +} + +async function audit( + tx: Prisma.TransactionClient, + deps: Deps, + actor: FileLibActor, + action: string, + nodeId: string, + pathIds: string, + detail: Record, +): Promise { + await writeFileLibAudit(tx, { + action, + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: "grant", + objectId: nodeId, + objectPath: pathIds, + detail, + }); +} diff --git a/hub/src/database/filelib/groupResolver.ts b/hub/src/database/filelib/groupResolver.ts new file mode 100644 index 0000000..a3aa546 --- /dev/null +++ b/hub/src/database/filelib/groupResolver.ts @@ -0,0 +1,48 @@ +/** + * GroupResolver port(契约 C2)。 + * + * 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。 + * Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此 + * port,真身到位后替换实现,不换调用点。 + */ + +import type { PrismaClient } from "@prisma/client"; + +export interface GroupResolver { + resolveMemberGroupIds(userId: string): Promise; +} + +/** + * 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。 + * 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎 + * 的 Group 通路今天就是真的,而不是 mock。 + */ +export function createTeamGroupResolver( + prisma: PrismaClient, + organizationId: string, +): GroupResolver { + return { + async resolveMemberGroupIds(userId) { + const memberships = await prisma.teamMembership.findMany({ + where: { + userId, + revokedAt: null, + team: { organizationId, archivedAt: null }, + }, + select: { teamId: true }, + }); + return memberships.map((m) => m.teamId); + }, + }; +} + +/** 单测 mock:静态 用户→组 映射。 */ +export function createStaticGroupResolver( + map: Readonly>, +): GroupResolver { + return { + async resolveMemberGroupIds(userId) { + return map[userId] ?? []; + }, + }; +} diff --git a/hub/src/database/filelib/groupResolverHttp.ts b/hub/src/database/filelib/groupResolverHttp.ts new file mode 100644 index 0000000..f4b6a96 --- /dev/null +++ b/hub/src/database/filelib/groupResolverHttp.ts @@ -0,0 +1,49 @@ +/** + * GroupResolver 的 HTTP 实现(契约 C2,Group 团队服务到位后启用, + * 经 HUB_GROUP_SERVICE_URL 配置)。 + * + * 语义红线: + * - 失败 → FileLibError(503, group_unavailable)。依赖故障不是"无权限", + * 绝不伪装成 404/403(计划 D13)。 + * - 我方绝不自己推祖先:返回什么用什么,不在本地补逻辑。 + */ + +import { FileLibError } from "./model.js"; +import type { GroupResolver } from "./groupResolver.js"; + +export interface HttpGroupResolverConfig { + readonly baseUrl: string; + readonly timeoutMs?: number; + /** 测试可注入假 fetch;生产用全局 fetch。 */ + readonly fetchFn?: typeof fetch; +} + +export function createHttpGroupResolver(config: HttpGroupResolverConfig): GroupResolver { + const timeoutMs = config.timeoutMs ?? 2_000; + const fetchFn = config.fetchFn ?? fetch; + return { + async resolveMemberGroupIds(userId) { + const url = `${config.baseUrl.replace(/\/$/, "")}/groups/resolve-member-groups?userId=${encodeURIComponent(userId)}`; + let response: Response; + try { + response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) }); + } catch (error) { + throw new FileLibError(503, "group_unavailable", `group service unreachable: ${String(error)}`); + } + if (!response.ok) { + throw new FileLibError(503, "group_unavailable", `group service returned ${response.status}`); + } + let body: unknown; + try { + body = await response.json(); + } catch { + throw new FileLibError(503, "group_unavailable", "group service returned malformed JSON"); + } + const groupIds = (body as { groupIds?: unknown }).groupIds; + if (!Array.isArray(groupIds) || groupIds.some((id) => typeof id !== "string")) { + throw new FileLibError(503, "group_unavailable", "group service returned malformed payload"); + } + return groupIds as readonly string[]; + }, + }; +} diff --git a/hub/src/database/filelib/guards.ts b/hub/src/database/filelib/guards.ts new file mode 100644 index 0000000..e934302 --- /dev/null +++ b/hub/src/database/filelib/guards.ts @@ -0,0 +1,47 @@ +/** + * 文件库 HTTP 门禁(契约 C4 的入驻适配)。 + * + * 身份链:hub session(飞书 OAuth / dev bypass)→ silo org membership。 + * 网站管理员 = org 的 OWNER/ADMIN(D19:仅 root 创建与 force_adjust 特权, + * 不给内容读旁路);普通成员 = 任何活跃 membership;非成员 = 403。 + */ + +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { OrganizationMemberRole, PrismaClient } from "@prisma/client"; +import { requireSession, sendError } from "../../admin/auth/guards.js"; +import type { FileLibActor } from "./treeService.js"; + +export interface FileLibGuardDeps { + readonly prisma: PrismaClient; + readonly sessionSecret: string; + /** 文件库归属的 silo org(ADR-0020/0025)。 */ + readonly organizationId: string; +} + +const WEBSITE_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"]; + +/** 每个 /database/api/* 端点第一行调它;返回 null 时响应已发出,fail closed。 */ +export async function requireFileLibActor( + request: FastifyRequest, + reply: FastifyReply, + deps: FileLibGuardDeps, +): Promise { + const auth = await requireSession(request, reply, { + prisma: deps.prisma, + sessionSecret: deps.sessionSecret, + }); + if (auth === null) return null; + + const membership = await deps.prisma.organizationMembership.findFirst({ + where: { organizationId: deps.organizationId, userId: auth.user.id, revokedAt: null }, + select: { role: true }, + }); + if (membership === null) { + await sendError(reply, 403, "forbidden", "not a member of this organization"); + return null; + } + return { + userId: auth.user.id, + isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role), + }; +} diff --git a/hub/src/database/filelib/model.ts b/hub/src/database/filelib/model.ts new file mode 100644 index 0000000..4ff5684 --- /dev/null +++ b/hub/src/database/filelib/model.ts @@ -0,0 +1,59 @@ +/** + * 文件库领域基础:角色秩、命名规则(D14)、错误类型。 + * + * 语义锚定:仓库根《文件库-接口契约.md》(2.2 权限等级 / D8 可见性 / D14 命名) + * 与 .omo/文件库-开工计划.md。本模块是独立文件库,不复用 hub 的 + * Folder/Project/PermissionGrant 体系。 + */ + +export const FILE_LIB_ROLES = ["VIEW", "EDIT", "MANAGE"] as const; +export type FileLibRole = (typeof FILE_LIB_ROLES)[number]; + +/** 契约 2.2:MANAGE > EDIT > VIEW,严格全序。 */ +export const ROLE_RANK: Record = { VIEW: 1, EDIT: 2, MANAGE: 3 }; + +export function roleAtLeast(role: FileLibRole, min: FileLibRole): boolean { + return ROLE_RANK[role] >= ROLE_RANK[min]; +} + +/** 业务错误。statusCode 由路由层映射为 HTTP 响应(D8 语义在此层只表达为 code)。 */ +export class FileLibError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string, + /** 结构化附加信息(如 409 时的 currentVersion),路由层并入错误响应。 */ + readonly details?: Record, + ) { + super(message); + this.name = "FileLibError"; + } +} + +export const NODE_NAME_MAX_LENGTH = 128; + +/** D14:禁 `/`;反斜杠同样禁止(它会变成存储路径的分隔符,且易用于伪装)。控制字符禁。 */ +const FORBIDDEN_NAME_CHARS = /[/\\\p{C}]/u; + +/** + * D14:NFC 归一化 + trim,然后校验长度与字符集。 + * 违规抛 FileLibError(400, "invalid_name"),路由层原样透传。 + */ +export function normalizeNodeName(raw: string): string { + const name = raw.normalize("NFC").trim(); + if (name.length === 0) { + throw new FileLibError(400, "invalid_name", "name must not be empty"); + } + if (name.length > NODE_NAME_MAX_LENGTH) { + throw new FileLibError(400, "invalid_name", `name exceeds ${NODE_NAME_MAX_LENGTH} characters`); + } + if (FORBIDDEN_NAME_CHARS.test(name)) { + throw new FileLibError(400, "invalid_name", "name must not contain '/', '\\' or control characters"); + } + return name; +} + +/** D14:活跃兄弟节点大小写不敏感唯一的比较键(DB 层另有部分唯一索引兜底)。 */ +export function nameKey(normalizedName: string): string { + return normalizedName.toLowerCase(); +} diff --git a/hub/src/database/filelib/permission.ts b/hub/src/database/filelib/permission.ts new file mode 100644 index 0000000..c6bdba4 --- /dev/null +++ b/hub/src/database/filelib/permission.ts @@ -0,0 +1,74 @@ +/** + * 纯权限 reducer(契约 P6 / D11 / D8)。 + * + * 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链 + * 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离 + * 存储做密集单测与随机化不变量测试。 + */ + +import type { FileLibRole } from "./model.js"; +import { ROLE_RANK } from "./model.js"; + +export interface FileLibGrantFact { + readonly nodeId: string; + readonly principalType: "USER" | "GROUP"; + readonly principalId: string; + readonly role: FileLibRole; + readonly isCreatorGrant: boolean; +} + +export interface EffectiveRoleInput { + /** 目标节点(self)。 */ + readonly nodeId: string; + readonly nodeKind: "FOLDER" | "PROJECT"; + /** 目标的全部祖先 id(不含 self,顺序无关)。 */ + readonly ancestorIds: readonly string[]; + /** 项目独立权限开关(D11/P5);文件夹忽略此值。 */ + readonly independentPermissionsEnabled: boolean; + readonly userId: string; + /** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */ + readonly groupIds: readonly string[]; + /** self ∪ ancestors 上的全部活跃 grant(revokedAt 已由获取层过滤)。 */ + readonly grants: readonly FileLibGrantFact[]; +} + +/** + * 契约 P6:effective(user, R) = max { grant.role | s ∈ {user} ∪ groups*(user), + * r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。 + * "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。 + * + * D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant + * 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的 + * grant 不受开关影响。 + */ +export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null { + const onChain = new Set([input.nodeId, ...input.ancestorIds]); + const groups = new Set(input.groupIds); + const freezeProjectGrants = + input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled; + + let best: FileLibRole | null = null; + for (const grant of input.grants) { + if (!onChain.has(grant.nodeId)) continue; + if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue; + if (grant.principalType === "USER" && grant.principalId !== input.userId) continue; + if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue; + if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role; + } + return best; +} + +/** + * D8 可见性语义: + * - 完全无权限(effective === null)→ "not_found"(路由层映射 404,不泄露存在性); + * - 有权限但不足 → "forbidden"(路由层映射 403)。 + */ +export type AccessVerdict = + | { readonly allowed: true; readonly role: FileLibRole } + | { readonly allowed: false; readonly reason: "not_found" | "forbidden" }; + +export function checkAccess(effective: FileLibRole | null, min: FileLibRole): AccessVerdict { + if (effective === null) return { allowed: false, reason: "not_found" }; + if (ROLE_RANK[effective] < ROLE_RANK[min]) return { allowed: false, reason: "forbidden" }; + return { allowed: true, role: effective }; +} diff --git a/hub/src/database/filelib/routeShared.ts b/hub/src/database/filelib/routeShared.ts new file mode 100644 index 0000000..a4d23fa --- /dev/null +++ b/hub/src/database/filelib/routeShared.ts @@ -0,0 +1,89 @@ +/** + * /database/api/* 路由共享件:依赖装配、actor 门禁、统一错误映射。 + * 约定(与 admin 面一致):绝对路径;guard 前置 fail closed;查询 scope 到 silo org。 + */ + +import type { FastifyReply, FastifyRequest } from "fastify"; +import { Prisma } from "@prisma/client"; +import type { PrismaClient } from "@prisma/client"; +import { FileLibError } from "./model.js"; +import { requireFileLibActor } from "./guards.js"; +import type { FileLibActor, TreeServiceDeps } from "./treeService.js"; +import type { GroupResolver } from "./groupResolver.js"; +import type { VersionStore } from "./versionStore.js"; +import type { ExportAdapter } from "./exportService.js"; + +export interface FileLibRouteDeps { + readonly prisma: PrismaClient; + readonly sessionSecret: string; + readonly organizationId: string; + readonly storageRoot: string; + readonly groupResolver: GroupResolver; + readonly versionStore: VersionStore; + readonly exportAdapters: readonly ExportAdapter[]; +} + +/** 组装 treeService 依赖(路由处理内直接使用)。 */ +export function treeDeps(deps: FileLibRouteDeps): TreeServiceDeps { + return { + prisma: deps.prisma, + groupResolver: deps.groupResolver, + versionStore: deps.versionStore, + organizationId: deps.organizationId, + storageRoot: deps.storageRoot, + }; +} + +/** 端点第一行调用;null = 响应已发(401/403),fail closed。 */ +export async function actorOrNull( + request: FastifyRequest, + reply: FastifyReply, + deps: FileLibRouteDeps, +): Promise { + return requireFileLibActor(request, reply, { + prisma: deps.prisma, + sessionSecret: deps.sessionSecret, + organizationId: deps.organizationId, + }); +} + +/** 统一错误出口:FileLibError → 语义码;P2002 → 409;其余 → 500(不泄露内部)。 */ +export async function sendRouteError(reply: FastifyReply, error: unknown): Promise { + if (error instanceof FileLibError) { + await reply.status(error.statusCode).send({ + error: { code: error.code, message: error.message, ...(error.details ?? {}) }, + }); + return; + } + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + await reply.status(409).send({ error: { code: "conflict", message: "uniqueness conflict" } }); + return; + } + reply.log.error({ err: error }, "filelib route: unexpected error"); + await reply.status(500).send({ error: { code: "internal", message: "internal error" } }); +} + +/** 请求体轻量校验(抛 FileLibError 400)。 */ +export function bodyObject(body: unknown): Record { + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new FileLibError(400, "invalid_request", "request body must be a JSON object"); + } + return body as Record; +} + +export function requireString(obj: Record, key: string): string { + const value = obj[key]; + if (typeof value !== "string" || value === "") { + throw new FileLibError(400, "invalid_request", `missing or invalid field: ${key}`); + } + return value; +} + +export function optionalString(obj: Record, key: string): string | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new FileLibError(400, "invalid_request", `invalid field: ${key}`); + } + return value; +} diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts new file mode 100644 index 0000000..75a1825 --- /dev/null +++ b/hub/src/database/filelib/treeService.ts @@ -0,0 +1,598 @@ +/** + * 文件库树服务(Phase 1 服务层,Phase 2 路由直接调用)。 + * + * 语义锚定: + * - D8 无权限 → 404 不泄露;越权 → 403(loadChain / requireAccess) + * - D11 creator 不可变 + 自动 MANAGE grant;独立权限开关语义在 permission.ts + * - D12 move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁防并发成环 + * - D14 命名规则(model.ts)+ 活跃兄弟唯一(DB 部分唯一索引兜底) + * - D15 删除只打标本节点;"任一祖先已删"即整支不可见 + * - D17 breadcrumb 无 View 的祖先只给占位,不泄露名字 + * - 树表示:parentId 权威;pathIds 为 id 编码的派生物化路径(name 不入路径, + * rename 不重写后代;move 用一次前缀重写维护) + * + * 网站管理员(D19)= silo org 的 OWNER/ADMIN(契约 C4 的入驻适配):仅 root 创建 + * 与 force_adjust 特权,不隐式穿透内容权限 —— 本文件所有读路径对它同样走 + * effectiveRole,没有 admin 旁路。 + */ + +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { Prisma } from "@prisma/client"; +import type { PrismaClient, FileLibNode } from "@prisma/client"; +import { + FileLibError, + nameKey, + normalizeNodeName, + type FileLibRole, +} from "./model.js"; +import { checkAccess, effectiveRole } from "./permission.js"; +import type { GroupResolver } from "./groupResolver.js"; +import type { VersionStore } from "./versionStore.js"; +import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; + +export interface FileLibActor { + readonly userId: string; + /** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */ + readonly isWebsiteAdmin: boolean; +} + +export interface TreeServiceDeps { + readonly prisma: PrismaClient; + readonly groupResolver: GroupResolver; + readonly versionStore: VersionStore; + /** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */ + readonly organizationId: string; + /** 项目 git 仓库的磁盘根目录;项目仓 = /。 */ + readonly storageRoot: string; +} + +/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */ +export type AccessDeps = Pick; + +export interface InitialGrant { + readonly principalType: "USER" | "GROUP"; + readonly principalId: string; + readonly role: FileLibRole; +} + +type Tx = Prisma.TransactionClient; + +interface Chain { + readonly node: FileLibNode; + /** 根在前、直接父在后;不含 node 自身。 */ + readonly ancestors: readonly FileLibNode[]; +} + +/* ---------------------------------------------------------------- 内部工具 */ + +/** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */ +function ancestorIdsOf(node: FileLibNode): string[] { + return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1); +} + +/** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */ +async function loadVisibleChain( + tx: Tx, + organizationId: string, + nodeId: string, +): Promise { + const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } }); + if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); + const ancestorIds = ancestorIdsOf(node); + const ancestors = ancestorIds.length === 0 + ? [] + : await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } }); + if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) { + // D15:已删子树对外"不存在"(D8 不泄露)。 + throw new FileLibError(404, "node_not_found", "node not found"); + } + const byId = new Map(ancestors.map((a) => [a.id, a])); + const ordered = ancestorIds + .map((id) => byId.get(id)) + .filter((a): a is FileLibNode => a !== undefined); + return { node, ancestors: ordered }; +} + +/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */ +async function resolveRole( + tx: Tx, + deps: AccessDeps, + actor: FileLibActor, + chain: Chain, +): Promise { + const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id]; + const grants = await tx.fileLibGrant.findMany({ + where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, + select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, + }); + let independentPermissionsEnabled = false; + if (chain.node.kind === "PROJECT") { + const settings = await tx.fileLibProjectSettings.findUnique({ + where: { nodeId: chain.node.id }, + select: { independentPermissionsEnabled: true }, + }); + independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false; + } + const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); + return effectiveRole({ + nodeId: chain.node.id, + nodeKind: chain.node.kind, + ancestorIds: chain.ancestors.map((a) => a.id), + independentPermissionsEnabled, + userId: actor.userId, + groupIds, + grants, + }); +} + +/** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */ +async function requireAccess( + tx: Tx, + deps: AccessDeps, + actor: FileLibActor, + nodeId: string, + minRole: FileLibRole, +): Promise { + const chain = await loadVisibleChain(tx, deps.organizationId, nodeId); + const role = await resolveRole(tx, deps, actor, chain); + const verdict = checkAccess(role, minRole); + if (!verdict.allowed) { + throw verdict.reason === "not_found" + ? new FileLibError(404, "node_not_found", "node not found") + : new FileLibError(403, "forbidden", `requires ${minRole}`); + } + return { ...chain, role: verdict.role }; +} + +/** + * 兄弟模块(grantService 等)共用的 tx 内门禁:在调用方自己的事务里做 + * 权限校验,校验与后续写同一根事务绳,避免 check-tx / write-tx 之间的竞态。 + */ +export async function requireAccessInTx( + tx: Tx, + deps: AccessDeps, + actor: FileLibActor, + nodeId: string, + minRole: FileLibRole, +): Promise { + return requireAccess(tx, deps, actor, nodeId, minRole); +} + +/** P2002(活跃兄弟名部分唯一索引)→ 409。 */ +function rethrowNameConflict(error: unknown, name: string): never { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`); + } + throw error; +} + +function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string { + const table = kind === "PROJECT" + ? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete } + : { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete }; + return table[verb]; +} + +function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void { + const seen = new Set(); + for (const grant of grants) { + const key = `${grant.principalType}:${grant.principalId}`; + if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`); + seen.add(key); + if (grant.principalType === "USER" && grant.principalId === actor.userId) { + throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant"); + } + // v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。 + } +} + +/* ---------------------------------------------------------------- 公共操作 */ + +export interface CreateNodeInput { + readonly parentId: string | null; + readonly kind: "FOLDER" | "PROJECT"; + readonly name: string; + readonly description?: string | undefined; + readonly grants?: readonly InitialGrant[] | undefined; +} + +/** + * 创建文件夹/项目。root 创建仅网站管理员(契约 2.1);非 root 需父节点 EDIT+。 + * creator 自动 MANAGE(D11);项目走 provisioning 状态机:PROVISIONING → init → READY。 + */ +export async function createNode( + deps: TreeServiceDeps, + actor: FileLibActor, + input: CreateNodeInput, +): Promise { + const name = normalizeNodeName(input.name); + const initialGrants = input.grants ?? []; + validateInitialGrants(actor, initialGrants); + + const id = randomUUID(); + let pathIds: string; + let storageDir: string | null = null; + + const created = await deps.prisma.$transaction(async (tx) => { + if (input.parentId === null) { + if (!actor.isWebsiteAdmin) { + throw new FileLibError(403, "forbidden", "root creation requires website administrator"); + } + pathIds = `/${id}`; + } else { + const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT"); + if (parent.node.kind !== "FOLDER") { + throw new FileLibError(400, "invalid_parent", "projects cannot have children"); + } + pathIds = `${parent.node.pathIds}/${id}`; + } + + if (input.kind === "PROJECT") { + storageDir = path.join(deps.storageRoot, id); + } + + let node: FileLibNode; + try { + node = await tx.fileLibNode.create({ + data: { + id, + organizationId: deps.organizationId, + parentId: input.parentId, + kind: input.kind, + name, + nameLower: nameKey(name), + pathIds, + creatorId: actor.userId, + provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY", + storageDir, + ...(input.description !== undefined && input.description.trim() !== "" + ? { description: input.description.trim() } + : {}), + }, + }); + } catch (error) { + rethrowNameConflict(error, name); + } + + await tx.fileLibGrant.create({ + data: { + organizationId: deps.organizationId, + nodeId: id, + principalType: "USER", + principalId: actor.userId, + role: "MANAGE", + isCreatorGrant: true, + createdByUserId: actor.userId, + }, + }); + for (const grant of initialGrants) { + await tx.fileLibGrant.create({ + data: { + organizationId: deps.organizationId, + nodeId: id, + principalType: grant.principalType, + principalId: grant.principalId, + role: grant.role, + createdByUserId: actor.userId, + }, + }); + } + if (input.kind === "PROJECT") { + await tx.fileLibProjectSettings.create({ + data: { nodeId: id, independentPermissionsEnabled: false }, + }); + } + + await writeFileLibAudit(tx, { + action: nodeAction(input.kind, "Create"), + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: input.kind === "PROJECT" ? "project" : "folder", + objectId: id, + objectPath: pathIds, + detail: { name, parentId: input.parentId, initialGrants: initialGrants.length }, + }); + for (const grant of initialGrants) { + await writeFileLibAudit(tx, { + action: FILE_LIB_AUDIT_ACTIONS.permissionGrant, + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: "grant", + objectId: id, + objectPath: pathIds, + detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role }, + }); + } + return node; + }); + + // provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。 + if (input.kind === "PROJECT" && storageDir !== null) { + try { + await deps.versionStore.init(storageDir); + return await deps.prisma.fileLibNode.update({ + where: { id: created.id }, + data: { provisionStatus: "READY" }, + }); + } catch (error) { + await deps.prisma.fileLibNode + .update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } }) + .catch(() => undefined); + throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`); + } + } + return created; +} + +/** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */ +export async function renameNode( + deps: TreeServiceDeps, + actor: FileLibActor, + nodeId: string, + rawName: string, +): Promise { + const name = normalizeNodeName(rawName); + return deps.prisma.$transaction(async (tx) => { + const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE"); + let updated: FileLibNode; + try { + updated = await tx.fileLibNode.update({ + where: { id: node.id }, + data: { name, nameLower: nameKey(name) }, + }); + } catch (error) { + rethrowNameConflict(error, name); + } + await writeFileLibAudit(tx, { + action: nodeAction(node.kind, "Rename"), + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: node.kind === "PROJECT" ? "project" : "folder", + objectId: node.id, + objectPath: node.pathIds, + detail: { from: node.name, to: name }, + }); + return updated; + }); +} + +/** + * 移动(D12):本节点 MANAGE + 目标父 EDIT+(移到 root 需网站管理员); + * 事务 + org 级咨询锁防并发成环;后代 pathIds 一次前缀重写。 + */ +export async function moveNode( + deps: TreeServiceDeps, + actor: FileLibActor, + nodeId: string, + newParentId: string | null, +): Promise { + return deps.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`; + + const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE"); + if (node.parentId === newParentId) return node; + + let newPathIds: string; + if (newParentId === null) { + if (!actor.isWebsiteAdmin) { + throw new FileLibError(403, "forbidden", "moving to root requires website administrator"); + } + newPathIds = `/${node.id}`; + } else { + const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT"); + if (parent.node.kind !== "FOLDER") { + throw new FileLibError(400, "invalid_parent", "projects cannot have children"); + } + if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) { + throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree"); + } + newPathIds = `${parent.node.pathIds}/${node.id}`; + } + + const oldPrefix = node.pathIds; + let updated: FileLibNode; + try { + updated = await tx.fileLibNode.update({ + where: { id: node.id }, + data: { parentId: newParentId, pathIds: newPathIds }, + }); + // 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。 + await tx.$executeRaw` + UPDATE "FileLibNode" + SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int) + WHERE "organizationId" = ${deps.organizationId} + AND "pathIds" LIKE ${oldPrefix + "/%"} + `; + } catch (error) { + rethrowNameConflict(error, node.name); + } + await writeFileLibAudit(tx, { + action: nodeAction(node.kind, "Move"), + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: node.kind === "PROJECT" ? "project" : "folder", + objectId: node.id, + objectPath: newPathIds, + detail: { fromParentId: node.parentId, toParentId: newParentId }, + }); + return updated; + }); +} + +/** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */ +export async function softDeleteNode( + deps: TreeServiceDeps, + actor: FileLibActor, + nodeId: string, +): Promise { + await deps.prisma.$transaction(async (tx) => { + const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE"); + await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } }); + await writeFileLibAudit(tx, { + action: nodeAction(node.kind, "Delete"), + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: node.kind === "PROJECT" ? "project" : "folder", + objectId: node.id, + objectPath: node.pathIds, + detail: { name: node.name }, + }); + }); +} + +/** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */ +export async function getEffectiveRole( + deps: TreeServiceDeps, + actor: FileLibActor, + nodeId: string, +): Promise { + return deps.prisma.$transaction(async (tx) => { + const chain = await loadVisibleChain(tx, deps.organizationId, nodeId); + const role = await resolveRole(tx, deps, actor, chain); + if (role === null) throw new FileLibError(404, "node_not_found", "node not found"); + return role; + }); +} + +export interface BreadcrumbEntry { + readonly depth: number; + /** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */ + readonly id: string | null; + readonly name: string | null; + readonly kind: "FOLDER" | "PROJECT"; +} + +/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */ +export async function breadcrumb( + deps: TreeServiceDeps, + actor: FileLibActor, + nodeId: string, +): Promise { + return deps.prisma.$transaction(async (tx) => { + const chain = await loadVisibleChain(tx, deps.organizationId, nodeId); + const selfRole = await resolveRole(tx, deps, actor, chain); + if (checkAccess(selfRole, "VIEW").allowed !== true) { + throw new FileLibError(404, "node_not_found", "node not found"); + } + const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); + const chainNodes = [...chain.ancestors, chain.node]; + const chainIds = chainNodes.map((n) => n.id); + const allGrants = await tx.fileLibGrant.findMany({ + where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, + select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, + }); + const settings = chain.node.kind === "PROJECT" + ? await tx.fileLibProjectSettings.findUnique({ + where: { nodeId: chain.node.id }, + select: { independentPermissionsEnabled: true }, + }) + : null; + + return chainNodes.map((current, depth) => { + const role = effectiveRole({ + nodeId: current.id, + nodeKind: current.kind, + ancestorIds: chainNodes.slice(0, depth).map((n) => n.id), + independentPermissionsEnabled: + current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false, + userId: actor.userId, + groupIds, + grants: allGrants, + }); + const visible = role !== null; + return { + depth, + id: visible ? current.id : null, + name: visible ? current.name : null, + kind: current.kind, + }; + }); + }); +} + +export interface ChildNodeDto { + readonly id: string; + readonly parentId: string | null; + readonly kind: "FOLDER" | "PROJECT"; + readonly name: string; + readonly role: FileLibRole; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +/** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */ +export async function listChildren( + deps: TreeServiceDeps, + actor: FileLibActor, + parentId: string | null, +): Promise { + return deps.prisma.$transaction(async (tx) => { + let parentAncestorIds: string[] = []; + if (parentId !== null) { + const parent = await requireAccess(tx, deps, actor, parentId, "VIEW"); + parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id]; + } + const children = await tx.fileLibNode.findMany({ + where: { organizationId: deps.organizationId, parentId, deletedAt: null }, + orderBy: [{ kind: "asc" }, { nameLower: "asc" }], + }); + if (children.length === 0) return []; + + // D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。 + const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)]; + const allGrants = await tx.fileLibGrant.findMany({ + where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } }, + select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, + }); + const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id); + const settingsRows = projectIds.length === 0 + ? [] + : await tx.fileLibProjectSettings.findMany({ + where: { nodeId: { in: projectIds } }, + select: { nodeId: true, independentPermissionsEnabled: true }, + }); + const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled])); + const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); + + const out: ChildNodeDto[] = []; + for (const child of children) { + const role = effectiveRole({ + nodeId: child.id, + nodeKind: child.kind, + ancestorIds: parentAncestorIds, + independentPermissionsEnabled: toggleByNode.get(child.id) ?? false, + userId: actor.userId, + groupIds, + grants: allGrants, + }); + if (role === null) continue; + out.push({ + id: child.id, + parentId: child.parentId, + kind: child.kind, + name: child.name, + role, + createdAt: child.createdAt, + updatedAt: child.updatedAt, + }); + } + return out; + }); +} + +/** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */ +export async function updateNodeDescription( + deps: TreeServiceDeps, + actor: FileLibActor, + nodeId: string, + description: string | null, +): Promise { + return deps.prisma.$transaction(async (tx) => { + const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT"); + return tx.fileLibNode.update({ + where: { id: node.id }, + data: { description: description?.trim() || null }, + }); + }); +} diff --git a/hub/src/database/filelib/versionStore.ts b/hub/src/database/filelib/versionStore.ts new file mode 100644 index 0000000..08cd844 --- /dev/null +++ b/hub/src/database/filelib/versionStore.ts @@ -0,0 +1,280 @@ +/** + * VersionStore port(契约 C1)+ 开发用内存实现。 + * + * 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。 + * 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、 + * init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。 + * + * 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期 + * demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。 + */ + +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { FileLibError } from "./model.js"; + +export type VersionId = string; + +export interface CommitRequest { + /** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */ + readonly baseVersion: VersionId | null; + readonly content: string | Buffer; + readonly message?: string | undefined; + readonly author?: string | undefined; +} + +export type CommitResult = + | { readonly status: "ok"; readonly version: VersionId } + | { readonly status: "conflict"; readonly currentVersion: VersionId }; + +export interface VersionInfo { + readonly version: VersionId; + readonly message: string; + readonly author: string | undefined; + readonly committedAt: string; +} + +export interface FileEntry { + readonly path: string; + readonly size: number; +} + +export interface VersionStore { + init(projectDir: string): Promise; + list(projectDir: string, prefix?: string): Promise; + head(projectDir: string, filePath: string): Promise; + read(projectDir: string, filePath: string, at?: VersionId): Promise; + commit(projectDir: string, filePath: string, req: CommitRequest): Promise; + remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise; + diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise; + history(projectDir: string, filePath: string, limit?: number): Promise; +} + +interface StoredVersion { + readonly version: VersionId; + readonly content: Buffer; + readonly message: string; + readonly author: string | undefined; + readonly committedAt: string; + readonly deleted: boolean; +} + +interface Repo { + /** 每文件一条版本链(D16:版本是文件级的,互不干扰)。 */ + readonly files: Map; + counter: number; +} + +/** S4:同 projectDir 的写操作经 per-repo promise 链串行化。 */ +function createKeySerializer(): (key: string, fn: () => Promise) => Promise { + const tails = new Map>(); + return (key: string, fn: () => Promise): Promise => { + const prev = tails.get(key) ?? Promise.resolve(); + const next = prev.then(fn, fn); + tails.set(key, next.catch(() => undefined)); + return next; + }; +} + +function toBuffer(content: string | Buffer): Buffer { + return typeof content === "string" ? Buffer.from(content, "utf8") : content; +} + +/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */ +function naiveDiff(fromText: string, toText: string): string { + const a = fromText.split("\n"); + const b = toText.split("\n"); + const out: string[] = ["--- a", "+++ b"]; + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i += 1) { + const al = a[i]; + const bl = b[i]; + if (al === bl) { + if (al !== undefined) out.push(` ${al}`); + } else { + if (al !== undefined) out.push(`-${al}`); + if (bl !== undefined) out.push(`+${bl}`); + } + } + return out.join("\n"); +} + +export function createInMemoryVersionStore(persistPath?: string): VersionStore { + const repos = new Map(); + const serialize = createKeySerializer(); + + /* ---------------- 开发期快照持久化(语义不变,仅防重启丢失) ---------------- */ + interface PersistedVersion extends Omit { + content: string; // base64 + } + function loadPersisted(): void { + if (persistPath === undefined) return; + try { + const raw = JSON.parse(readFileSync(persistPath, "utf8")) as { + repos: Record }>; + }; + for (const [dir, repo] of Object.entries(raw.repos)) { + const files = new Map(); + for (const [filePath, versions] of Object.entries(repo.files)) { + files.set(filePath, versions.map((v) => ({ ...v, content: Buffer.from(v.content, "base64") }))); + } + repos.set(dir, { files, counter: repo.counter }); + } + } catch { /* 无快照或损坏 → 空库起步(开发语义) */ } + } + function savePersisted(): void { + if (persistPath === undefined) return; + const out: Record }> = {}; + for (const [dir, repo] of repos) { + const files: Record = {}; + for (const [filePath, versions] of repo.files) { + files[filePath] = versions.map((v) => ({ ...v, content: v.content.toString("base64") })); + } + out[dir] = { counter: repo.counter, files }; + } + try { + mkdirSync(path.dirname(persistPath), { recursive: true }); + const tmp = `${persistPath}.tmp`; + writeFileSync(tmp, JSON.stringify({ repos: out }), "utf8"); + renameSync(tmp, persistPath); + } catch { /* 快照失败不影响开发使用 */ } + } + loadPersisted(); + + function requireRepo(projectDir: string): Repo { + const repo = repos.get(projectDir); + if (repo === undefined) { + throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`); + } + return repo; + } + + function liveVersion(repo: Repo, filePath: string): StoredVersion { + const chain = repo.files.get(filePath); + const latest = chain?.[chain.length - 1]; + if (chain === undefined || latest === undefined || latest.deleted) { + throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`); + } + return latest; + } + + function findVersion(repo: Repo, filePath: string, version: VersionId): StoredVersion { + const found = repo.files.get(filePath)?.find((v) => v.version === version); + if (found === undefined) { + throw new FileLibError(404, "version_not_found", `version not found: ${filePath}@${version}`); + } + return found; + } + + return { + async init(projectDir) { + await serialize(projectDir, async () => { + // S7:幂等,重复调用不报错、不重建。 + const created = repos.get(projectDir) === undefined; + repos.set(projectDir, repos.get(projectDir) ?? { files: new Map(), counter: 0 }); + if (created) savePersisted(); + }); + }, + + async list(projectDir, prefix) { + const repo = requireRepo(projectDir); + const out: FileEntry[] = []; + for (const [path, chain] of repo.files) { + const latest = chain[chain.length - 1]; + if (latest === undefined || latest.deleted) continue; + if (prefix !== undefined && !path.startsWith(prefix)) continue; + out.push({ path, size: latest.content.byteLength }); + } + return out.sort((a, b) => a.path.localeCompare(b.path)); + }, + + async head(projectDir, filePath) { + return liveVersion(requireRepo(projectDir), filePath).version; + }, + + async read(projectDir, filePath, at) { + const repo = requireRepo(projectDir); + if (at !== undefined) return findVersion(repo, filePath, at).content; + return liveVersion(repo, filePath).content; + }, + + async commit(projectDir, filePath, req) { + return serialize(projectDir, async (): Promise => { + const repo = requireRepo(projectDir); + const chain = repo.files.get(filePath) ?? []; + const latest = chain[chain.length - 1]; + const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null; + + // S2:新建(baseVersion null)要求文件当前不存在;否则要求 baseVersion 精确等于当前版本(S1)。 + if (req.baseVersion === null) { + if (currentVersion !== null) return { status: "conflict", currentVersion }; + } else if (req.baseVersion !== currentVersion) { + return { + status: "conflict", + currentVersion: currentVersion ?? req.baseVersion, + }; + } + + repo.counter += 1; + const version = `v${repo.counter}`; + chain.push({ + version, + content: toBuffer(req.content), + message: req.message ?? `commit ${version}`, + author: req.author, + committedAt: new Date().toISOString(), + deleted: false, + }); + repo.files.set(filePath, chain); + savePersisted(); + return { status: "ok", version }; + }); + }, + + async remove(projectDir, filePath, baseVersion) { + return serialize(projectDir, async (): Promise => { + const repo = requireRepo(projectDir); + const chain = repo.files.get(filePath) ?? []; + const latest = chain[chain.length - 1]; + const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null; + if (currentVersion === null) { + throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`); + } + if (baseVersion !== currentVersion) return { status: "conflict", currentVersion }; + repo.counter += 1; + const version = `v${repo.counter}`; + chain.push({ + version, + content: Buffer.alloc(0), + message: `remove ${filePath}`, + author: undefined, + committedAt: new Date().toISOString(), + deleted: true, + }); + repo.files.set(filePath, chain); + savePersisted(); + return { status: "ok", version }; + }); + }, + + async diff(projectDir, filePath, from, to) { + const repo = requireRepo(projectDir); + const a = findVersion(repo, filePath, from); + const b = findVersion(repo, filePath, to); + return naiveDiff(a.content.toString("utf8"), b.content.toString("utf8")); + }, + + async history(projectDir, filePath, limit) { + const repo = requireRepo(projectDir); + const chain = repo.files.get(filePath) ?? []; + const infos: VersionInfo[] = chain.map((v) => ({ + version: v.version, + message: v.message, + author: v.author, + committedAt: v.committedAt, + })); + const ordered = infos.reverse(); + return limit !== undefined ? ordered.slice(0, limit) : ordered; + }, + }; +} diff --git a/hub/src/database/routes/adminPanels.ts b/hub/src/database/routes/adminPanels.ts new file mode 100644 index 0000000..6a54981 --- /dev/null +++ b/hub/src/database/routes/adminPanels.ts @@ -0,0 +1,227 @@ +/** + * 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。 + * + * 用户管理 = org 成员(/api/org/:orgSlug/members); + * Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams), + * 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。 + */ + +function apiBase(orgSlug: string): string { + return `/api/org/${encodeURIComponent(orgSlug)}`; +} + +/* ---------------------------------------------------------------- 用户管理 */ + +export function renderUsersPanel(orgSlug: string): string { + return ` +
+
+
添加成员
+
+ + + + +
+
+
+
成员列表
+ + + +
成员userId角色
+
+
+`; +} + +/* ---------------------------------------------------------------- Group 管理 */ + +export function renderGroupsPanel(orgSlug: string): string { + return ` +
+
+
+
新建 Group
+
+
+
+
+
+
+
Group 列表
+
+
+
+
+
从左侧选择一个 Group 查看成员
+
+
+`; +} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index 5f1ef6f..dbb1c31 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -15,7 +15,20 @@ */ import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; +import path from "node:path"; import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js"; +import { registerFileLibRoutes } from "./filelibRoutes.js"; +import { registerFileRoutes } from "./fileRoutes.js"; +import { registerTeacherApp } from "./teacherApp.js"; +import { renderLibraryBrowser } from "./libraryBrowser.js"; +import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js"; +import { createInMemoryVersionStore } from "../filelib/versionStore.js"; +import { createTeamGroupResolver } from "../filelib/groupResolver.js"; +import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js"; +import { createManifestStubAdapter } from "../filelib/exportService.js"; +import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js"; +import { UI_HEAD_FONTS, UI_THEME_CSS } from "./uiTheme.js"; +import type { FileLibRouteDeps } from "../filelib/routeShared.js"; export interface DatabaseRouteConfig { readonly prisma: PrismaClient; @@ -31,6 +44,9 @@ export async function registerDatabaseRoutes( app: FastifyInstance, config: DatabaseRouteConfig, ): Promise { + // 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。 + let filelibDepsForStats: FileLibRouteDeps | null = null; + app.get("/database/admin", async (request, reply) => { // Already signed in → straight to the dashboard. if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) { @@ -42,7 +58,8 @@ export async function registerDatabaseRoutes( app.get("/database/dashboard", async (request, reply) => { const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config); if (user === null) return reply.redirect("/database/admin"); - return reply.type("text/html").send(renderDashboard(user.displayName)); + const stats = await loadDashboardStats(config.prisma, filelibDepsForStats); + return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug)); }); // DEV ONLY bypass — self-contained here, registered only when the flag is on @@ -96,9 +113,116 @@ export async function registerDatabaseRoutes( }); } - // Add more /database/* routes here. Guard data routes with requireSession / - // requireOrgRole (../../admin/auth/guards.js) and scope every query to the - // caller's org (ADR-0020). Access the DB via config.prisma. + // 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。 + // 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换); + // GroupResolver 默认读 hub Team,HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); + // 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。 + const siloOrg = await config.prisma.organization.findUnique({ + where: { slug: config.siloOrganizationSlug }, + select: { id: true }, + }); + if (siloOrg === null) { + app.log.warn({ slug: config.siloOrganizationSlug }, "filelib: silo organization not found, routes not registered"); + return; + } + const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos"); + const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json")); + const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"]; + const filelibDeps: FileLibRouteDeps = { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + organizationId: siloOrg.id, + storageRoot, + groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === "" + ? createTeamGroupResolver(config.prisma, siloOrg.id) + : createHttpGroupResolver({ baseUrl: groupServiceUrl }), + versionStore, + exportAdapters: [createManifestStubAdapter(versionStore)], + }; + await registerFileLibRoutes(app, filelibDeps); + await registerFileRoutes(app, filelibDeps); + await registerTeacherApp(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + siloOrganizationSlug: config.siloOrganizationSlug, + allowDevLoginBypass: config.allowDevLoginBypass, + }); + + // 独立文件库页已并入后台「文件库」tab(/database/dashboard#library),旧地址跳转保留兼容。 + app.get("/database/library", async (_request, reply) => + reply.redirect("/database/dashboard#library"), + ); + + filelibDepsForStats = filelibDeps; +} + +/* ------------------------------------------------------------ 概览页统计 */ + +interface DashboardStats { + readonly folders: number; + readonly projects: number; + readonly files: number; + readonly grants: number; + readonly recent: ReadonlyArray<{ + readonly action: string; + readonly actor: string; + readonly label: string; + readonly when: Date; + }>; +} + +/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */ +async function loadDashboardStats( + prisma: PrismaClient, + deps: FileLibRouteDeps | null, +): Promise { + if (deps === null) return null; + const organizationId = deps.organizationId; + const [folders, projects, grants] = await Promise.all([ + prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }), + prisma.fileLibNode.count({ where: { organizationId, kind: "PROJECT", deletedAt: null } }), + prisma.fileLibGrant.count({ where: { organizationId, revokedAt: null } }), + ]); + // 文件计数:遍历 READY 项目问版本库(demo 规模;真版本包到位后应换成存储侧统计) + const readyProjects = await prisma.fileLibNode.findMany({ + where: { + organizationId, kind: "PROJECT", deletedAt: null, + provisionStatus: "READY", storageDir: { not: null }, + }, + select: { storageDir: true }, + }); + let files = 0; + for (const project of readyProjects) { + if (project.storageDir === null) continue; + try { + files += (await deps.versionStore.list(project.storageDir)).length; + } catch { /* repo 缺失(如重启未恢复)不计 */ } + } + const entries = await prisma.auditEntry.findMany({ + where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } }, + orderBy: { createdAt: "desc" }, + take: 8, + }); + const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))]; + const users = actorIds.length === 0 + ? [] + : await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } }); + const nameById = new Map(users.map((u) => [u.id, u.displayName])); + const recent = entries.map((entry) => { + const meta = (entry.metadata ?? {}) as Record; + const label = + (typeof meta["name"] === "string" ? meta["name"] : undefined) ?? + (typeof meta["to"] === "string" ? meta["to"] : undefined) ?? + (typeof meta["path"] === "string" ? meta["path"] : undefined) ?? + (typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : ""); + return { + action: entry.action, + actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown", + label, + when: entry.createdAt, + }; + }); + return { folders, projects, files, grants, recent }; } /** Verify the session cookie and load the user, or null if not signed in. */ @@ -116,183 +240,170 @@ async function resolveUser( return user; } -/** - * Shared document head: Tailwind CDN + a small design system (fonts, keyframes - * for the aurora background, fade-in, shimmer). Both pages import it so the - * look stays consistent. - */ +/** 管理后台共享 head(全局 UI 主题,与老师端 /app 同源)。 */ function pageHead(title: string): string { return ` ${title} - - - - + ${UI_HEAD_FONTS} + `; } -/** The animated aurora background blobs, shared by both pages (soft pastels on light). */ -const AURORA = ` -
-
-
-
-
`; - function renderLoginPage(config: DatabaseRouteConfig): string { const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`; const devButton = config.allowDevLoginBypass - ? ` -
-
-
开发模式
-
- - 一键登录管理员 - -

仅开发环境可见 · 跳过飞书 OAuth

` + ? `
+ 开发模式 + +
+ ⚡ 一键登录管理员 +

仅开发环境可见 · 跳过飞书 OAuth

` : ""; return ` ${pageHead("Database Admin · 登录")} - - ${AURORA} -
-
-
D
-

Database Admin

-

使用飞书登录以管理数据库

+ +
+
+
Database Admin
+

使用飞书登录以管理数据库

+ 使用飞书登录 + ${devButton}
- - - - 使用飞书登录 - - ${devButton} -
+ `; } /** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */ -const NAV_ITEMS: ReadonlyArray<{ label: string; icon: string; href: string; active: boolean }> = [ - { label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z", href: "/database/dashboard", active: true }, - { label: "数据表", icon: "M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z", href: "#", active: false }, - { label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", href: "#", active: false }, - { label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z", href: "#", active: false }, +const NAV_TABS: ReadonlyArray<{ id: string; label: string; icon: string }> = [ + { id: "overview", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" }, + { id: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" }, + { id: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" }, + { id: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" }, + { id: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" }, + { id: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" }, ]; -function renderDashboard(displayName: string): string { - const nav = NAV_ITEMS.map((item) => { - const cls = item.active - ? "group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50" - : "group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"; - return ` - - ${item.label} - `; - }).join("\n "); +function renderDashboard(displayName: string, stats: DashboardStats | null, orgSlug: string): string { + const nav = NAV_TABS.map((t) => ` + `).join("\n"); - const stats = [ - { label: "数据表", value: "—", accent: "from-violet-200/60 to-transparent" }, - { label: "记录数", value: "—", accent: "from-cyan-200/60 to-transparent" }, - { label: "最近查询", value: "—", accent: "from-indigo-200/60 to-transparent" }, - ].map((s, i) => ` -
-
-

${s.label}

-

${s.value}

+ const cards = [ + { label: "文件夹", value: stats?.folders ?? "—" }, + { label: "项目", value: stats?.projects ?? "—" }, + { label: "文件", value: stats?.files ?? "—" }, + { label: "活跃授权", value: stats?.grants ?? "—" }, + ].map((s) => ` +
+

${s.label}

+

${s.value}

`).join(""); + const recentRows = stats === null || stats.recent.length === 0 + ? `
暂无文件库活动 · 到「文件库」里创建第一个文件夹吧
` + : stats.recent.map((r) => ` +
+ ${escapeHtml(r.action)} + ${escapeHtml(r.label)} + ${escapeHtml(r.actor)} · ${escapeHtml(r.when.toLocaleString("zh-CN"))} +
`).join(""); + const initial = escapeHtml(displayName.slice(0, 1) || "U"); return ` -${pageHead("Database Admin · 概览")} - - ${AURORA} -
- -