Compare commits

...

13 Commits

Author SHA1 Message Date
87bbf0bd57 feat(filelib-web): 刷新页面时保持树展开、选中节点、tab 与文件夹位置 2026-07-27 21:27:09 +08:00
8eda04f1d9 fix(filelib-web): 文件内容无变化时不提交 commit 2026-07-27 21:21:15 +08:00
1dab83f9db feat(filelib): 项目新增修改历史 tab 并隐藏 version hash id 2026-07-27 21:19:59 +08:00
8c26c42e0c feat(filelib-web): 编辑器增加 Typst 语法高亮支持 2026-07-27 21:02:02 +08:00
fe38d0b8a9 feat(filelib-web): 文件编辑改为模态框并集成 CodeMirror 语法高亮 2026-07-27 20:53:16 +08:00
e194670d74 feat(filelib-web): 文件面板支持资源管理器式文件夹导航与图标视图切换 2026-07-27 20:48:23 +08:00
3b544d99f4 feat(filelib): 导出改为下载 cph 编译的真实 PDF 2026-07-27 20:25:29 +08:00
ce5fbfb9a6 fix(filelib): 上传上限抬到 50MiB,并把它与 body limit 的串联写清
7.8MB 文件上传报 413:那是 Fastify 在 body 解析阶段拒的,不是
HUB_FILELIB_MAX_FILE_BYTES。上传把内容放在 JSON body 里、二进制过 base64
体积涨 4/3,所以有效上限是 min(该值, HUB_HTTP_BODY_LIMIT_BYTES × 3/4)。
原先 body limit 是 1MiB,10MiB 的文件上限根本不可达。

.env.example:body limit 1MiB → 70MiB,新增 HUB_FILELIB_MAX_FILE_BYTES=50MiB。
注意 body limit 同时是 ADR-0022 requestBodySize 维度的平台 ceiling,抬高它
对所有端点生效。

resolveMaxFileBytes 拆成 parseMaxFileBytes(纯解析)+ resolveMaxFileBytes(读
env):原先带默认参数,显式传 undefined 会回落到读 env,"没传值"与"读环境变量"
分不开,vitest 加载 .env 后测试会读到真实配置。
2026-07-27 16:14:50 +08:00
4849a765da fix(filelib): 授权表与侧栏展示 displayName 而非裸 userId
GrantDto 加 principalName:USER → User.displayName,GROUP → MemberGroup.name,
取不到行(用户/组已删)时回落为 principalId,与 /database/api/me 同一回落语义。
解析走批量 helper(两条 IN 查询,非 N+1),listGrants/putGrants/forceAdjustGrants
三个出口共用,保证 GET 与 PUT 响应同形状。组不按 archivedAt 过滤 —— 已归档组的
历史授权仍需显示名字,否则管理员无法辨认后收回。

principalName 是纯展示字段;写路径仍只认 principalId,不得据此做授权判断。

前端:
- GrantsPanel 主体列由裸 id 改为展示名,id 移入 title 供排查;收回确认框同步。
- LibraryView 侧栏身份区改用 $me.displayName(/me 早已返回,此前未消费)。
- types.ts 去掉重复声明的 Grant 与无引用的 GroupSearchResult。

集成测试断言三种情形(displayName / 组名 / 已删主体回落)。
2026-07-27 15:56:07 +08:00
82241afb56 feat(filelib)!: VersionStore 改为真 git,一项目一仓库
内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。

git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。

同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
  /database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
  message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)

BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
2026-07-27 15:55:01 +08:00
a306c58db2 fix(filelib-web): 退出登录后管理后台真正跳回登录页
管理后台外壳把「未登录跳 /database/admin」写在 onMount 里,只判断一次。
但 logout() 只清前端 store(它与老师端 /app 共用,那边 me=null 是终态、
不跳转),退出后这层壳重新渲染进 me===null 分支,onMount 不会再跑,
于是永远停在「跳转到登录页…」。

把这道权限门移到 $effect,对任何一次「变成未登录」都生效。条件里的
$authChecked 是必要的 —— 否则首屏 session 请求未回时 me 仍是初始 null,
会把已登录用户直接弹去登录页。

顺带修好「无权访问管理后台」分支里同一个死胡同的退出按钮。
/auth/logout 的 204 本身没有问题。
2026-07-27 14:24:11 +08:00
50ddf32cc2 fix(auth): POST /auth/logout 接受任意 Content-Type
该端点不读 body,但调用方(curl -d、Postman、部分 HTTP 客户端)常给空 POST
自动带上 Content-Type。Fastify 默认只有 JSON parser,遇到别的媒体类型在解析
阶段就以 415 FST_ERR_CTP_INVALID_MEDIA_TYPE 拒掉,进不到 handler。

修法是给它一个丢弃 body 的 catch-all parser,**封装在自己的 register 作用域
内**。不能加到外层实例上:admin plugin 没有 fastify-plugin 封装,那样会让全站
每个 POST/PUT/PATCH 都接受 form-urlencoded。而 form-urlencoded 是跨站 HTML
form 唯一能发出的媒体类型(application/json 会触发 CORS preflight),"只认
JSON"本身是一层 CSRF 纵深防御 —— 当前 sameSite=lax 还挡着,但不该为这个端点
全局放掉。

两处细节:
- "*" 只兜没有专属 parser 的媒体类型。内建 JSON parser 优先级更高,空 body 会
  被它判成 FST_ERR_CTP_EMPTY_JSON_BODY(400),故在本作用域内一并覆盖。
- 用 parseAs:"string" 让 Fastify 读完流(否则连接不释放),而非手写
  payload.resume()。

前端未改 —— 原本不带 Content-Type 的发法一直是 204,是正确的。

测试 5 个 case,最后一个是护栏:断言作用域外的 POST 路由发 form-encoded 仍为
415,防止以后有人把 parser 提到外层。
2026-07-26 22:39:46 +08:00
e6e23294a2 Merge branch 'feat/member-group-hierarchy'
MemberGroup 全局嵌套层级(ADR-0028)与 /database 前后端分离(ADR-0029)。
2026-07-26 20:43:59 +08:00
35 changed files with 2629 additions and 214 deletions
@@ -0,0 +1,157 @@
# ADR 0030: The File Library VersionStore Is a Real Git Repository per Project
## Status
Accepted.
## Context
The file library (`hub/src/database/filelib/`, an independent subsystem that does
not reuse the Hub's own `Folder`/`Project` tree from ADR-0021) stores each project
as a versioned file tree behind the `VersionStore` port (contract C1). Until now
the only implementation was `createInMemoryVersionStore`: a `Map` of per-file
version chains, with `VersionId` as a per-repository monotonic counter
(`v1`, `v2`, …), a hand-written line differ, and an optional JSON snapshot of the
entire storage root written to `<storageRoot>/.version-store.json` so that a
process restart did not lose the demo data.
Two things about the surrounding design were already settled in code and are
confirmed here rather than changed:
- **A `FOLDER` node has no on-disk existence.** `FileLibNode.storageDir` is
`NULL` for folders. The tree is `parentId` plus the `pathIds` materialized path;
nothing in the filesystem mirrors it.
- **Projects are flat under one root, keyed by id.** `storageDir` is
`<storageRoot>/<nodeId>` where `nodeId` is a `randomUUID()`. Names never enter
the path, which is why `renameNode` touches no disk state and does not rewrite
descendant paths.
What was never true is the part the names implied. `HUB_FILELIB_STORAGE_ROOT` was
documented as "the project git repository root" and `fileService` was documented
as observing a "git first, then audit" ordering, but no code in the repository
ever invoked git. `versionStore.init(storageDir)` inserted a `Map` entry; the
directory was never created. Every project's entire content and history lived in
one process-global JSON file. The header comment and `README.md` both marked this
as a placeholder awaiting an npm package from the versioning team.
That package has not arrived, and the in-memory store's properties are not
acceptable for real teacher data: a corrupt or lost `.version-store.json` loses
every project at once, the whole storage root is rewritten on every commit, and
`VersionId` values are meaningless outside the process that minted them.
## Decision
**Each file library project is a real Git repository at
`<storageRoot>/<nodeId>`.** `VersionStore.init` creates the directory and runs
`git init` there. This is the production implementation;
`createInMemoryVersionStore` is retained for tests only.
**`VersionId` is a Git commit hash.** The full 40-hex object name, as printed by
`git rev-parse`. It is no longer a per-repository counter.
**File-level versioning (D16) maps onto commit history as follows.** A write
touches exactly one path and produces exactly one commit. The version of a file is
the hash of the most recent commit that modified that path — `git log -1 --
<path>`. Consequently:
- Two files in one project have independent versions, because a commit that
touches `a.md` does not appear in `git log -- b.md`. This preserves the D16
property that advancing one file does not invalidate another file's
`baseVersion`, even though commits are repository-global objects.
- `baseVersion` checking (S1/S2) compares the caller's id against the current
per-file version. `baseVersion: null` means create, and conflicts if the path
already exists at `HEAD`.
- Reading version `V` of a path means `git show V:<path>`, which is the content as
of that commit, not the content the commit introduced to some other file.
**Deletion is a commit, not a tombstone record.** `remove` runs `git rm` and
commits, so the path is absent from `HEAD` and `list` stops reporting it, while
`git show <olderVersion>:<path>` still resolves. The in-memory store expressed
this as a `deleted: true` chain entry; the observable API semantics are the same.
**Git is invoked as a subprocess, not through a library.** `node:child_process`
`execFile` with an argument array, no new npm dependency. Every invocation is
hardened, and the hardening is load-bearing rather than incidental:
- `-c core.hooksPath=` and `-c commit.gpgsign=false`, plus
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`. A project
repository is *data*, uploaded by teachers. Without this, a committed
`.git/hooks/` entry or a developer's global `gitconfig` would execute or alter
server-side behavior.
- `GIT_LITERAL_PATHSPECS=1` and `--` before every path, so a filename is never
reinterpreted as an option or as pathspec magic (`:(glob)`).
- `GIT_TERMINAL_PROMPT=0`, so a repository never blocks a request waiting on
credentials.
- Author identity is passed per-commit via `GIT_AUTHOR_*`/`GIT_COMMITTER_*`
environment variables, never written into the repository's config. The git
author name is the acting user's `displayName` (falling back to `userId` when
absent), and the email is `<userId>@filelib.paradigm-edu.net`. The email
deliberately keys on `userId` rather than the display name, because nicknames
change and identity attribution must not drift with them. Characters that would
break git's ident line (`<`, `>`, newlines) are stripped from the name.
- `--git-dir=<projectDir>/.git` and `--work-tree=<projectDir>` are pinned on
every invocation, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`/
`GIT_OBJECT_DIRECTORY` are removed from the child environment. Git otherwise
searches *upward* for a `.git`, and the storage root is frequently nested inside
another repository — the local development default `hub/.filelib-repos` sits
inside this very repo. Without pinning, operations on a project directory that
has no repository of its own silently retarget the enclosing repository.
Existence is therefore tested on the filesystem (`<projectDir>/.git`), not with
`git rev-parse --git-dir`, which merely echoes a pinned value back.
**Writes to one repository remain serialized in-process**, as under S4, because
concurrent git invocations contend on `index.lock`. This is a single-process
guarantee only; see Consequences.
## Consequences
- `.version-store.json` is not read or migrated by the new store. Existing
development data under `HUB_FILELIB_STORAGE_ROOT` does not appear in the git
store; those projects report `repo_not_found` until recreated. No production
data exists to migrate, since the in-memory store was never production-viable.
- `VersionId` changes shape in API responses (`GET .../files/*`, history, and the
409 `currentVersion` detail). Clients must keep treating it as an opaque
string; `filelib-web` already does.
- `VersionInfo.author` now comes back as the git author name, which is the acting
user's display name at commit time (or the `userId` when no display name is
known). Commits written without an author carry a fixed `filelib` identity
rather than `undefined`. Display names are point-in-time: renaming a user does
not rewrite existing commits, and the stable identifier stays in the email.
- `VersionStore.commit`/`remove` take a structured `CommitAuthor`
(`{ userId, displayName? }`) rather than a bare author string, so the port can
express both the stable key and the display label. Deletion carries the same
identity as any other commit.
- Serialization is per-process. Two Hub processes sharing a storage root can race
on the same repository and surface a git lock error rather than a clean
conflict. The alpha Silo deployment (ADR-0025) is one process per organization,
so this is not currently reachable; a multi-process deployment needs either a
database advisory lock keyed by project id or a single writer.
- `git` must be present on the host. Absence is a startup-visible failure of
project creation (`provision_failed`), not a silent degradation.
- Repository content is now attacker-influenced data on disk. The path validation
in `fileService.validateFilePath` (rejecting `..`, `.git`, absolute paths,
control characters) moves from hygiene to a security boundary, and
`versionStore` re-checks it rather than trusting callers.
## Alternatives considered
- **`isomorphic-git` or `simple-git`.** Both add a dependency to carry work that
three `execFile` calls do. `isomorphic-git` additionally reimplements the object
layer, so its bugs would be ours to diagnose.
- **One commit per repository state, with the repository head as the version.**
Simpler mapping, but it breaks D16: any write would invalidate every other
file's `baseVersion`, turning independent edits into false conflicts.
- **Keeping the counter as `VersionId` alongside git.** Requires a durable
counter-to-hash mapping outside git, which is the state the decision removes.
- **Bare repositories with a git index-only write path.** Avoids a working tree,
but every read and write becomes plumbing (`hash-object`, `update-index`,
`commit-tree`), for no benefit at this scale.
## Deferred
- Cross-process write serialization (advisory lock keyed by project id).
- Garbage collection and pack maintenance policy for long-lived repositories.
- Whether export builds (`exportService`) should read a git tree directly instead
of going through the `listFiles`/`readFile` port.
- Recovering `provisionStatus=FAILED` projects by re-running `init`; the status
machine records the failure but nothing retries it yet.
+17 -1
View File
@@ -23,12 +23,18 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# HUB_AGENT_MAX_TURNS=25 # HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS="1" HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="900" HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_HTTP_BODY_LIMIT_BYTES="1048576" HUB_HTTP_BODY_LIMIT_BYTES="73400320"
HUB_MAX_FILES_PER_MESSAGE="8" HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILE_BYTES="26214400" HUB_MAX_FILE_BYTES="26214400"
HUB_HTTP_REQUESTS_PER_MINUTE="120" HUB_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120" HUB_FEISHU_EVENTS_PER_MINUTE="120"
# 文件库单文件上限(缺省 10 MiB)。这两个值是串联的:上传把文件内容放在
# JSON body 里,二进制过 base64 体积涨 4/3。所以有效上限是
# min(本值, HUB_HTTP_BODY_LIMIT_BYTES × 3/4);body limit 太小时本值不可达,
# 且报错是 Fastify 的 413 Payload Too Large 而不是 file_too_large。
HUB_FILELIB_MAX_FILE_BYTES="52428800"
# Persistent system-managed root for project workspaces. Production must use an # Persistent system-managed root for project workspaces. Production must use an
# absolute path outside the deployment/release tree; install_service.sh defaults # absolute path outside the deployment/release tree; install_service.sh defaults
# to this path and rejects any overlap before installing the unit. # to this path and rejects any overlap before installing the unit.
@@ -45,8 +51,18 @@ HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires # Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# the file to be executable and `cph --version` to succeed. # the file to be executable and `cph --version` to succeed.
#
# Always set this explicitly. `cph` is also the command name of the unrelated
# PyPI package conda-package-handling, so on any host with miniconda on PATH a
# bare `cph` resolves to the wrong tool and exports fail with an argparse
# "invalid choice: 'build'" that gives no hint about the name collision.
CPH_BIN="/usr/local/bin/cph" CPH_BIN="/usr/local/bin/cph"
# The `cph-render` typst package directory (the folder holding lib.typ /
# typst.toml). Needed by PDF export: when unset, cph falls back to resolving the
# repo-relative `render/`, which does not exist in a deployed layout.
CPH_RENDER_DIR="/opt/curriculum-project-hub/render"
# Hub bind address and port. Production defaults to loopback for a local TLS # Hub bind address and port. Production defaults to loopback for a local TLS
# reverse proxy; both values are validated and honored by the HTTP server. # reverse proxy; both values are validated and honored by the HTTP server.
HOST="127.0.0.1" HOST="127.0.0.1"
+302 -47
View File
@@ -7,6 +7,17 @@
"": { "": {
"name": "filelib-web", "name": "filelib-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/language": "^6.12.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"vite-plugin-wasm": "^3.6.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
@@ -19,11 +30,161 @@
"vite": "^8.0.16" "vite": "^8.0.16"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz",
"integrity": "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -35,7 +196,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -46,7 +206,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -103,11 +262,94 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
"integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -126,7 +368,6 @@
"version": "0.139.0", "version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
@@ -146,7 +387,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -163,7 +403,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -180,7 +419,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -197,7 +435,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -214,7 +451,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -231,7 +467,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -251,7 +486,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -271,7 +505,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -291,7 +524,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -311,7 +543,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -331,7 +562,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -351,7 +581,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -368,7 +597,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -387,7 +615,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -404,7 +631,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -418,7 +644,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
@@ -808,7 +1033,6 @@
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -895,6 +1119,34 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/codemirror-lang-typst": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/codemirror-lang-typst/-/codemirror-lang-typst-0.4.0.tgz",
"integrity": "sha512-jpHz5qQRC3LE48JH+C24qZJAEAhjqRzWA4MFodPaxriz7UwLVuTLCQ672rdDx9ziObBQ2BvHaRIm5/Upz4KoBQ==",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "^6.11.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.1",
"@lezer/common": "^1.2.3",
"@lezer/highlight": "^1.2.1"
}
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -905,6 +1157,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -919,7 +1177,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -975,7 +1232,6 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -993,7 +1249,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1025,7 +1280,7 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@@ -1045,7 +1300,6 @@
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -1078,7 +1332,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1099,7 +1352,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1120,7 +1372,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1141,7 +1392,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1162,7 +1412,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1183,7 +1432,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1207,7 +1455,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1231,7 +1478,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1255,7 +1501,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1279,7 +1524,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1300,7 +1544,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1355,7 +1598,6 @@
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1388,14 +1630,12 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -1408,7 +1648,6 @@
"version": "8.5.22", "version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1451,7 +1690,6 @@
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.139.0", "@oxc-project/types": "=0.139.0",
@@ -1520,12 +1758,17 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.56.7", "version": "5.56.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz",
@@ -1604,7 +1847,6 @@
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -1631,7 +1873,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
@@ -1653,7 +1894,6 @@
"version": "8.1.5", "version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
@@ -1727,6 +1967,15 @@
} }
} }
}, },
"node_modules/vite-plugin-wasm": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
"license": "MIT",
"peerDependencies": {
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/vitefu": { "node_modules/vitefu": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
@@ -1747,6 +1996,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+11
View File
@@ -20,5 +20,16 @@
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^8.0.16" "vite": "^8.0.16"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/language": "^6.12.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"vite-plugin-wasm": "^3.6.0"
} }
} }
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
/**
* CodeMirror 6 编辑器封装。自动根据文件扩展名选择语法高亮。
* 只在浏览器 mount 后创建 EditorView(CodeMirror 依赖 DOM)。
*/
import { onMount, onDestroy } from "svelte";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { typst } from "codemirror-lang-typst";
import type { Extension } from "@codemirror/state";
let { value = "", readonly = false, filename = "", onchange }: {
value?: string;
readonly?: boolean;
filename?: string;
onchange?: (value: string) => void;
} = $props();
let container = $state<HTMLDivElement | null>(null);
let view: EditorView | null = null;
/** 根据文件名后缀选语言扩展 */
function langExtension(name: string): Extension[] {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "md":
case "markdown":
return [markdown()];
case "js":
case "mjs":
case "cjs":
return [javascript()];
case "ts":
case "mts":
case "cts":
return [javascript({ typescript: true })];
case "jsx":
return [javascript({ jsx: true })];
case "tsx":
return [javascript({ jsx: true, typescript: true })];
case "json":
case "jsonc":
return [json()];
case "html":
case "htm":
case "svelte":
case "vue":
return [html()];
case "css":
case "scss":
return [css()];
case "typ":
case "typst":
return [typst()];
default:
return [];
}
}
onMount(() => {
if (!container) return;
const extensions: Extension[] = [
basicSetup,
...langExtension(filename),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
];
if (readonly) extensions.push(EditorState.readOnly.of(true));
view = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: container,
});
});
onDestroy(() => {
view?.destroy();
view = null;
});
// 外部 value 变化时(如冲突载入最新),替换编辑器内容。
$effect(() => {
if (view && view.state.doc.toString() !== value) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}
});
</script>
<div bind:this={container} class="code-editor-wrapper"></div>
<style>
.code-editor-wrapper {
border: 1px solid var(--color-line);
border-radius: 8px;
overflow: hidden;
font-size: 13px;
line-height: 1.65;
}
.code-editor-wrapper :global(.cm-editor) {
height: 100%;
max-height: 60vh;
overflow: auto;
}
.code-editor-wrapper :global(.cm-editor.cm-focused) {
outline: none;
}
.code-editor-wrapper :global(.cm-scroller) {
overflow: auto;
}
</style>
+68 -28
View File
@@ -1,11 +1,22 @@
<script lang="ts"> <script lang="ts">
/**
* 文件编辑器(模态框形式)。打开后加载文件内容并使用 CodeMirror 编辑,
* 支持语法高亮、版本冲突处理、历史查看与删除。
*/
import { api, ApiError } from "./api.js"; import { api, ApiError } from "./api.js";
import { toastOk, toastErr, toast } from "./stores.js"; import { toastOk, toastErr, toast } from "./stores.js";
import type { FileContent, VersionInfo, Role } from "./types.js"; import type { FileContent, VersionInfo, Role } from "./types.js";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
import CodeEditor from "./CodeEditor.svelte";
let { projectId, path, role, onchanged, onclose }: { projectId: string; path: string; role: Role; onchanged: () => void; onclose?: () => void } = $props(); let { projectId, path, role, onchanged, onclose }: {
projectId: string;
path: string;
role: Role;
onchanged: () => void;
onclose: () => void;
} = $props();
let file = $state<FileContent | null>(null); let file = $state<FileContent | null>(null);
let draft = $state(""); let draft = $state("");
@@ -15,6 +26,7 @@
let history = $state<VersionInfo[]>([]); let history = $state<VersionInfo[]>([]);
const canEdit = $derived(role !== "VIEW"); const canEdit = $derived(role !== "VIEW");
const filename = $derived(path.split("/").pop() ?? "");
async function load(): Promise<void> { async function load(): Promise<void> {
try { try {
@@ -35,6 +47,11 @@
async function save(): Promise<void> { async function save(): Promise<void> {
if (file === null) return; if (file === null) return;
// 内容未变化时不提交,避免产生空 commit。
if (draft === file.content) {
toastOk("内容无变化,未提交");
return;
}
try { try {
const r = await api<{ version: string }>(`/database/api/projects/${projectId}/file/commits`, { const r = await api<{ version: string }>(`/database/api/projects/${projectId}/file/commits`, {
method: "POST", method: "POST",
@@ -81,6 +98,7 @@
toastOk("已删除"); toastOk("已删除");
file = null; file = null;
onchanged(); onchanged();
onclose();
} catch (e) { } catch (e) {
toastErr(e instanceof Error ? e.message : String(e)); toastErr(e instanceof Error ? e.message : String(e));
} }
@@ -104,58 +122,80 @@
} }
</script> </script>
{#if loadError} <!-- 主编辑器模态框:宽屏 overlay -->
<div class="panel text-xs text-danger">{loadError}</div> <div
{:else if file} class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
<div class="panel"> role="presentation"
<div class="mb-2.5 flex items-center justify-between"> onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
<span class="file-meta">{file.path} @ {file.version}</span> >
<div class="flex h-[85vh] w-full max-w-[900px] flex-col rounded-2xl border border-line-soft bg-panel shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<!-- 顶栏 -->
<div class="flex shrink-0 items-center justify-between border-b border-line-soft px-6 py-4">
<div class="flex min-w-0 items-center gap-3">
<span class="text-[15px] font-semibold text-ink truncate">{filename}</span>
</div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a> {#if file}
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>
<Icon name="download" size={13} /> 下载
</a>
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button> <button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
{#if canEdit} {#if canEdit}
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button> <button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除</button>
{/if} {/if}
{#if onclose}
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览"></button>
{/if} {/if}
<button class="btn !px-2.5" onclick={onclose} title="关闭" aria-label="关闭编辑器"></button>
</div> </div>
</div> </div>
{#if file.encoding === "base64"} <!-- 编辑器主体 -->
<div class="flex-1 overflow-y-auto px-6 py-4">
{#if loadError}
<div class="text-xs text-danger">{loadError}</div>
{:else if file === null}
<div class="quiet">加载中…</div>
{:else if file.encoding === "base64"}
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div> <div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
{:else} {:else}
<textarea rows="14" class="textarea !leading-7" bind:value={draft} readonly={!canEdit}></textarea> <CodeEditor value={draft} filename={path} readonly={!canEdit} onchange={(v) => (draft = v)} />
{/if}
{#if canEdit && file.encoding !== "base64"}
<div class="mt-3 flex justify-end">
<button class="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if} {/if}
{#if conflict} {#if conflict}
<div class="mt-3.5 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4"> <div class="mt-4 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
<div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下(你的基版 → 最新版)</div> <div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下</div>
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre> <pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后,以最新内容为全文重新提交(基版更新为 {conflict.currentVersion})</div> <div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后重新提交(基版更新为 {conflict.currentVersion})</div>
<div class="mt-2 flex justify-end"> <div class="mt-2 flex justify-end">
<button class="btn" onclick={acceptLatest}>载入最新内容</button> <button class="btn" onclick={acceptLatest}>载入最新内容</button>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{:else}
<div class="panel quiet">加载中…</div> <!-- 底栏 -->
{/if} {#if canEdit && file && file.encoding !== "base64"}
<div class="flex shrink-0 justify-end border-t border-line-soft px-6 py-3">
<button class="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if}
</div>
</div>
{#if showHistory} {#if showHistory}
<Modal title="版本历史" onclose={() => (showHistory = false)}> <Modal title="版本历史" onclose={() => (showHistory = false)}>
<div class="max-h-80 overflow-y-auto"> <div class="max-h-80 overflow-y-auto">
{#each history as v (v.version)} {#each history as v (v.version)}
<div class="border-t border-line-soft py-2 text-xs first:border-t-0"> <div class="flex items-center gap-2.5 border-t border-line-soft py-2.5 first:border-t-0">
<span class="font-mono text-accent">{v.version}</span> {v.message} <span
<div class="text-ink-3">{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}</div> class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white"
aria-hidden="true"
>{(v.author ?? "?").slice(0, 1).toUpperCase()}</span>
<div class="min-w-0 flex-1">
<p class="text-[12.5px] text-ink">{v.message}</p>
<p class="text-[11px] text-ink-3">
{#if v.author}<span>{v.author}</span> · {/if}{new Date(v.committedAt).toLocaleString("zh-CN")}
</p>
</div>
</div> </div>
{/each} {/each}
</div> </div>
+251 -15
View File
@@ -2,6 +2,7 @@
import { api } from "./api.js"; import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js"; import { toastOk, toastErr } from "./stores.js";
import { selectedFilePath, filesVersion } from "./browser.js"; import { selectedFilePath, filesVersion } from "./browser.js";
import { loadConfig } from "./config.js";
import type { FileEntry, NodeDetail } from "./types.js"; import type { FileEntry, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
@@ -13,11 +14,56 @@
let showNewFile = $state(false); let showNewFile = $state(false);
let newPath = $state(""); let newPath = $state("");
let newContent = $state(""); let newContent = $state("");
let newMessage = $state("");
// 上传弹窗:选完文件先暂存,等用户确认路径与 commit 信息再传。
let pendingFile = $state<File | null>(null);
let uploadPath = $state("");
let uploadMessage = $state("");
let uploading = $state(false);
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。 // bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
let uploadInput = $state<HTMLInputElement | null>(null); let uploadInput = $state<HTMLInputElement | null>(null);
/** 上传上限由 /database/config 下发(后端 HUB_FILELIB_MAX_FILE_BYTES)。 */
let maxFileBytes = $state<number | null>(null);
const maxLabel = $derived(
maxFileBytes === null ? "" : `${(maxFileBytes / 1024 / 1024).toFixed(maxFileBytes % (1024 * 1024) === 0 ? 0 : 1)}MB`,
);
const canEdit = $derived(node.role !== "VIEW"); const canEdit = $derived(node.role !== "VIEW");
/**
* 当前浏览目录("" = 根,否则以 "/" 结尾)。文件夹是从扁平 path 列表派生的
* 虚拟层(ADR-0030 —— git 版本存储里只有文件,没有目录对象),不对应任何
* 独立的后端资源,纯前端按 "/" 分段分组即可,无需新增接口。
*/
let currentDir = $state("");
let viewMode = $state<"list" | "grid">(loadViewMode());
function restoreCurrentDir(nodeId: string): string {
try {
return sessionStorage.getItem(`filelib.dir.${nodeId}`) ?? "";
} catch {
return "";
}
}
function loadViewMode(): "list" | "grid" {
try {
return localStorage.getItem("filelib.viewMode") === "grid" ? "grid" : "list";
} catch {
return "list";
}
}
function setViewMode(mode: "list" | "grid"): void {
viewMode = mode;
try {
localStorage.setItem("filelib.viewMode", mode);
} catch {
// 隐私模式等场景下 localStorage 可能不可用;视图切换仍在当前会话内生效,只是不跨会话记忆。
}
}
async function loadFiles(): Promise<void> { async function loadFiles(): Promise<void> {
try { try {
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`); const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`);
@@ -34,17 +80,86 @@
void loadFiles(); void loadFiles();
}); });
// 切换项目时恢复对应项目的目录位置。
$effect(() => {
void node.id;
currentDir = restoreCurrentDir(node.id);
});
// currentDir 变化时持久化。
$effect(() => {
try {
sessionStorage.setItem(`filelib.dir.${node.id}`, currentDir);
} catch { /* ignore */ }
});
$effect(() => {
void loadConfig()
.then((c) => (maxFileBytes = c.maxFileBytes))
.catch(() => (maxFileBytes = null));
});
interface FolderRow {
readonly kind: "folder";
readonly name: string;
readonly path: string;
}
interface FileRow {
readonly kind: "file";
readonly name: string;
readonly path: string;
readonly size: number;
}
/** 按当前目录分组:落在 currentDir 前缀下、第一段之后还有 "/" 的算子文件夹,否则是本层文件。 */
const rows = $derived.by((): { folders: FolderRow[]; files: FileRow[] } | null => {
if (files === null) return null;
const folderNames = new Set<string>();
const fileRows: FileRow[] = [];
for (const f of files) {
if (!f.path.startsWith(currentDir)) continue;
const rest = f.path.slice(currentDir.length);
const slash = rest.indexOf("/");
if (slash === -1) fileRows.push({ kind: "file", name: rest, path: f.path, size: f.size });
else folderNames.add(rest.slice(0, slash));
}
const folders = [...folderNames]
.sort((a, b) => a.localeCompare(b))
.map((name): FolderRow => ({ kind: "folder", name, path: `${currentDir}${name}/` }));
fileRows.sort((a, b) => a.name.localeCompare(b.name));
return { folders, files: fileRows };
});
const breadcrumbSegs = $derived(currentDir === "" ? [] : currentDir.slice(0, -1).split("/"));
function enterFolder(path: string): void {
currentDir = path;
}
function goUp(): void {
if (currentDir === "") return;
const segs = currentDir.slice(0, -1).split("/");
segs.pop();
currentDir = segs.length === 0 ? "" : `${segs.join("/")}/`;
}
function gotoBreadcrumb(index: number): void {
currentDir = index < 0 ? "" : `${breadcrumbSegs.slice(0, index + 1).join("/")}/`;
}
async function submitNewFile(): Promise<void> { async function submitNewFile(): Promise<void> {
const path = newPath.trim(); const path = newPath.trim();
if (path === "") return; if (path === "") return;
const message = newMessage.trim();
try { try {
await api(`/database/api/projects/${node.id}/file`, { await api(`/database/api/projects/${node.id}/file`, {
method: "PUT", method: "PUT",
body: { path, content: newContent }, // message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
body: message === "" ? { path, content: newContent } : { path, content: newContent, message },
}); });
toastOk("已创建"); toastOk("已创建");
showNewFile = false; showNewFile = false;
newPath = ""; newContent = ""; newPath = ""; newContent = ""; newMessage = "";
await loadFiles(); await loadFiles();
} catch (e) { } catch (e) {
toastErr(e instanceof Error ? e.message : String(e)); toastErr(e instanceof Error ? e.message : String(e));
@@ -60,28 +175,51 @@
return btoa(bin); return btoa(bin);
} }
async function doUpload(e: Event): Promise<void> { /** 选文件只负责暂存与预填;真正上传在弹窗确认后。 */
function pickFile(e: Event): void {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ""; input.value = "";
if (!file) return; if (!file) return;
if (file.size > 10 * 1024 * 1024) { // 上限取后端值;拉不到就不在前端拦 —— 后端横竖会以 413 file_too_large 兼底,
toastErr("文件超过 10MB 上限"); // 前端这道只是省一次往返。
if (maxFileBytes !== null && file.size > maxFileBytes) {
toastErr(`文件超过 ${maxLabel} 上限`);
return; return;
} }
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name); pendingFile = file;
if (!targetPath) return; uploadPath = `${currentDir}${file.name}`;
uploadMessage = "";
}
function cancelUpload(): void {
pendingFile = null;
uploadPath = "";
uploadMessage = "";
}
async function submitUpload(): Promise<void> {
const file = pendingFile;
const targetPath = uploadPath.trim();
if (file === null || targetPath === "") return;
uploading = true;
try {
const bytes = new Uint8Array(await file.arrayBuffer()); const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0); const isBinary = bytes.includes(0);
const body = isBinary const message = uploadMessage.trim();
const body: Record<string, string> = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" } ? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" }; : { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try { // message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
if (message !== "") body["message"] = message;
await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body }); await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body });
toastOk("已上传 " + file.name); toastOk(`已上传 ${file.name}`);
cancelUpload();
await loadFiles(); await loadFiles();
} catch (err) { } catch (err) {
toastErr(err instanceof Error ? err.message : String(err)); toastErr(err instanceof Error ? err.message : String(err));
} finally {
uploading = false;
} }
} }
</script> </script>
@@ -91,9 +229,9 @@
<div class="section-title">项目文件({files?.length ?? 0})</div> <div class="section-title">项目文件({files?.length ?? 0})</div>
{#if canEdit} {#if canEdit}
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<button class="btn" onclick={() => (showNewFile = true)}><Icon name="plus" size={13} /> 新建文件</button> <button class="btn" onclick={() => { newPath = currentDir; showNewFile = true; }}><Icon name="plus" size={13} /> 新建文件</button>
<button class="btn btn-primary" onclick={() => uploadInput?.click()}>上传文件</button> <button class="btn btn-primary" onclick={() => uploadInput?.click()}>上传文件</button>
<input bind:this={uploadInput} type="file" class="hidden" onchange={doUpload} /> <input bind:this={uploadInput} type="file" class="hidden" onchange={pickFile} />
</div> </div>
{/if} {/if}
</div> </div>
@@ -104,20 +242,91 @@
<div class="py-5 text-center text-xs text-danger">{loadError}</div> <div class="py-5 text-center text-xs text-danger">{loadError}</div>
{:else if files && files.length === 0} {:else if files && files.length === 0}
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div> <div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
{:else if files} {:else if rows}
<!-- 地址栏:上级 + 面包屑,与视图切换同一行,windows 资源管理器的标准布局 -->
<div class="mb-2 flex items-center justify-between gap-2 border-b border-line-soft pb-2">
<div class="flex min-w-0 items-center gap-0.5 overflow-x-auto text-[12.5px] text-ink-3">
<button
class="mr-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-ink-3 disabled:opacity-30 {currentDir !== '' ? 'hover:bg-hover hover:text-ink' : ''}"
onclick={goUp}
disabled={currentDir === ""}
title="返回上级"
aria-label="返回上级"
><Icon name="arrowUp" size={13} /></button>
<button class="shrink-0 rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(-1)}>根目录</button>
{#each breadcrumbSegs as seg, i (i)}
<span class="shrink-0 text-line">/</span>
<button class="shrink-0 truncate rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(i)}>{seg}</button>
{/each}
</div>
<div class="flex shrink-0 gap-1">
<button
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'list' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
onclick={() => setViewMode("list")}
title="列表视图"
aria-label="列表视图"
aria-pressed={viewMode === "list"}
><Icon name="viewList" size={14} /></button>
<button
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'grid' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
onclick={() => setViewMode("grid")}
title="大图标视图"
aria-label="大图标视图"
aria-pressed={viewMode === "grid"}
><Icon name="viewGrid" size={14} /></button>
</div>
</div>
{#if rows.folders.length === 0 && rows.files.length === 0}
<div class="quiet py-5 text-center">此文件夹为空</div>
{:else if viewMode === "list"}
<table class="list"> <table class="list">
<tbody> <tbody>
{#each files as f (f.path)} {#each rows.folders as folder (folder.path)}
<tr
class="cursor-pointer hover:bg-hover"
role="button"
tabindex="0"
onclick={() => enterFolder(folder.path)}
onkeydown={(e) => e.key === "Enter" && enterFolder(folder.path)}
>
<td><span class="inline-flex items-center gap-2 text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="folder" size={15} /></span>{folder.name}</span></td>
<td class="file-meta text-right"></td>
</tr>
{/each}
{#each rows.files as f (f.path)}
<tr <tr
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}" class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)} onclick={() => selectedFilePath.set(f.path)}
> >
<td class="font-mono text-[12.5px] text-ink">{f.path}</td> <td><span class="inline-flex items-center gap-2 font-mono text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="file" size={14} /></span>{f.name}</span></td>
<td class="file-meta text-right">{f.size} B</td> <td class="file-meta text-right">{f.size} B</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(84px,1fr))] gap-1 py-1">
{#each rows.folders as folder (folder.path)}
<button
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center hover:bg-hover"
onclick={() => enterFolder(folder.path)}
>
<span class="text-ink-3"><Icon name="folder" size={34} /></span>
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{folder.name}</span>
</button>
{/each}
{#each rows.files as f (f.path)}
<button
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<span class="text-ink-3"><Icon name="file" size={34} /></span>
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{f.name}</span>
</button>
{/each}
</div>
{/if}
{/if} {/if}
</div> </div>
@@ -131,9 +340,36 @@
<label class="form-label" for="nf-content">内容</label> <label class="form-label" for="nf-content">内容</label>
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea> <textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
</div> </div>
<div class="form-row">
<label class="form-label" for="nf-msg">提交信息(可选)</label>
<input id="nf-msg" class="input" bind:value={newMessage} placeholder="留空则自动生成" />
</div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showNewFile = false)}>取消</button> <button class="btn" onclick={() => (showNewFile = false)}>取消</button>
<button class="btn btn-primary" onclick={submitNewFile}>创建</button> <button class="btn btn-primary" onclick={submitNewFile}>创建</button>
</div> </div>
</Modal> </Modal>
{/if} {/if}
{#if pendingFile}
<Modal title="上传文件" onclose={cancelUpload}>
<div class="form-row">
<span class="form-label">已选文件</span>
<p class="font-mono text-[12.5px] text-ink">{pendingFile.name}<span class="quiet"> · {pendingFile.size} B</span></p>
</div>
<div class="form-row">
<label class="form-label" for="up-path">保存到路径</label>
<input id="up-path" class="input font-mono" bind:value={uploadPath} placeholder="材料/课件.pptx" />
</div>
<div class="form-row">
<label class="form-label" for="up-msg">提交信息(可选)</label>
<input id="up-msg" class="input" bind:value={uploadMessage} placeholder="留空则自动生成" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={cancelUpload} disabled={uploading}>取消</button>
<button class="btn btn-primary" onclick={submitUpload} disabled={uploading || uploadPath.trim() === ""}>
{uploading ? "上传中…" : "上传"}
</button>
</div>
</Modal>
{/if}
+3 -2
View File
@@ -84,7 +84,7 @@
} }
async function revoke(g: Grant): Promise<void> { async function revoke(g: Grant): Promise<void> {
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return; if (!confirm(`收回「${g.principalName}」的 ${g.role} 授权?`)) return;
try { try {
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, { await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
method: "DELETE", method: "DELETE",
@@ -133,7 +133,8 @@
<td> <td>
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span> <span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
<span class="font-mono text-[12px]">{g.principalId}</span> <!-- 展示名优先(后端已解析);id 作为 title 供排查。 -->
<span class="font-medium" title={g.principalId}>{g.principalName}</span>
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if} {#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
</span> </span>
</td> </td>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
/**
* 项目修改历史 tab:展示所有文件的提交记录(新→旧)。
* 默认只显示 commit message + 作者头像 + 时间,点击可展开查看修改的文件。
*/
import { api } from "./api.js";
import { toastErr } from "./stores.js";
import type { ProjectCommitInfo, NodeDetail } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
let history = $state<ProjectCommitInfo[] | null>(null);
let loadError = $state<string | null>(null);
let limit = $state(50);
let expanded = $state<Set<string>>(new Set());
async function loadHistory(): Promise<void> {
try {
const r = await api<{ history: ProjectCommitInfo[] }>(
`/database/api/projects/${node.id}/history?limit=${limit}`,
);
history = r.history;
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void node.id;
void loadHistory();
});
function toggle(version: string): void {
const next = new Set(expanded);
if (next.has(version)) next.delete(version);
else next.add(version);
expanded = next;
}
function loadMore(): void {
limit += 50;
void loadHistory();
}
function authorInitial(author: string | undefined): string {
return (author ?? "?").slice(0, 1).toUpperCase();
}
</script>
<div class="panel">
<div class="mb-3 section-title">修改历史</div>
{#if history === null && loadError === null}
<div class="quiet py-5 text-center">加载中…</div>
{:else if loadError}
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
{:else if history && history.length === 0}
<div class="quiet py-5 text-center">暂无提交记录</div>
{:else if history}
<div>
{#each history as commit (commit.version)}
{@const isOpen = expanded.has(commit.version)}
<div class="border-t border-line-soft first:border-t-0">
<button
class="flex w-full items-center gap-2.5 py-3 text-left transition hover:bg-hover rounded-md px-1.5 -mx-1.5"
onclick={() => toggle(commit.version)}
aria-expanded={isOpen}
>
<!-- 头像 -->
<span
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent text-[12px] font-semibold text-white"
aria-hidden="true"
>{authorInitial(commit.author)}</span>
<!-- 消息与时间 -->
<div class="min-w-0 flex-1">
<p class="text-[13px] text-ink">{commit.message}</p>
<p class="mt-0.5 text-[11.5px] text-ink-3">
{#if commit.author}<span>{commit.author}</span> · {/if}{new Date(commit.committedAt).toLocaleString("zh-CN")}
</p>
</div>
<!-- 展开指示 -->
<span class="shrink-0 text-ink-3 transition {isOpen ? 'rotate-90' : ''}">
<Icon name="chevron" size={12} />
</span>
</button>
{#if isOpen}
<div class="pb-3 pl-[46px]">
<div class="flex flex-wrap gap-1">
{#each commit.files as filePath (filePath)}
<span class="inline-flex items-center gap-1 rounded-md bg-hover px-1.5 py-0.5 font-mono text-[11px] text-ink-2">
<Icon name="file" size={10} />{filePath}
</span>
{/each}
{#if commit.files.length === 0}
<span class="text-[11.5px] text-ink-3">无文件变更信息</span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{#if history.length >= limit}
<div class="mt-3 flex justify-center">
<button class="btn" onclick={loadMore}>加载更多</button>
</div>
{/if}
{/if}
</div>
+7
View File
@@ -19,6 +19,13 @@
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。 // 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6", archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5", restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
download: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3",
// Windows 资源管理器式文件浏览:文件夹/文件项与大小图标切换用。
folder: "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",
file: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z M14 2v4a2 2 0 0 0 2 2h4",
viewList: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
viewGrid: "M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 0h7v7h-7v-7Z",
arrowUp: "M12 19V5M5 12l7-7 7 7",
} as const; } as const;
export type IconName = keyof typeof ICONS; export type IconName = keyof typeof ICONS;
+22 -8
View File
@@ -9,8 +9,8 @@
import { api } from "./api.js"; import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js"; import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js"; import { logout } from "./session.js";
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js"; import { treeVersion, bumpTree, currentNode, breadcrumb, selectedFilePath, clearSelectedFile, bumpFiles, restoreNodeId } from "./browser.js";
import type { NodeChild } from "./types.js"; import type { NodeChild, NodeDetail, BreadcrumbEntry } from "./types.js";
import TreeNode from "./TreeNode.svelte"; import TreeNode from "./TreeNode.svelte";
import NodeDetailPanel from "./NodeDetailPanel.svelte"; import NodeDetailPanel from "./NodeDetailPanel.svelte";
import FileEditor from "./FileEditor.svelte"; import FileEditor from "./FileEditor.svelte";
@@ -35,7 +35,22 @@
} }
} }
onMount(loadRoots); onMount(async () => {
await loadRoots();
// 刷新后恢复之前选中的节点。
if (restoreNodeId) {
try {
const [detail, crumb] = await Promise.all([
api<{ node: NodeDetail }>(`/database/api/nodes/${restoreNodeId}`),
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${restoreNodeId}/breadcrumb`),
]);
currentNode.set(detail.node);
breadcrumb.set(crumb.breadcrumb);
} catch {
// 节点已删除或无权访问,静默忽略。
}
}
});
$effect(() => { $effect(() => {
void $treeVersion; void $treeVersion;
void loadRoots(); void loadRoots();
@@ -63,7 +78,7 @@
} }
} }
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase()); const initial = $derived((($me?.displayName ?? $me?.userId) ?? "U").slice(0, 1).toUpperCase());
</script> </script>
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
@@ -97,7 +112,8 @@
{#if showUserFooter} {#if showUserFooter}
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]"> <div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div> <div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span> <!-- 展示名优先;/me 取不到 User 行时后端已回落为 userId。 -->
<span class="flex-1 truncate text-ink" title={$me?.userId ?? ""}>{$me?.displayName ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button> <button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div> </div>
{/if} {/if}
@@ -108,9 +124,8 @@
<NodeDetailPanel /> <NodeDetailPanel />
</main> </main>
<!-- 右侧:文件预览/编辑栏(选中文件时出现) --> <!-- 文件编辑器(模态框) -->
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"} {#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
<section class="flex w-[46%] min-w-[420px] shrink-0 flex-col overflow-y-auto border-l border-line-soft bg-bg p-4">
<FileEditor <FileEditor
projectId={$currentNode.id} projectId={$currentNode.id}
path={$selectedFilePath} path={$selectedFilePath}
@@ -118,7 +133,6 @@
onchanged={bumpFiles} onchanged={bumpFiles}
onclose={clearSelectedFile} onclose={clearSelectedFile}
/> />
</section>
{/if} {/if}
</div> </div>
+30 -5
View File
@@ -1,15 +1,21 @@
<script lang="ts"> <script lang="ts">
import { api } from "./api.js"; import { api } from "./api.js";
import { currentNode, breadcrumb, bumpTree, clearSelectedFile } from "./browser.js"; import { currentNode, breadcrumb, bumpTree, clearSelectedFile, activeTab, restoreTab } from "./browser.js";
import { toastOk, toastErr } from "./stores.js"; import { toastOk, toastErr } from "./stores.js";
import OverviewPanel from "./OverviewPanel.svelte"; import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte"; import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte"; import GrantsPanel from "./GrantsPanel.svelte";
import HistoryPanel from "./HistoryPanel.svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
type Tab = "detail" | "files" | "grants"; type Tab = "detail" | "files" | "history" | "grants";
let tab = $state<Tab>("detail"); const validTabs: readonly Tab[] = ["detail", "files", "history", "grants"];
let tab = $state<Tab>((validTabs.includes(restoreTab as Tab) ? restoreTab as Tab : "detail"));
/** 跟踪上一次见到的 node id,用于判断是否真正切换了节点。 */
let prevNodeId: string | null = null;
/** 首次恢复时不重置 tab。 */
let restoredOnce = restoreTab !== null;
let showCreateChild = $state(false); let showCreateChild = $state(false);
let newName = $state(""); let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER"); let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
@@ -20,21 +26,38 @@
const canManage = $derived(node?.role === "MANAGE"); const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT"); const canEdit = $derived(canManage || node?.role === "EDIT");
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE // 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;修改历史仅 PROJECT;授权仅 MANAGE
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。 // (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => { const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
const out: Array<readonly [Tab, string]> = [["detail", "概览"]]; const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
if (node?.kind === "PROJECT") out.push(["files", "文件"]); if (node?.kind === "PROJECT") out.push(["files", "文件"]);
if (node?.kind === "PROJECT") out.push(["history", "修改历史"]);
if (canManage) out.push(["grants", "授权"]); if (canManage) out.push(["grants", "授权"]);
return out; return out;
}); });
$effect(() => { $effect(() => {
void node?.id; const id = node?.id ?? null;
// node 还未加载或与上次相同时不做任何事。
if (id === null || id === prevNodeId) return;
prevNodeId = id;
if (restoredOnce) {
// 首次恢复(刷新后)保持 persisted tab,但要确保 tab 对当前节点有效。
restoredOnce = false;
const isProject = node?.kind === "PROJECT";
if ((tab === "files" || tab === "history") && !isProject) tab = "detail";
if (tab === "grants" && node?.role !== "MANAGE") tab = "detail";
} else {
tab = "detail"; tab = "detail";
}
clearSelectedFile(); clearSelectedFile();
}); });
// tab 变化时同步到持久化 store。
$effect(() => {
activeTab.set(tab);
});
async function createChild(): Promise<void> { async function createChild(): Promise<void> {
const name = newName.trim(); const name = newName.trim();
if (name === "" || node === null) return; if (name === "" || node === null) return;
@@ -129,6 +152,8 @@
<GrantsPanel {node} /> <GrantsPanel {node} />
{:else if tab === "files" && node.kind === "PROJECT"} {:else if tab === "files" && node.kind === "PROJECT"}
<FilesPanel {node} /> <FilesPanel {node} />
{:else if tab === "history" && node.kind === "PROJECT"}
<HistoryPanel {node} />
{:else} {:else}
<OverviewPanel {node} /> <OverviewPanel {node} />
{#if node.kind === "FOLDER"} {#if node.kind === "FOLDER"}
+51 -18
View File
@@ -4,12 +4,14 @@
import { currentNode } from "./browser.js"; import { currentNode } from "./browser.js";
import type { ExportJob, NodeDetail } from "./types.js"; import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props(); let { node }: { node: NodeDetail } = $props();
let showEditDesc = $state(false); let showEditDesc = $state(false);
let descDraft = $state(""); let descDraft = $state("");
let exportJob = $state<ExportJob | null>(null); let exportJob = $state<ExportJob | null>(null);
let exporting = $state(false);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT"); const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE"); const canManage = $derived(node.role === "MANAGE");
@@ -34,6 +36,7 @@
$effect(() => { $effect(() => {
void node.id; void node.id;
exportJob = null; exportJob = null;
exporting = false;
}); });
function openEditDesc(): void { function openEditDesc(): void {
@@ -57,31 +60,63 @@
} }
} }
async function submitExport(): Promise<void> { /**
* 导出 PDF:提交 job → 轮询 → 完成即自动触发浏览器下载。
*
* 只有一个导出目标,所以不给目标选择器 —— target 由后端 adapter 固定。
* 下载走 <a download> 而非 fetch+blob:接口是 same-origin cookie 认证,
* 浏览器直接带上会话,不需要在 JS 里搬一遍字节。
*/
async function exportPdf(): Promise<void> {
if (exporting) return;
exporting = true;
exportJob = null;
try { try {
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, { const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
method: "POST", method: "POST",
body: { target: "manifest" }, body: { target: "pdf" },
}); });
toastOk("导出已提交"); await pollExport(r.jobId);
void pollExport(r.jobId);
} catch (e) { } catch (e) {
toastErr(e instanceof Error ? e.message : String(e)); toastErr(e instanceof Error ? e.message : String(e));
exporting = false;
} }
} }
async function pollExport(jobId: string): Promise<void> { async function pollExport(jobId: string): Promise<void> {
for (;;) { for (;;) {
await new Promise((r) => setTimeout(r, 800)); await new Promise((r) => setTimeout(r, 800));
let job: ExportJob;
try { try {
const job = await api<ExportJob>(`/database/api/exports/${jobId}`); job = await api<ExportJob>(`/database/api/exports/${jobId}`);
} catch (e) {
exporting = false;
toastErr(e instanceof Error ? e.message : String(e));
return;
}
exportJob = job; exportJob = job;
if (job.status === "DONE" || job.status === "FAILED") break; if (job.status === "DONE") {
} catch { exporting = false;
break; toastOk("导出完成,开始下载");
triggerDownload(`/database/api/exports/${job.id}/download`);
return;
}
if (job.status === "FAILED") {
exporting = false;
toastErr(`导出失败:${job.error ?? "未知原因"}`);
return;
} }
} }
} }
function triggerDownload(url: string): void {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
}
</script> </script>
<div class="panel"> <div class="panel">
@@ -124,18 +159,16 @@
<div class="section-title mb-2">导出</div> <div class="section-title mb-2">导出</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select> <button class="btn" onclick={exportPdf} disabled={exporting}>
<button class="btn" onclick={submitExport}>开始导出</button> <Icon name="download" size={13} />
{#if exportJob} {exporting ? "导出中…" : "导出 PDF"}
</button>
{#if exportJob?.status === "DONE"}
<span class="file-meta"> <span class="file-meta">
{#if exportJob.status === "DONE"} 完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download" download>重新下载</a>
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
{:else if exportJob.status === "FAILED"}
失败:{exportJob.error ?? ""}
{:else}
{exportJob.status}
{/if}
</span> </span>
{:else if exportJob?.status === "FAILED"}
<span class="file-meta text-danger">失败:{exportJob.error ?? "未知原因"}</span>
{/if} {/if}
</div> </div>
{/if} {/if}
+48 -3
View File
@@ -1,17 +1,62 @@
import { writable } from "svelte/store"; import { writable, get } from "svelte/store";
import type { BreadcrumbEntry, NodeDetail } from "./types.js"; import type { BreadcrumbEntry, NodeDetail } from "./types.js";
const STORAGE_KEY = "filelib.browser";
interface PersistedState {
expanded: string[];
currentNodeId: string | null;
tab: string | null;
selectedFilePath: string | null;
}
function loadPersisted(): PersistedState {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as PersistedState;
} catch { /* ignore */ }
return { expanded: [], currentNodeId: null, tab: null, selectedFilePath: null };
}
function savePersisted(): void {
try {
const state: PersistedState = {
expanded: [...get(expanded)],
currentNodeId: get(currentNode)?.id ?? null,
tab: get(activeTab),
selectedFilePath: get(selectedFilePath),
};
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* sessionStorage 不可用时静默忽略 */ }
}
const persisted = loadPersisted();
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */ /** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
export const expanded = writable<Set<string>>(new Set()); export const expanded = writable<Set<string>>(new Set(persisted.expanded));
export const currentNode = writable<NodeDetail | null>(null); export const currentNode = writable<NodeDetail | null>(null);
export const breadcrumb = writable<BreadcrumbEntry[]>([]); export const breadcrumb = writable<BreadcrumbEntry[]>([]);
export const treeVersion = writable(0); export const treeVersion = writable(0);
/** 刷新后需要恢复的节点 ID;LibraryView onMount 消费后清空。 */
export const restoreNodeId = persisted.currentNodeId;
/** 刷新后需要恢复的 tab;NodeDetailPanel 消费。 */
export const restoreTab = persisted.tab;
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */ /** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
export const selectedFilePath = writable<string | null>(null); export const selectedFilePath = writable<string | null>(persisted.selectedFilePath);
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */ /** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
export const filesVersion = writable(0); export const filesVersion = writable(0);
/** 当前激活的 tab(由 NodeDetailPanel 写入,持久化用)。 */
export const activeTab = writable<string | null>(persisted.tab);
// 订阅需要持久化的 store,变化时写 sessionStorage。
expanded.subscribe(() => savePersisted());
currentNode.subscribe(() => savePersisted());
selectedFilePath.subscribe(() => savePersisted());
activeTab.subscribe(() => savePersisted());
export function bumpTree(): void { export function bumpTree(): void {
treeVersion.update((v) => v + 1); treeVersion.update((v) => v + 1);
} }
+2
View File
@@ -12,6 +12,8 @@ import { api } from "./api.js";
export interface AppConfig { export interface AppConfig {
readonly orgSlug: string; readonly orgSlug: string;
readonly devLoginEnabled: boolean; readonly devLoginEnabled: boolean;
/** 单文件上传上限(字节)。后端 `HUB_FILELIB_MAX_FILE_BYTES` 的生效值。 */
readonly maxFileBytes: number;
} }
let cached: AppConfig | null = null; let cached: AppConfig | null = null;
+11 -15
View File
@@ -63,6 +63,15 @@ export interface VersionInfo {
readonly committedAt: string; readonly committedAt: string;
} }
/** 项目级提交历史条目(包含受影响文件路径)。 */
export interface ProjectCommitInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
readonly files: readonly string[];
}
export interface ExportJob { export interface ExportJob {
readonly id: string; readonly id: string;
readonly nodeId: string; readonly nodeId: string;
@@ -72,21 +81,6 @@ export interface ExportJob {
readonly createdAt: string; 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;
}
/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */ /** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */
export interface MemberGroupNode { export interface MemberGroupNode {
readonly id: string; readonly id: string;
@@ -114,6 +108,8 @@ export interface Grant {
readonly id: string; readonly id: string;
readonly principalType: "USER" | "GROUP"; readonly principalType: "USER" | "GROUP";
readonly principalId: string; readonly principalId: string;
/** 后端解析好的展示名(USER→displayName / GROUP→组名);取不到行时回落为 principalId。 */
readonly principalName: string;
readonly role: Role; readonly role: Role;
/** 创建者授权不可收回、不可改(契约 8.1)。 */ /** 创建者授权不可收回、不可改(契约 8.1)。 */
readonly isCreatorGrant: boolean; readonly isCreatorGrant: boolean;
@@ -28,9 +28,18 @@
const BASE = "/database/dashboard"; const BASE = "/database/dashboard";
onMount(async () => { onMount(loadSession);
await loadSession();
if ($me === null) void goto("/database/admin", { replaceState: true }); /**
* 未登录一律回登录页 —— 必须是 effect 而非 onMount 里的一次性判断:
* `logout()` 只清 store(它被老师端 /app 共用,那边 me=null 是终态而非跳转),
* 退出后这层壳会重新渲染成 me===null,若跳转只写在 onMount 就永远停在
* "跳转到登录页…"。
*/
$effect(() => {
if ($authChecked && $me === null) {
void goto("/database/admin", { replaceState: true });
}
}); });
function href(seg: string): string { function href(seg: string): string {
+2 -1
View File
@@ -1,5 +1,6 @@
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import wasm from "vite-plugin-wasm";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。 // 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。
@@ -8,7 +9,7 @@ import { defineConfig } from "vite";
const backend = "http://127.0.0.1:8788"; const backend = "http://127.0.0.1:8788";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [wasm(), tailwindcss(), sveltekit()],
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
+22 -1
View File
@@ -268,10 +268,31 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
} }
}); });
app.post("/auth/logout", async (_request, reply) => { // 退出登录不读 body,但调用方(curl -d、Postman、部分 HTTP 客户端)常给空
// POST 自动带上 Content-Type。Fastify 默认只有 JSON parser,遇到别的媒体类型
// 会在解析阶段以 415 拒掉,进不到 handler —— 对一个"无输入"的端点没有意义。
//
// 这里用 register 起一个封装作用域,catch-all parser 只在其中生效。
// **不要**把 parser 加到外层 app 上:admin plugin 没有 fastify-plugin 封装,
// 那样会让全站每个 POST/PUT/PATCH 都接受 form-urlencoded。而 form-urlencoded
// 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS
// preflight),"只认 JSON"本身是一层 CSRF 纵深防御,不能为了这个端点全局放掉。
await app.register(async (scope) => {
// parseAs:"string" 让 Fastify 负责读完流(否则连接不释放),这里直接丢掉内容
// —— 该端点不接受任何输入。
// "*" 只兜没有专属 parser 的媒体类型;内建 JSON parser 优先级更高,空 body
// 会被它判成 FST_ERR_CTP_EMPTY_JSON_BODY(400),所以要在本作用域内覆盖掉。
for (const mediaType of ["*", "application/json"]) {
scope.addContentTypeParser(mediaType, { parseAs: "string" }, (_request, _body, done) => {
done(null, undefined);
});
}
scope.post("/auth/logout", async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
return reply.status(204).send(); return reply.status(204).send();
}); });
});
app.get("/auth/feishu/complete", async (request, reply) => { app.get("/auth/feishu/complete", async (request, reply) => {
const query = request.query as { org?: string }; const query = request.query as { org?: string };
+16 -4
View File
@@ -110,7 +110,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust | | `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust |
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) | | `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port | | `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) | | `filelib/versionStore.ts` | 契约 C1 port + 内存实现(**仅测试用**,ADR-0030) |
| `filelib/gitVersionStore.ts` | **生产** C1 实现:一项目一 git 仓库,VersionId = commit hash(ADR-0030) |
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) | | `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) | | `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) | | `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
@@ -125,9 +126,20 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub - `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team) MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**, ## 存储布局(ADR-0030)
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
> 持久化 git 包到位后此问题消失。 - **文件夹不落盘。** `FOLDER` 节点的 `storageDir` 永为 `NULL`,磁盘上不存在任何
对应目录;树形只由 `parentId` + `pathIds`(id 编码的物化路径)表达。
- **项目扁平、以 id 命名。** 仓库就是 `<storageRoot>/<nodeId>`(nodeId 是 uuid)。
名字不进路径 —— 这是 rename 不动磁盘、也不重写后代路径的原因。
- **一项目一真 git 仓库。** `init` 建目录 + `git init`;`VersionId` 是 40 位 commit
hash;某文件的版本 = `git log -1 -- <path>`,所以一个文件的提交不会使另一个
文件的 `baseVersion` 失效(D16)。删除也是一个 commit,旧版本仍可读。
- git 用 `execFile` 调系统二进制,不引依赖。**每次调用都禁 hooks、隔离全局/系统
gitconfig、`GIT_LITERAL_PATHSPECS=1`** —— 项目仓库是老师上传的**数据**而非可信代码。
- 宿主必须有 `git`;缺失时建项目报 `provision_failed`
- 写入仅**进程内**串行化。共享 storage root 的多进程会在同一仓库上竞争 git 锁;
alpha Silo 是一 org 一进程(ADR-0025),目前不可达。
关键语义速查: 关键语义速查:
+146 -1
View File
@@ -7,11 +7,18 @@
*/ */
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { FileLibError } from "./model.js"; import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import { requireAccessInTx, type FileLibActor } from "./treeService.js"; import { requireAccessInTx, type FileLibActor } from "./treeService.js";
import type { FileDeps } from "./fileService.js"; import type { FileDeps } from "./fileService.js";
const execFileAsync = promisify(execFile);
export interface ExportAdapterInput { export interface ExportAdapterInput {
readonly storageDir: string; readonly storageDir: string;
readonly target: string; readonly target: string;
@@ -49,8 +56,146 @@ export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]
}; };
} }
/** `cph` 可执行文件位置;生产由 preflight 校验为绝对路径(见 deployment/preflight.ts)。 */
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
/** typst 渲染包目录;未设时由 cph 自行解析(仓库内 `render/`)。 */
const CPH_RENDER_DIR = process.env["CPH_RENDER_DIR"];
const CPH_BUILD_TIMEOUT_MS = 120_000;
/**
* 默认导出 target。
*
* UI 只给一个「导出 PDF」按钮,不让用户选 target;`student` 是 cph 自身在
* 工程文件未声明 `[targets.*]` 时的默认值(cph-check DEFAULT_TARGET),
* 与之保持一致,避免两端各有一套默认。
*/
const DEFAULT_PDF_TARGET = "student";
/**
* 真导出适配器:把项目物化到临时目录,跑 `cph build`,取回 PDF 字节。
*
* 为什么不直接在 `storageDir`(git worktree)里跑构建:那是项目的版本库工作区,
* 构建产物会变成未跟踪文件混进去,后续 `list`/`commit` 的语义会被污染。
* 物化到临时目录让构建对版本库完全无副作用,代价是一次文件拷贝。
*/
export function createCphPdfAdapter(): ExportAdapter {
return {
target: "pdf",
async run(input) {
await requireCourswareCph();
const target = typeof input.params["target"] === "string" ? input.params["target"] : DEFAULT_PDF_TARGET;
const workDir = await mkdtemp(path.join(tmpdir(), "cph-export-"));
try {
await materialize(input, workDir);
const outRel = path.join("build", `${target}.pdf`);
const args = ["build", ".", "--target", target, "-o", outRel];
if (CPH_RENDER_DIR !== undefined && CPH_RENDER_DIR !== "") {
args.unshift("--render-dir", CPH_RENDER_DIR);
}
await runCphBuild(args, workDir);
const content = await readFile(path.join(workDir, outRel));
return { filename: `${target}.pdf`, content };
} finally {
await rm(workDir, { recursive: true, force: true });
}
},
};
}
/**
* 确认 CPH_BIN 指向的是 Courseware 检查器,而不是同名的其它工具。
*
* `cph` 这个名字在 PyPI 上已被 conda 的 conda-package-handling 占用,装了
* miniconda 的机器上 PATH 里的 `cph` 就是它。直接拿它跑 `build` 会得到
* 一句 argparse 的 "invalid choice: 'build'",根本看不出是撞名 —— 所以这里先用
* `--version` 探一下,把撞名变成一条能直接终止排查的错误。
*
* 结果缓存:探测只为拦配置错误,没必要每次导出都多起一个进程。
*/
let cphIdentityCheck: Promise<void> | null = null;
function requireCourswareCph(): Promise<void> {
cphIdentityCheck ??= (async () => {
let stdout: string;
try {
({ stdout } = await execFileAsync(CPH_BIN, ["--version"], { timeout: 10_000 }));
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
throw new FileLibError(
500,
"cph_not_found",
`cph binary not found at "${CPH_BIN}" (set CPH_BIN to the Courseware cph)`,
);
}
throw new FileLibError(500, "cph_unusable", `cph --version failed at "${CPH_BIN}": ${String(err.message)}`);
}
if (!/^cph\s+\d+\.\d+\.\d+/.test(stdout.trim())) {
throw new FileLibError(
500,
"cph_wrong_binary",
`"${CPH_BIN}" is not the Courseware cph checker (--version said: ${stdout.trim().split("\n")[0] ?? ""}). ` +
`Set CPH_BIN to the Courseware cph binary.`,
);
}
})().catch((error: unknown) => {
// 不缓存失败:改完 CPH_BIN 重启前,下一次导出应该重新探测。
cphIdentityCheck = null;
throw error;
});
return cphIdentityCheck;
}
/** 把版本库当前内容写进 workDir。路径已由 versionStore 的 safeRelPath 约束。 */
async function materialize(input: ExportAdapterInput, workDir: string): Promise<void> {
const files = await input.listFiles();
if (files.length === 0) {
throw new FileLibError(409, "export_empty_project", "project has no files to export");
}
for (const f of files) {
const abs = path.join(workDir, f.path);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, await input.readFile(f.path));
}
}
/**
* 跑 `cph build`。cph 的约定是诊断走 stderr、退出码非零表示构建失败(ADR-0010),
* 所以失败时把 stderr 原样带进错误信息 —— 老师需要看到是哪个诊断挡住了导出。
*/
async function runCphBuild(args: readonly string[], cwd: string): Promise<void> {
try {
await execFileAsync(CPH_BIN, args, { cwd, timeout: CPH_BUILD_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
} catch (error) {
const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
if (err.code === "ENOENT") {
throw new FileLibError(500, "cph_not_found", `cph binary not found at "${CPH_BIN}"`);
}
const detail = (err.stderr ?? "").trim() || (err.stdout ?? "").trim() || err.message;
throw new FileLibError(422, "cph_build_failed", `cph build failed: ${detail}`);
}
}
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。 // v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
const artifacts = new Map<string, ExportArtifact>(); const artifacts = new Map<string, ExportArtifact>();
/**
* 内存里最多保留的产物份数。
*
* 产物不做持久化也不复用 —— 每次导出都按仓库当前内容重新编译,内存副本只为
* 支撑「提交完成后那一次下载」。因此这里可以无条件淘汰最旧的:被淘汰的 job 再点
* 下载会拿到 export_not_ready,重新导出即可,不存在数据丢失。
* 没有上限的话每次导出都会永久占住一份 PDF(数百 KB 级),进程内存只增不减。
*/
const MAX_RETAINED_ARTIFACTS = 32;
/** Map 迭代顺序即插入顺序,首个 key 就是最旧的产物。 */
function retainArtifact(jobId: string, artifact: ExportArtifact): void {
artifacts.set(jobId, artifact);
while (artifacts.size > MAX_RETAINED_ARTIFACTS) {
const oldest = artifacts.keys().next();
if (oldest.done === true) break;
artifacts.delete(oldest.value);
}
}
export interface ExportDeps extends FileDeps { export interface ExportDeps extends FileDeps {
readonly adapters: readonly ExportAdapter[]; readonly adapters: readonly ExportAdapter[];
@@ -136,7 +281,7 @@ async function runExportJob(
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix), listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
readFile: (path) => deps.versionStore.read(storageDir, path), readFile: (path) => deps.versionStore.read(storageDir, path),
}); });
artifacts.set(jobId, artifact); retainArtifact(jobId, artifact);
await deps.prisma.fileLibExportJob.update({ await deps.prisma.fileLibExportJob.update({
where: { id: jobId }, where: { id: jobId },
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` }, data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
+74 -10
View File
@@ -10,14 +10,43 @@
import { FileLibError } from "./model.js"; import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { CommitResult, FileEntry, VersionInfo, VersionStore } from "./versionStore.js"; import type { CommitResult, FileEntry, VersionInfo, VersionStore, ProjectCommitInfo } from "./versionStore.js";
import type { AccessDeps, FileLibActor } from "./treeService.js"; import type { AccessDeps, FileLibActor } from "./treeService.js";
import { requireAccessInTx } from "./treeService.js"; import { requireAccessInTx } from "./treeService.js";
import type { FileLibNode, PrismaClient } from "@prisma/client"; import type { FileLibNode, PrismaClient } from "@prisma/client";
export const FILE_PATH_MAX_LENGTH = 512; export const FILE_PATH_MAX_LENGTH = 512;
export const FILE_PATH_MAX_DEPTH = 32; export const FILE_PATH_MAX_DEPTH = 32;
export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值
/** 单文件上限的出厂默认值(OPEN-5 初值)。实际生效值见 `resolveMaxFileBytes`。 */
export const FILE_CONTENT_MAX_BYTES_DEFAULT = 10 * 1024 * 1024;
/**
* 单文件字节上限的纯解析。非法值(非正整数/NaN)按缺省处理 ——
* 配置写错不该让上传静默变成 0 上限(那会把每次上传都拒掉)。
*
* 与 `resolveMaxFileBytes` 分开是有意的:带默认参数的单函数版本里,
* 显式传 undefined 会触发默认值、回到读 env,于是“没传值”和“读环境变量”
* 永远分不开,测试也会被 vitest 加载的 .env 干扰。
*/
export function parseMaxFileBytes(raw: string | undefined): number {
if (raw === undefined || raw.trim() === "") return FILE_CONTENT_MAX_BYTES_DEFAULT;
const parsed = Number(raw.trim());
if (!Number.isSafeInteger(parsed) || parsed <= 0) return FILE_CONTENT_MAX_BYTES_DEFAULT;
return parsed;
}
/**
* 生效上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB。
*
* 注意它与 `HUB_HTTP_BODY_LIMIT_BYTES` 是串联的:上传把内容放在 JSON body 里,
* 二进制过 base64 体积涨 4/3,所以真正的天花板是
* min(本值, bodyLimit × 3/4)。body limit 太小时本值不可达,而且报错发生在
* Fastify 解析阶段(413 Payload Too Large),根本到不了下面的 checkSize。
*/
export function resolveMaxFileBytes(): number {
return parseMaxFileBytes(process.env["HUB_FILELIB_MAX_FILE_BYTES"]);
}
const CONTROL_CHARS = /[\p{C}]/u; const CONTROL_CHARS = /[\p{C}]/u;
const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]); const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
@@ -52,6 +81,8 @@ export function validateFilePath(raw: string): string {
export interface FileDeps extends AccessDeps { export interface FileDeps extends AccessDeps {
readonly prisma: PrismaClient; readonly prisma: PrismaClient;
readonly versionStore: VersionStore; readonly versionStore: VersionStore;
/** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */
readonly maxFileBytes?: number | undefined;
} }
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string }; type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
@@ -101,13 +132,25 @@ function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding
: { encoding: "utf8", content: buffer.toString("utf8") }; : { encoding: "utf8", content: buffer.toString("utf8") };
} }
function checkSize(content: string | Buffer): void { function checkSize(content: string | Buffer, maxBytes: number): void {
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength; const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
if (bytes > FILE_CONTENT_MAX_BYTES) { if (bytes > maxBytes) {
throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`); throw new FileLibError(413, "file_too_large", `file exceeds ${maxBytes} bytes`);
} }
} }
/**
* commit message 的默认文案:`【用户名】修改了【路径】`。
* 用户名取 displayName,缺失回退 userId(权限判定从不看它)。
* 显式传 message 的调用方优先 —— 这里只填空缺。
*/
export function defaultCommitMessage(actor: FileLibActor, filePath: string): string {
const who = actor.displayName === undefined || actor.displayName.trim() === ""
? actor.userId
: actor.displayName.trim();
return `${who}】修改了【${filePath}`;
}
async function auditFile( async function auditFile(
deps: FileDeps, deps: FileDeps,
actor: FileLibActor, actor: FileLibActor,
@@ -185,6 +228,17 @@ export async function fileHistory(
return deps.versionStore.history(project.storageDir, filePath, limit); return deps.versionStore.history(project.storageDir, filePath, limit);
} }
/** 项目级提交历史(所有文件),VIEW 即可访问。 */
export async function projectHistory(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
limit?: number,
): Promise<readonly ProjectCommitInfo[]> {
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.projectHistory(project.storageDir, limit);
}
export async function diffFile( export async function diffFile(
deps: FileDeps, deps: FileDeps,
actor: FileLibActor, actor: FileLibActor,
@@ -221,14 +275,21 @@ export async function commitFile(
): Promise<{ readonly version: string }> { ): Promise<{ readonly version: string }> {
const filePath = validateFilePath(input.path); const filePath = validateFilePath(input.path);
const content = decodeContent(input.content, input.encoding ?? "utf8"); const content = decodeContent(input.content, input.encoding ?? "utf8");
checkSize(content); checkSize(content, deps.maxFileBytes ?? resolveMaxFileBytes());
const project = await requireProject(deps, actor, projectId, "EDIT"); const project = await requireProject(deps, actor, projectId, "EDIT");
// 调用方传了非空 message 则用它,否则回退默认文案。
// 空串必须当作没传:`git commit -m ""` 会以 empty commit message 失败。
const trimmedMessage = input.message?.trim();
const message = trimmedMessage === undefined || trimmedMessage === ""
? defaultCommitMessage(actor, filePath)
: trimmedMessage;
const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, { const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, {
baseVersion: input.baseVersion, baseVersion: input.baseVersion,
content, content,
message: input.message, message,
author: actor.userId, // 身份:name=displayName(回退 userId),email=<userId>@域名(git 实现里拼)。
author: { userId: actor.userId, displayName: actor.displayName },
}); });
if (result.status === "conflict") { if (result.status === "conflict") {
@@ -247,7 +308,7 @@ export async function commitFile(
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit, input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project, project,
filePath, filePath,
{ version: result.version, message: input.message ?? null }, { version: result.version, message },
); );
return { version: result.version }; return { version: result.version };
} }
@@ -261,7 +322,10 @@ export async function deleteFile(
): Promise<void> { ): Promise<void> {
const filePath = validateFilePath(rawPath); const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "EDIT"); const project = await requireProject(deps, actor, projectId, "EDIT");
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion); const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion, {
userId: actor.userId,
displayName: actor.displayName,
});
if (result.status === "conflict") { if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, { await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion, baseVersion,
+443
View File
@@ -0,0 +1,443 @@
/**
* VersionStore(契约 C1)的真 git 实现 —— ADR-0030。
*
* 一个项目一个 git 仓库,位于 <storageRoot>/<nodeId>(nodeId 是 uuid;
* 文件夹不落盘,见 ADR-0030 Context)。VersionId = commit hash。
*
* D16 文件级版本的映射(ADR-0030 Decision):一次写只碰一个路径、只产生一个
* commit;某文件的版本 = `git log -1 -- <path>` 的 hash。因此 a.md 的提交不出现在
* b.md 的 log 里,两者 baseVersion 互不失效 —— 尽管 commit 本身是仓库级对象。
*
* 不引 npm 依赖:三条 execFile 就够,见 ADR-0030 Alternatives。
*/
import { execFile } from "node:child_process";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { FileLibError } from "./model.js";
import type {
CommitAuthor,
CommitRequest,
CommitResult,
FileEntry,
ProjectCommitInfo,
VersionId,
VersionInfo,
VersionStore,
} from "./versionStore.js";
/** 无 author 时的固定身份(ADR-0030 Consequences:不再是 undefined)。 */
const FALLBACK_AUTHOR = "filelib";
/** 提交者 email 域名:`<userId>@filelib.paradigm-edu.net`。 */
const AUTHOR_EMAIL_DOMAIN = "filelib.paradigm-edu.net";
/**
* 每次调用都带的加固(ADR-0030 Decision)。项目仓库是老师上传的**数据**,
* 不是可信代码:hooks 必须禁用,全局/系统 gitconfig 必须隔离,否则仓库内容
* 或开发者机器上的配置就能改变服务端行为。
*/
const HARDENING_ARGS = ["-c", "core.hooksPath=", "-c", "commit.gpgsign=false"] as const;
/**
* 仓库定位参数。**这两个不能省**:git 默认会沿目录树**向上**找 `.git`,
* 而 storage root 很可能就在另一个 git 仓库里(本地开发的默认值
* `hub/.filelib-repos` 就在本 repo 内)。不钉死的后果是项目目录没自己的 `.git`
* 时,所有命令默默落到**外层仓库**上 —— 轻则 `git add` 报 ignored,
* 重则把老师的文件提交进源码仓。
*/
function repoArgs(projectDir: string): readonly string[] {
return [`--git-dir=${path.join(projectDir, ".git")}`, `--work-tree=${projectDir}`];
}
const HARDENING_ENV = {
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
// 文件名永远不被重解释为 pathspec magic(`:(glob)` 等)。
GIT_LITERAL_PATHSPECS: "1",
// 仓库不得因为凭据提示卡住一个 HTTP 请求。
GIT_TERMINAL_PROMPT: "0",
} as const;
/**
* 继承来的这几个会劫持全部命令(比如 hub 自身被一个 git hook 启动时),
* 必须从子进程 env 里**删掉**而不是置空 —— 置空在 git 里的含义并不统一。
* 我们只认 repoArgs 里显式传的那一份。
*/
const STRIPPED_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY"] as const;
function childEnv(extra: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, ...HARDENING_ENV, ...extra };
for (const key of STRIPPED_ENV) delete env[key];
return env;
}
interface GitResult {
readonly stdout: Buffer;
readonly code: number;
readonly stderr: string;
}
/** execFile 的 args 数组形式:不拼 shell,文件名不参与命令解析。 */
function runGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, [...repoArgs(cwd), ...args], env);
}
/**
* 不钉 --git-dir 的调用。**只给 `git init` 用** —— 那一刻 `.git` 尚不存在,
* 钉上去 git 会直接报错。init 自己总是在 cwd 建仓,不会向上找。
*/
function runGitBare(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, args, env);
}
function execGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>>,
): Promise<GitResult> {
return new Promise((resolve, reject) => {
execFile(
"git",
[...HARDENING_ARGS, ...args],
{
cwd,
encoding: "buffer",
env: childEnv(env),
maxBuffer: 64 * 1024 * 1024,
windowsHide: true,
},
(error, stdout, stderr) => {
const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(String(stdout));
const err = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr);
if (error === null) {
resolve({ stdout: out, code: 0, stderr: err });
return;
}
const code = (error as NodeJS.ErrnoException & { code?: number | string }).code;
if (code === "ENOENT") {
// 坑:spawn 的 ENOENT 有两种来源且**报错完全一样**(都是 path:"git"、
// syscall:"spawn git") —— git 真的不在 PATH 上,或者 cwd 目录不存在。
// 后者在这里是常态(DB 里有 storageDir、磁盘上却没建过,比如内存 store
// 时代留下的旧项目),必须报 repo_not_found 而不是冤枉 git 没装。
void access(cwd).then(
() => reject(new FileLibError(500, "git_missing", "git executable not found on PATH")),
() => reject(new FileLibError(404, "repo_not_found", `repository directory missing: ${cwd}`)),
);
return;
}
// 非零退出是常规控制流(文件不存在、空仓库等),交给调用点判断。
resolve({ stdout: out, code: typeof code === "number" ? code : 1, stderr: err });
},
);
});
}
async function requireGit(cwd: string, args: readonly string[], env?: Record<string, string>): Promise<Buffer> {
const res = await runGit(cwd, args, env);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
async function requireGitBare(cwd: string, args: readonly string[]): Promise<Buffer> {
const res = await runGitBare(cwd, args);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
/**
* S4:同仓库写操作串行化。git 的并发写会在 index.lock 上打架,
* 串行化把它变成干净的 conflict 返回值而不是锁错误。仅进程内有效(ADR-0030)。
*/
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
const tails = new Map<string, Promise<unknown>>();
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const prev = tails.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
tails.set(key, next.catch(() => undefined));
return next;
};
}
/**
* 仓库内相对路径的再校验。fileService.validateFilePath 已经把过一遍,
* 但 ADR-0030 把这条从卫生升级为安全边界 —— 本层不信调用方。
*/
function safeRelPath(filePath: string): string {
const normalized = filePath.normalize("NFC");
if (normalized === "" || path.isAbsolute(normalized) || normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
const segments = normalized.split("/");
for (const segment of segments) {
if (segment === "" || segment === "." || segment === ".." || segment === ".git") {
throw new FileLibError(400, "invalid_path", `unsafe path segment in: ${filePath}`);
}
}
// 解析后必须仍在仓库内(符号链接由 git 自身不跟随 + 此处前缀检查共同兜住)。
const resolved = path.posix.normalize(normalized);
if (resolved.startsWith("..") || path.isAbsolute(resolved)) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
return resolved;
}
/**
* 提交者身份 → git author/committer。
* name = displayName(缺失回退 userId);email = `<userId>@filelib.paradigm-edu.net`。
* email 用 userId 而不用 displayName:昵称会改,身份追溯不能跟着漂。
* name 里的换行/`<`/`>` 必须清掉 —— 它们会破坏 git 的 ident 行格式。
*/
function authorEnv(author: CommitAuthor | undefined): Record<string, string> {
const rawName = author?.displayName;
const fallback = author?.userId ?? FALLBACK_AUTHOR;
const name = (rawName === undefined || rawName.trim() === "" ? fallback : rawName.trim())
.replace(/[<>\n\r]/g, " ")
.trim();
const localPart = (author?.userId ?? FALLBACK_AUTHOR).replace(/[^\w.-]/g, "_");
const email = `${localPart}@${AUTHOR_EMAIL_DOMAIN}`;
return {
GIT_AUTHOR_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_AUTHOR_EMAIL: email,
GIT_COMMITTER_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_COMMITTER_EMAIL: email,
};
}
export function createGitVersionStore(): VersionStore {
const serialize = createKeySerializer();
/**
* 未 init → repo_not_found。
* 不能用 `git rev-parse --git-dir`:repoArgs 已把 --git-dir 钉死,rev-parse 会
* 原样回显它而不验证存在;而不钉死时它又会向上找到外层仓库。所以直接
* 测文件系统:项目目录里必须有属于它自己的 `.git`。
*/
async function requireRepo(projectDir: string): Promise<void> {
try {
await access(path.join(projectDir, ".git"));
} catch {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
}
/** 该路径在 HEAD 上的当前版本;不存在(或从未提交)→ null。 */
async function currentVersion(projectDir: string, filePath: string): Promise<VersionId | null> {
const exists = await runGit(projectDir, ["cat-file", "-e", `HEAD:${filePath}`]);
if (exists.code !== 0) return null; // 空仓库、已删除、或从无此文件
const log = await runGit(projectDir, ["log", "-1", "--format=%H", "--", filePath]);
if (log.code !== 0) return null;
const hash = log.stdout.toString("utf8").trim();
return hash === "" ? null : hash;
}
async function headVersion(projectDir: string, filePath: string): Promise<VersionId> {
const version = await currentVersion(projectDir, filePath);
if (version === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return version;
}
/** commit id 必须存在,否则 version_not_found(而非把 git 错误透出去)。 */
async function requireCommit(projectDir: string, version: VersionId): Promise<void> {
const res = await runGit(projectDir, ["rev-parse", "--verify", "--quiet", `${version}^{commit}`]);
if (res.code !== 0) {
throw new FileLibError(404, "version_not_found", `version not found: ${version}`);
}
}
/** 提交暂存区里已备好的单个路径。返回新 commit hash。 */
async function commitPath(
projectDir: string,
filePath: string,
message: string,
author: CommitAuthor | undefined,
): Promise<VersionId> {
const env = authorEnv(author);
await requireGit(projectDir, ["commit", "--quiet", "--allow-empty", "-m", message, "--", filePath], env);
const hash = await requireGit(projectDir, ["rev-parse", "HEAD"]);
return hash.toString("utf8").trim();
}
return {
/** S7 幂等:已有仓库就不重建。 */
async init(projectDir) {
await serialize(projectDir, async () => {
await mkdir(projectDir, { recursive: true });
try {
await access(path.join(projectDir, ".git"));
return; // 已是仓库,不清空
} catch { /* 继续 init */ }
// init 用 runGitBare:此时 .git 尚不存在,钉 --git-dir 反而会让 git 报错。
await requireGitBare(projectDir, ["init", "--quiet"]);
// 默认分支名不依赖宿主 git 版本/配置(全局配置已被隔离)。
await requireGit(projectDir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
});
},
async list(projectDir, prefix) {
await requireRepo(projectDir);
const res = await runGit(projectDir, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
if (res.code !== 0) return []; // 空仓库(无 HEAD)
const out: FileEntry[] = [];
for (const record of res.stdout.toString("utf8").split("\0")) {
if (record === "") continue;
// 形如:"<mode> <type> <object> <size>\t<path>"
const tab = record.indexOf("\t");
if (tab === -1) continue;
const meta = record.slice(0, tab).split(/\s+/);
const entryPath = record.slice(tab + 1);
if (meta[1] !== "blob") continue;
if (prefix !== undefined && !entryPath.startsWith(prefix)) continue;
out.push({ path: entryPath, size: Number.parseInt(meta[3] ?? "0", 10) || 0 });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
await requireRepo(projectDir);
return headVersion(projectDir, safeRelPath(filePath));
},
async read(projectDir, filePath, at) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
if (at === undefined) {
await headVersion(projectDir, rel); // 存在性 → 404 file_not_found
const res = await runGit(projectDir, ["show", `HEAD:${rel}`]);
if (res.code !== 0) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
return res.stdout;
}
await requireCommit(projectDir, at);
const res = await runGit(projectDir, ["show", `${at}:${rel}`]);
if (res.code !== 0) {
// commit 存在但该版本里没有这个路径。
throw new FileLibError(404, "version_not_found", `version not found: ${rel}@${at}`);
}
return res.stdout;
},
async commit(projectDir, filePath, req: CommitRequest): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
// S2:baseVersion=null 表新建,已存在即 conflict;
// S1:否则要求 baseVersion 精确等于当前版本。
if (req.baseVersion === null) {
if (current !== null) return { status: "conflict", currentVersion: current };
} else if (req.baseVersion !== current) {
return { status: "conflict", currentVersion: current ?? req.baseVersion };
}
const abs = path.join(projectDir, rel);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, req.content);
await requireGit(projectDir, ["add", "--", rel]);
const message = req.message ?? (req.baseVersion === null ? `create ${rel}` : `update ${rel}`);
const version = await commitPath(projectDir, rel, message, req.author);
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion, author): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
if (current === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
if (baseVersion !== current) return { status: "conflict", currentVersion: current };
await requireGit(projectDir, ["rm", "--quiet", "--", rel]);
const version = await commitPath(projectDir, rel, `remove ${rel}`, author);
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
await requireCommit(projectDir, from);
await requireCommit(projectDir, to);
const res = await runGit(projectDir, ["diff", from, to, "--", rel]);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git diff failed: ${res.stderr.trim()}`);
}
return res.stdout.toString("utf8");
},
async history(projectDir, filePath, limit) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
const args = ["log", "--format=%H%x1f%an%x1f%aI%x1f%s%x1e"];
if (limit !== undefined) args.push(`-${limit}`);
args.push("--", rel);
const res = await runGit(projectDir, args);
if (res.code !== 0) return []; // 空仓库
const out: VersionInfo[] = [];
for (const record of res.stdout.toString("utf8").split("\x1e")) {
const line = record.trim();
if (line === "") continue;
const [version, author, committedAt, message] = line.split("\x1f");
if (version === undefined) continue;
out.push({
version,
message: message ?? "",
author: author === undefined || author === "" ? undefined : author,
committedAt: committedAt ?? "",
});
}
return out; // git log 已是新→旧
},
async projectHistory(projectDir, limit) {
await requireRepo(projectDir);
// %x1e 放在 format 开头作为每条记录的分隔符;--name-only 的文件列表跟在 format 行之后。
const args = ["log", "--format=%x1e%H%x1f%an%x1f%aI%x1f%s", "--name-only"];
if (limit !== undefined) args.push(`-${limit}`);
const res = await runGit(projectDir, args);
if (res.code !== 0) return []; // 空仓库
const out: ProjectCommitInfo[] = [];
// split 按 \x1e 分块,第一个空块跳过。
for (const block of res.stdout.toString("utf8").split("\x1e")) {
const trimmed = block.trim();
if (trimmed === "") continue;
const lines = trimmed.split("\n");
const header = lines[0];
if (header === undefined) continue;
const [version, author, committedAt, message] = header.split("\x1f");
if (version === undefined) continue;
// header 之后的非空行是受影响的文件路径。
// git 对含特殊字符的路径加双引号,去掉外层引号即可。
const files = lines.slice(1)
.map((l) => l.trim())
.filter((l) => l !== "")
.map((l) => (l.startsWith('"') && l.endsWith('"') ? l.slice(1, -1) : l));
out.push({
version,
message: message ?? "",
author: author === undefined || author === "" ? undefined : author,
committedAt: committedAt ?? "",
files,
});
}
return out;
},
};
}
+41 -4
View File
@@ -25,16 +25,24 @@ export interface GrantDto {
readonly id: string; readonly id: string;
readonly principalType: "USER" | "GROUP"; readonly principalType: "USER" | "GROUP";
readonly principalId: string; readonly principalId: string;
/**
* 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
* USER → `User.displayName`;GROUP → `MemberGroup.name`;
* 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
* 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
*/
readonly principalName: string;
readonly role: FileLibRole; readonly role: FileLibRole;
readonly isCreatorGrant: boolean; readonly isCreatorGrant: boolean;
readonly createdAt: Date; readonly createdAt: Date;
} }
function toDto(grant: FileLibGrant): GrantDto { function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
return { return {
id: grant.id, id: grant.id,
principalType: grant.principalType, principalType: grant.principalType,
principalId: grant.principalId, principalId: grant.principalId,
principalName: principalName ?? grant.principalId,
role: grant.role, role: grant.role,
isCreatorGrant: grant.isCreatorGrant, isCreatorGrant: grant.isCreatorGrant,
createdAt: grant.createdAt, createdAt: grant.createdAt,
@@ -44,6 +52,35 @@ function toDto(grant: FileLibGrant): GrantDto {
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
type Deps = AccessDeps & { readonly prisma: PrismaClient }; type Deps = AccessDeps & { readonly prisma: PrismaClient };
/**
* 批量解析 principal 展示名(两条 IN 查询,不做 N+1)。
* 组不按 archivedAt 过滤:已归档组的历史授权仍要能显示出名字来给管理员收回。
*/
async function resolvePrincipalNames(
tx: Tx | PrismaClient,
grants: readonly FileLibGrant[],
): Promise<ReadonlyMap<string, string>> {
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
const [users, groups] = await Promise.all([
userIds.length === 0
? Promise.resolve([])
: tx.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }),
groupIds.length === 0
? Promise.resolve([])
: tx.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }),
]);
const names = new Map<string, string>();
for (const u of users) names.set(`USER:${u.id}`, u.displayName);
for (const g of groups) names.set(`GROUP:${g.id}`, g.name);
return names;
}
async function toDtosWithNames(tx: Tx | PrismaClient, grants: readonly FileLibGrant[]): Promise<readonly GrantDto[]> {
const names = await resolvePrincipalNames(tx, grants);
return grants.map((g) => toDto(g, names.get(`${g.principalType}:${g.principalId}`)));
}
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */ /** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
async function requireManage( async function requireManage(
deps: Deps, deps: Deps,
@@ -66,7 +103,7 @@ export async function listGrants(
where: { organizationId: deps.organizationId, nodeId, revokedAt: null }, where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
}); });
return grants.map(toDto); return toDtosWithNames(deps.prisma, grants);
} }
export interface PutGrantsResult { export interface PutGrantsResult {
@@ -137,7 +174,7 @@ export async function putGrants(
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
}); });
return { granted, updated, grants: grants.map(toDto) }; return { granted, updated, grants: await toDtosWithNames(tx, grants) };
}); });
} }
@@ -236,7 +273,7 @@ export async function forceAdjustGrants(
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
}); });
return { granted, updated, grants: grants.map(toDto) }; return { granted, updated, grants: await toDtosWithNames(tx, grants) };
}); });
} }
+2
View File
@@ -43,5 +43,7 @@ export async function requireFileLibActor(
return { return {
userId: auth.user.id, userId: auth.user.id,
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role), isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
displayName: auth.user.displayName,
}; };
} }
+2
View File
@@ -21,6 +21,8 @@ export interface FileLibRouteDeps {
readonly groupResolver: GroupResolver; readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore; readonly versionStore: VersionStore;
readonly exportAdapters: readonly ExportAdapter[]; readonly exportAdapters: readonly ExportAdapter[];
/** 单文件字节上限(`HUB_FILELIB_MAX_FILE_BYTES`)。 */
readonly maxFileBytes: number;
} }
/** 组装 treeService 依赖(路由处理内直接使用)。 */ /** 组装 treeService 依赖(路由处理内直接使用)。 */
+8
View File
@@ -35,6 +35,11 @@ export interface FileLibActor {
readonly userId: string; readonly userId: string;
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */ /** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
readonly isWebsiteAdmin: boolean; readonly isWebsiteAdmin: boolean;
/**
* 展示名(飞书昵称)。只用于生成 commit message 的【用户名】部分;
* 权限判定一律用 userId。缺失时回退到 userId。
*/
readonly displayName?: string | undefined;
} }
export interface TreeServiceDeps { export interface TreeServiceDeps {
@@ -229,6 +234,8 @@ export async function createNode(
} }
if (input.kind === "PROJECT") { if (input.kind === "PROJECT") {
// ADR-0030:项目扁平居于同一根下、以 uuid 命名;名字不进路径(所以 rename
// 不动磁盘)。FOLDER 永不赋值 —— 文件夹不落盘,只存在于 DB 的 parentId/pathIds。
storageDir = path.join(deps.storageRoot, id); storageDir = path.join(deps.storageRoot, id);
} }
@@ -308,6 +315,7 @@ export async function createNode(
}); });
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。 // provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
// ADR-0030:这一步真的建目录并 `git init`;宿主无 git 则此处 provision_failed。
if (input.kind === "PROJECT" && storageDir !== null) { if (input.kind === "PROJECT" && storageDir !== null) {
try { try {
await deps.versionStore.init(storageDir); await deps.versionStore.init(storageDir);
+64 -9
View File
@@ -1,12 +1,14 @@
/** /**
* VersionStore port(契约 C1)+ 开发用内存实现。 * VersionStore port(契约 C1)+ **仅测试用**的内存实现。
* *
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。 * 生产实现是 `gitVersionStore.ts`(一项目一 git 仓库,VersionId = commit hash),
* 见 ADR-0030。本文件留下来只为让不关心版本落盘的测试快速起个 store;它的
* VersionId 是每仓库计数器(`v1`/`v2`),与生产**不同形**,不要据此写断言。
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、 * 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。 * init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
* *
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期 * 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复。ADR-0030 之后
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与 * 已无生产调用点 —— 生产走 git,不再有这份进程级 JSON 快照
*/ */
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -15,12 +17,21 @@ import { FileLibError } from "./model.js";
export type VersionId = string; export type VersionId = string;
/**
* 提交者身份。`userId` 是稳定主键(追溯用),`displayName` 只影响展示。
* git 实现把它们映射成 `name <userId@域名>`(ADR-0030)。
*/
export interface CommitAuthor {
readonly userId: string;
readonly displayName?: string | undefined;
}
export interface CommitRequest { export interface CommitRequest {
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */ /** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
readonly baseVersion: VersionId | null; readonly baseVersion: VersionId | null;
readonly content: string | Buffer; readonly content: string | Buffer;
readonly message?: string | undefined; readonly message?: string | undefined;
readonly author?: string | undefined; readonly author?: CommitAuthor | undefined;
} }
export type CommitResult = export type CommitResult =
@@ -34,6 +45,16 @@ export interface VersionInfo {
readonly committedAt: string; readonly committedAt: string;
} }
/** 项目级提交历史条目(包含受影响文件路径)。 */
export interface ProjectCommitInfo {
readonly version: VersionId;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
/** 本次提交修改的文件路径列表。 */
readonly files: readonly string[];
}
export interface FileEntry { export interface FileEntry {
readonly path: string; readonly path: string;
readonly size: number; readonly size: number;
@@ -45,9 +66,17 @@ export interface VersionStore {
head(projectDir: string, filePath: string): Promise<VersionId>; head(projectDir: string, filePath: string): Promise<VersionId>;
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>; read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>; commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>; /** 删除也是一次提交,所以同样带提交者身份。 */
remove(
projectDir: string,
filePath: string,
baseVersion: VersionId,
author?: CommitAuthor,
): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>; diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>; history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
/** 项目级提交历史(所有文件),新→旧排列。 */
projectHistory(projectDir: string, limit?: number): Promise<ProjectCommitInfo[]>;
} }
interface StoredVersion { interface StoredVersion {
@@ -80,6 +109,13 @@ function toBuffer(content: string | Buffer): Buffer {
return typeof content === "string" ? Buffer.from(content, "utf8") : content; return typeof content === "string" ? Buffer.from(content, "utf8") : content;
} }
/** VersionInfo.author 是展示字符串;语义与 git 实现对齐(取 displayName,回退 userId)。 */
function authorLabel(author: CommitAuthor | undefined): string | undefined {
if (author === undefined) return undefined;
const name = author.displayName;
return name === undefined || name.trim() === "" ? author.userId : name.trim();
}
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */ /** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
function naiveDiff(fromText: string, toText: string): string { function naiveDiff(fromText: string, toText: string): string {
const a = fromText.split("\n"); const a = fromText.split("\n");
@@ -221,7 +257,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
version, version,
content: toBuffer(req.content), content: toBuffer(req.content),
message: req.message ?? `commit ${version}`, message: req.message ?? `commit ${version}`,
author: req.author, author: authorLabel(req.author),
committedAt: new Date().toISOString(), committedAt: new Date().toISOString(),
deleted: false, deleted: false,
}); });
@@ -231,7 +267,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
}); });
}, },
async remove(projectDir, filePath, baseVersion) { async remove(projectDir, filePath, baseVersion, author) {
return serialize(projectDir, async (): Promise<CommitResult> => { return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir); const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? []; const chain = repo.files.get(filePath) ?? [];
@@ -247,7 +283,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
version, version,
content: Buffer.alloc(0), content: Buffer.alloc(0),
message: `remove ${filePath}`, message: `remove ${filePath}`,
author: undefined, author: authorLabel(author),
committedAt: new Date().toISOString(), committedAt: new Date().toISOString(),
deleted: true, deleted: true,
}); });
@@ -276,5 +312,24 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
const ordered = infos.reverse(); const ordered = infos.reverse();
return limit !== undefined ? ordered.slice(0, limit) : ordered; return limit !== undefined ? ordered.slice(0, limit) : ordered;
}, },
async projectHistory(projectDir, limit) {
const repo = requireRepo(projectDir);
// 汇集所有文件的所有版本,按提交时间新→旧排序。
const all: ProjectCommitInfo[] = [];
for (const [filePath, chain] of repo.files) {
for (const v of chain) {
all.push({
version: v.version,
message: v.message,
author: v.author,
committedAt: v.committedAt,
files: [filePath],
});
}
}
all.sort((a, b) => b.committedAt.localeCompare(a.committedAt));
return limit !== undefined ? all.slice(0, limit) : all;
},
}; };
} }
+10 -5
View File
@@ -29,10 +29,11 @@ import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
import { registerTeacherApp } from "./teacherApp.js"; import { registerTeacherApp } from "./teacherApp.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createGitVersionStore } from "../filelib/gitVersionStore.js";
import { resolveMaxFileBytes } from "../filelib/fileService.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js"; import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
import { createManifestStubAdapter } from "../filelib/exportService.js"; import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js"; import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
import type { FileLibRouteDeps } from "../filelib/routeShared.js"; import type { FileLibRouteDeps } from "../filelib/routeShared.js";
@@ -60,6 +61,8 @@ export async function registerDatabaseRoutes(
app.get("/database/config", async () => ({ app.get("/database/config", async () => ({
orgSlug: config.siloOrganizationSlug, orgSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass, devLoginEnabled: config.allowDevLoginBypass,
// 上传上限由后端下发,前端不再写死 —— 两处各自硬编码会随配置漂。
maxFileBytes: resolveMaxFileBytes(),
})); }));
// 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的 // 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
@@ -132,7 +135,8 @@ export async function registerDatabaseRoutes(
} }
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。 // 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换); // 依赖装配:VersionStore 是真 git —— 一项目一仓库 <storageRoot>/<nodeId>,
// VersionId = commit hash(ADR-0030);
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028), // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); // HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。 // 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
@@ -145,7 +149,7 @@ export async function registerDatabaseRoutes(
return; return;
} }
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos"); const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json")); const versionStore = createGitVersionStore();
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"]; const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
const filelibDeps: FileLibRouteDeps = { const filelibDeps: FileLibRouteDeps = {
prisma: config.prisma, prisma: config.prisma,
@@ -156,7 +160,8 @@ export async function registerDatabaseRoutes(
? createMemberGroupResolver(config.prisma) ? createMemberGroupResolver(config.prisma)
: createHttpGroupResolver({ baseUrl: groupServiceUrl }), : createHttpGroupResolver({ baseUrl: groupServiceUrl }),
versionStore, versionStore,
exportAdapters: [createManifestStubAdapter(versionStore)], exportAdapters: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)],
maxFileBytes: resolveMaxFileBytes(),
}; };
await registerFileLibRoutes(app, filelibDeps); await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps);
+23 -1
View File
@@ -10,6 +10,7 @@ import {
diffFile, diffFile,
fileHistory, fileHistory,
listFiles, listFiles,
projectHistory,
readFile, readFile,
readFileRaw, readFileRaw,
type FileContentEncoding, type FileContentEncoding,
@@ -39,6 +40,7 @@ export async function registerFileRoutes(
organizationId: deps.organizationId, organizationId: deps.organizationId,
groupResolver: deps.groupResolver, groupResolver: deps.groupResolver,
versionStore: deps.versionStore, versionStore: deps.versionStore,
maxFileBytes: deps.maxFileBytes,
}; };
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters }; const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
@@ -182,6 +184,22 @@ export async function registerFileRoutes(
} }
}); });
app.get("/database/api/projects/:id/history", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const rawLimit = (request.query as { limit?: string }).limit;
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
}
return { history: await projectHistory(fileDeps, actor, id, limit) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 导出(D10 异步) */ /* ------------------------------------------------------------ 导出(D10 异步) */
app.post("/database/api/projects/:id/exports", async (request, reply) => { app.post("/database/api/projects/:id/exports", async (request, reply) => {
@@ -224,9 +242,13 @@ export async function registerFileRoutes(
try { try {
const { jobId } = request.params as { jobId: string }; const { jobId } = request.params as { jobId: string };
const artifact = await downloadExport(exportDeps, actor, jobId); const artifact = await downloadExport(exportDeps, actor, jobId);
// PDF 报真实 MIME,浏览器才能内联预览/正确命名;其余产物保守为二进制流。
const contentType = artifact.filename.toLowerCase().endsWith(".pdf")
? "application/pdf"
: "application/octet-stream";
return reply return reply
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`) .header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
.type("application/octet-stream") .type(contentType)
.send(artifact.content); .send(artifact.content);
} catch (error) { } catch (error) {
return sendRouteError(reply, error); return sendRouteError(reply, error);
@@ -54,6 +54,7 @@ beforeEach(async () => {
groupResolver: createStaticGroupResolver({}), groupResolver: createStaticGroupResolver({}),
versionStore, versionStore,
exportAdapters: [createManifestStubAdapter(versionStore)], exportAdapters: [createManifestStubAdapter(versionStore)],
maxFileBytes: 10 * 1024 * 1024,
}; };
app = Fastify({ logger: false }); app = Fastify({ logger: false });
await app.register(fastifyCookie); await app.register(fastifyCookie);
@@ -149,6 +150,42 @@ describe("filelib http · 8.1 授权矩阵", () => {
expect(revoke.statusCode).toBe(403); expect(revoke.statusCode).toBe(403);
expect(revoke.json().error.code).toBe("cannot_touch_creator"); expect(revoke.json().error.code).toBe("cannot_touch_creator");
}); });
it("grants 返回 principalName:USER→displayName / GROUP→组名;取不到行时回落为 id", async () => {
const rootId = await createRoot(ADMIN_COOKIE());
const group = await prisma.memberGroup.create({ data: { name: "物理组" } });
const put = await app.inject({
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
headers: { cookie: ADMIN_COOKIE() },
payload: {
grants: [
{ principalType: "USER", principalId: "u_alice", role: "EDIT" },
{ principalType: "GROUP", principalId: group.id, role: "VIEW" },
// 数据库里没有对应 User 行(已删/脏数据)→ 回落为 principalId
{ principalType: "USER", principalId: "u_ghost", role: "VIEW" },
],
},
});
expect(put.statusCode).toBe(200);
const list = await app.inject({
method: "GET", url: `/database/api/nodes/${rootId}/grants`,
headers: { cookie: ADMIN_COOKIE() },
});
expect(list.statusCode).toBe(200);
const byPrincipal = new Map<string, string>(
list.json().grants.map((g: { principalId: string; principalName: string }) => [g.principalId, g.principalName]),
);
expect(byPrincipal.get("u_alice")).toBe("Alice");
expect(byPrincipal.get(group.id)).toBe("物理组");
expect(byPrincipal.get("u_ghost")).toBe("u_ghost");
// 创建者授权也要解析出名字
const creator = list.json().grants.find((g: { isCreatorGrant: boolean }) => g.isCreatorGrant);
expect(creator.principalName).toBe("Admin");
// PUT 响应与 GET 同形状
expect(put.json().grants.every((g: { principalName?: string }) => typeof g.principalName === "string")).toBe(true);
});
}); });
describe("filelib http · 文件冲突流", () => { describe("filelib http · 文件冲突流", () => {
+73
View File
@@ -0,0 +1,73 @@
/**
* 单文件上限的环境变量解析 + commit message 默认文案。
*
* 上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB;非法值按缺省 ——
* 配置写错不该让上传静默变成 0 上限(那会把每次上传都拒掉)。
* 文案:`【用户名】修改了【路径】`,用户名取 displayName、缺失回退 userId。
*/
import { describe, expect, it } from "vitest";
import {
FILE_CONTENT_MAX_BYTES_DEFAULT,
defaultCommitMessage,
parseMaxFileBytes,
} from "../../src/database/filelib/fileService.js";
describe("parseMaxFileBytes", () => {
it("缺省 10MiB", () => {
expect(FILE_CONTENT_MAX_BYTES_DEFAULT).toBe(10 * 1024 * 1024);
expect(parseMaxFileBytes(undefined)).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
expect(parseMaxFileBytes("")).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
expect(parseMaxFileBytes(" ")).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
});
it("合法正整数生效(可上调也可下调)", () => {
expect(parseMaxFileBytes("1048576")).toBe(1024 * 1024);
expect(parseMaxFileBytes(" 52428800 ")).toBe(50 * 1024 * 1024);
expect(parseMaxFileBytes("1")).toBe(1);
});
it("非法值一律回退缺省,不产生 0 或负数上限", () => {
for (const bad of ["0", "-1", "abc", "1.5", "NaN", "Infinity", "1e999", "10MB"]) {
expect(parseMaxFileBytes(bad)).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
}
});
});
describe("defaultCommitMessage", () => {
it("【用户名】修改了【路径】,用户名取 displayName", () => {
expect(defaultCommitMessage({ userId: "u1", isWebsiteAdmin: false, displayName: "张老师" }, "讲义/第一课.md"))
.toBe("【张老师】修改了【讲义/第一课.md】");
});
it("displayName 缺失/空白时回退 userId", () => {
expect(defaultCommitMessage({ userId: "u_alice", isWebsiteAdmin: false }, "a.md"))
.toBe("【u_alice】修改了【a.md】");
expect(defaultCommitMessage({ userId: "u_alice", isWebsiteAdmin: false, displayName: " " }, "a.md"))
.toBe("【u_alice】修改了【a.md】");
});
});
/**
* message 优先级的纯函数复刻(commitFile 里的那三行):
* 非空则用调用方的,否则回退默认文案。空串必须算“没传” ——
* `git commit -m ""` 会以 empty commit message 失败。
*/
function resolveMessage(input: string | undefined, fallback: string): string {
const trimmed = input?.trim();
return trimmed === undefined || trimmed === "" ? fallback : trimmed;
}
describe("commit message 优先级", () => {
const fallback = "【张老师】修改了【a.md】";
it("手填了就用手填的", () => {
expect(resolveMessage("补上第三课的课件", fallback)).toBe("补上第三课的课件");
expect(resolveMessage(" 前后有空格 ", fallback)).toBe("前后有空格");
});
it("未传/空/约空白一律回退默认文案", () => {
for (const raw of [undefined, "", " ", "\n\t"]) {
expect(resolveMessage(raw, fallback)).toBe(fallback);
}
});
});
@@ -0,0 +1,303 @@
/**
* 真 git VersionStore 语义单测(ADR-0030)。
*
* 与 filelib-version-store.test.ts(内存实现)逐条对齐同一份 C1 语义:
* S1/S2 冲突走返回值、S7 init 幂等、D16 文件级版本互不影响、S4 同仓库写串行。
* 差别只在 VersionId 是 commit hash 而非计数器 —— 断言因此不写死字面量。
*
* 每个用例一个真临时仓库,跑真 git;没有 mock。
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
import { createGitVersionStore } from "../../src/database/filelib/gitVersionStore.js";
import { FileLibError } from "../../src/database/filelib/model.js";
const HEX40 = /^[0-9a-f]{40}$/;
let root: string;
let dir: string;
const store = createGitVersionStore();
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "filelib-git-"));
dir = path.join(root, "11111111-2222-3333-4444-555555555555");
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
/** 取 ok 版本号;conflict 直接让用例失败(比 as 断言更早暴露问题)。 */
function okVersion(result: { status: string; version?: string }): string {
expect(result.status).toBe("ok");
return result.version as string;
}
describe("GitVersionStore · C1 语义(ADR-0030)", () => {
it("init 建目录 + git init;幂等(S7);未 init → repo_not_found", async () => {
await expect(store.head(dir, "a.md")).rejects.toThrowError(FileLibError);
await store.init(dir);
expect(existsSync(path.join(dir, ".git"))).toBe(true);
await store.init(dir); // 幂等
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "hello" }));
await store.init(dir); // 已有内容后再 init 不清空
expect(await store.head(dir, "a.md")).toBe(v1);
expect((await store.read(dir, "a.md")).toString()).toBe("hello");
});
it("VersionId 是 40 位 commit hash,且真的落到磁盘工作区", async () => {
await store.init(dir);
const v = okVersion(await store.commit(dir, "docs/a.md", { baseVersion: null, content: "内容" }));
expect(v).toMatch(HEX40);
// 工作区里是一个真文件(不只是 git 对象)。
expect(await readFile(path.join(dir, "docs/a.md"), "utf8")).toBe("内容");
});
it("对已存在文件再次『新建』 → conflict(S2)", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
expect(await store.commit(dir, "a.md", { baseVersion: null, content: "2" })).toEqual({
status: "conflict",
currentVersion: v1,
});
});
it("baseVersion 落后于当前 → conflict 并带 currentVersion(S1)", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "2" }));
expect(await store.commit(dir, "a.md", { baseVersion: v1, content: "3" })).toEqual({
status: "conflict",
currentVersion: v2,
});
});
it("旧版本仍可按 hash 读回", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "一" }));
await store.commit(dir, "a.md", { baseVersion: v1, content: "二" });
expect((await store.read(dir, "a.md", v1)).toString()).toBe("一");
expect((await store.read(dir, "a.md")).toString()).toBe("二");
});
it("D16 文件级版本:a.md 的提交不推进 b.md 的 base", async () => {
await store.init(dir);
const a1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "A1" }));
const b1 = okVersion(await store.commit(dir, "b.md", { baseVersion: null, content: "B1" }));
await store.commit(dir, "a.md", { baseVersion: a1, content: "A2" }); // 仓库 HEAD 前进
// b.md 仍以自己的版本为 base —— 这正是 commit 级 hash 做文件级版本的关键。
expect((await store.commit(dir, "b.md", { baseVersion: b1, content: "B2" })).status).toBe("ok");
expect(await store.head(dir, "b.md")).not.toBe(b1);
});
it("二进制内容原样往返(NUL 字节不被破坏)", async () => {
await store.init(dir);
const bytes = Buffer.from([0x00, 0x01, 0xff, 0x00, 0x7f]);
await store.commit(dir, "blob.bin", { baseVersion: null, content: bytes });
expect(Buffer.compare(await store.read(dir, "blob.bin"), bytes)).toBe(0);
});
it("remove:正确 base → ok;head 随后 404;stale/缺失 base → conflict/404", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "2" }));
expect(await store.remove(dir, "a.md", v1)).toEqual({ status: "conflict", currentVersion: v2 });
const v3 = okVersion(await store.remove(dir, "a.md", v2));
await expect(store.head(dir, "a.md")).rejects.toThrowError(FileLibError);
expect(existsSync(path.join(dir, "a.md"))).toBe(false);
// 删除是一个 commit:旧版本内容仍可取回(ADR-0030)。
expect((await store.read(dir, "a.md", v1)).toString()).toBe("1");
expect(v3).toMatch(HEX40);
await expect(store.remove(dir, "a.md", v3)).rejects.toThrowError(FileLibError);
});
it("history 新→旧,支持 limit,author 取 displayName", async () => {
await store.init(dir);
const v1 = okVersion(
await store.commit(dir, "docs/a.md", {
baseVersion: null,
content: "1",
message: "初版",
author: { userId: "u1", displayName: "张老师" },
}),
);
await store.commit(dir, "docs/a.md", {
baseVersion: v1,
content: "2",
message: "二版",
author: { userId: "u2", displayName: "李老师" },
});
await store.commit(dir, "other/b.md", { baseVersion: null, content: "x" });
const h = await store.history(dir, "docs/a.md");
expect(h.map((v) => v.message)).toEqual(["二版", "初版"]);
expect(h.map((v) => v.author)).toEqual(["李老师", "张老师"]);
expect(h[0]?.version).toMatch(HEX40);
expect((await store.history(dir, "docs/a.md", 1)).map((v) => v.message)).toEqual(["二版"]);
// other/b.md 的提交不出现在 docs/a.md 的历史里。
expect(h).toHaveLength(2);
});
it("list 排除已删、按前缀过滤、空仓库返回空", async () => {
await store.init(dir);
expect(await store.list(dir)).toEqual([]);
await store.commit(dir, "docs/a.md", { baseVersion: null, content: "1" });
const bv = okVersion(await store.commit(dir, "other/b.md", { baseVersion: null, content: "xy" }));
expect((await store.list(dir)).map((f) => f.path)).toEqual(["docs/a.md", "other/b.md"]);
expect((await store.list(dir, "docs/")).map((f) => f.path)).toEqual(["docs/a.md"]);
expect((await store.list(dir)).find((f) => f.path === "other/b.md")?.size).toBe(2);
await store.remove(dir, "other/b.md", bv);
expect((await store.list(dir)).map((f) => f.path)).toEqual(["docs/a.md"]);
});
it("S4 同仓库写串行:并发同 base 提交,恰好一成一冲突", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
const [r1, r2] = await Promise.all([
store.commit(dir, "a.md", { baseVersion: v1, content: "x" }),
store.commit(dir, "a.md", { baseVersion: v1, content: "y" }),
]);
expect([r1.status, r2.status].sort()).toEqual(["conflict", "ok"]);
});
it("目录不存在 → repo_not_found,不该报 git_missing", async () => {
// 回归:spawn 的 ENOENT 有两种来源且报错一模一样 —— git 不在 PATH 上,或 cwd 不存在。
// 早先把两者一律归为 git_missing,于是“项目目录没建过”会被误报成“git 没装”。
// 内存 store 时代建的旧项目就是这个形态:DB 有 storageDir,磁盘上什么都没有。
const missing = path.join(root, "never-created");
for (const op of [
() => store.head(missing, "a.md"),
() => store.list(missing),
() => store.read(missing, "a.md"),
() => store.commit(missing, "a.md", { baseVersion: null, content: "x" }),
() => store.remove(missing, "a.md", "0".repeat(40)),
() => store.history(missing, "a.md"),
]) {
await expect(op()).rejects.toMatchObject({ code: "repo_not_found", statusCode: 404 });
}
});
it("目录存在但不是 git 仓库 → repo_not_found", async () => {
const { mkdir } = await import("node:fs/promises");
const plain = path.join(root, "plain-dir");
await mkdir(plain, { recursive: true });
await expect(store.head(plain, "a.md")).rejects.toMatchObject({ code: "repo_not_found" });
});
it("仍然能从零开始:init 会把缺失的目录建出来", async () => {
const deep = path.join(root, "a", "b", "c-uuid");
await store.init(deep);
expect((await store.commit(deep, "a.md", { baseVersion: null, content: "1" })).status).toBe("ok");
});
it("提交身份:name=displayName,email=<userId>@filelib.paradigm-edu.net", async () => {
await store.init(dir);
await store.commit(dir, "a.md", {
baseVersion: null,
content: "1",
author: { userId: "u_alice", displayName: "张老师" },
});
const ident = execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae|%cn|%ce"]).toString().trim();
expect(ident).toBe("张老师|u_alice@filelib.paradigm-edu.net|张老师|u_alice@filelib.paradigm-edu.net");
});
it("displayName 缺失时 name 回退 userId,email 仍用 userId", async () => {
await store.init(dir);
await store.commit(dir, "a.md", { baseVersion: null, content: "1", author: { userId: "u_bob" } });
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim())
.toBe("u_bob|u_bob@filelib.paradigm-edu.net");
});
it("displayName 里的 <>/换行不能破坏 git ident 行", async () => {
await store.init(dir);
await store.commit(dir, "a.md", {
baseVersion: null,
content: "1",
author: { userId: "u_x", displayName: "a <evil@e.com>\n换行" },
});
const out = execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim();
const [name, email] = out.split("|");
// 断言性质而不是具体空白(git 自己还会压缩 ident 里的空白)。
expect(name).not.toContain("<");
expect(name).not.toContain(">");
expect(name).not.toContain("\n");
expect(name).toContain("换行");
expect(email).toBe("u_x@filelib.paradigm-edu.net");
});
it("删除提交也带提交者身份", async () => {
await store.init(dir);
const v = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
okVersion(await store.remove(dir, "a.md", v, { userId: "u_del", displayName: "删除者" }));
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim())
.toBe("删除者|u_del@filelib.paradigm-edu.net");
});
it("storage root 在另一个 git 仓库内时,不得落到外层仓库上", async () => {
// 回归:早先用 `git rev-parse --git-dir` 判定“是否已 init”,而 git 会沿目录树
// **向上**找 `.git`。本地开发的默认 storage root 就是 `hub/.filelib-repos`,
// 在本 repo 内 —— 于是未 init 的项目目录被误判为“已是仓库”,后续命令全部
// 落到外层源码仓上(表现为 `git add` 报 “paths are ignored by .gitignore”)。
const outer = path.join(root, "outer");
const { mkdir, writeFile } = await import("node:fs/promises");
await mkdir(outer, { recursive: true });
execFileSync("git", ["-C", outer, "init", "--quiet"]);
await writeFile(path.join(outer, ".gitignore"), "repos/\n");
const inner = path.join(outer, "repos", "proj-uuid");
await mkdir(inner, { recursive: true });
// 未 init 的子目录:必须报 repo_not_found,而不是静默用外层仓库。
await expect(store.head(inner, "a.md")).rejects.toMatchObject({ code: "repo_not_found" });
// init 后的写入必须进自己的仓库,外层仓库保持干净。
await store.init(inner);
okVersion(await store.commit(inner, "a.md", { baseVersion: null, content: "1" }));
expect(existsSync(path.join(inner, ".git"))).toBe(true);
expect(execFileSync("git", ["-C", outer, "status", "--porcelain"]).toString().trim()).toBe("?? .gitignore");
expect(execFileSync("git", ["-C", inner, "log", "--format=%s"]).toString().trim()).toBe("create a.md");
});
it("显式 message 原样落到 commit;不传则用调用方给的默认值", async () => {
await store.init(dir);
okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1", message: "手填的提交信息" }));
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%s"]).toString().trim()).toBe("手填的提交信息");
// 以 `-` 开头的 message 不得被当成选项(execFile 数组形式 + `-m` 传入)。
okVersion(await store.commit(dir, "b.md", { baseVersion: null, content: "1", message: "--force 看着像选项" }));
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%s"]).toString().trim()).toBe("--force 看着像选项");
});
it("diff 是真 unified diff,含增删行", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "keep\nold\n" }));
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "keep\nnew\n" }));
const d = await store.diff(dir, "a.md", v1, v2);
expect(d).toContain("-old");
expect(d).toContain("+new");
expect(d).toContain(" keep");
});
it("不存在的 version → version_not_found;非法路径 → invalid_path", async () => {
await store.init(dir);
await store.commit(dir, "a.md", { baseVersion: null, content: "1" });
const bogus = "0".repeat(40);
await expect(store.read(dir, "a.md", bogus)).rejects.toMatchObject({ code: "version_not_found" });
for (const bad of ["../escape.md", "a/../../b.md", ".git/config", "/abs.md", "a\\b.md"]) {
await expect(store.read(dir, bad)).rejects.toMatchObject({ code: "invalid_path" });
}
});
it("仓库内的 hooks 不被执行(ADR-0030 加固)", async () => {
await store.init(dir);
// 装一个会失败的 pre-commit hook:若 hooks 生效,下面的 commit 就会失败。
const { mkdir, writeFile, chmod } = await import("node:fs/promises");
const hookDir = path.join(dir, ".git", "hooks");
await mkdir(hookDir, { recursive: true });
const hook = path.join(hookDir, "pre-commit");
await writeFile(hook, "#!/bin/sh\nexit 1\n");
await chmod(hook, 0o755);
expect((await store.commit(dir, "a.md", { baseVersion: null, content: "1" })).status).toBe("ok");
});
});
+5 -1
View File
@@ -23,7 +23,11 @@ describe("InMemoryVersionStore · C1 语义", () => {
it("新建(baseVersion null)→ ok;head/read 命中", async () => { it("新建(baseVersion null)→ ok;head/read 命中", async () => {
const store = createInMemoryVersionStore(); const store = createInMemoryVersionStore();
await store.init(DIR); await store.init(DIR);
const r = await store.commit(DIR, "a.md", { baseVersion: null, content: "v1 内容", author: "u1" }); const r = await store.commit(DIR, "a.md", {
baseVersion: null,
content: "v1 内容",
author: { userId: "u1" },
});
expect(r).toEqual({ status: "ok", version: "v1" }); expect(r).toEqual({ status: "ok", version: "v1" });
expect((await store.read(DIR, "a.md")).toString()).toBe("v1 内容"); expect((await store.read(DIR, "a.md")).toString()).toBe("v1 内容");
}); });
+100
View File
@@ -0,0 +1,100 @@
/**
* POST /auth/logout 不读 body,应接受任何(或没有)Content-Type。
*
* 回归背景:该端点原先只有 Fastify 默认的 JSON parser,`curl -d ""` 之类带上
* form-urlencoded 的空 POST 会在解析阶段被 415 拒掉。修法是给它一个 catch-all
* parser —— 但**必须封装在自己的作用域里**。
*
* 最后一个 case 是这条修复的护栏:admin plugin 没有 fastify-plugin 封装,parser
* 若加到外层实例上会让全站每个 POST 都接受 form-urlencoded。而 form-urlencoded
* 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS
* preflight),"只认 JSON"是一层 CSRF 纵深防御,不能为了 logout 全局放掉。
*/
import Fastify, { type FastifyInstance } from "fastify";
import cookie from "@fastify/cookie";
import { describe, expect, it } from "vitest";
import { SESSION_COOKIE_NAME } from "../../src/admin/auth/session.js";
/** 复刻 authRoutes 里 logout 的注册方式(不拉起整个 admin plugin 与 Prisma)。 */
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(cookie);
await app.register(async (scope) => {
for (const mediaType of ["*", "application/json"]) {
scope.addContentTypeParser(mediaType, { parseAs: "string" }, (_request, _body, done) => {
done(null, undefined);
});
}
scope.post("/auth/logout", async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
return reply.status(204).send();
});
});
// 作用域外的改写型端点:用来证明 parser 没有漏出去。
app.post("/api/unrelated", async () => ({ ok: true }));
await app.ready();
return app;
}
describe("POST /auth/logout content-type tolerance", () => {
it("accepts a POST with no Content-Type", async () => {
const app = await buildApp();
const res = await app.inject({ method: "POST", url: "/auth/logout" });
expect(res.statusCode).toBe(204);
expect(JSON.stringify(res.headers["set-cookie"])).toContain(SESSION_COOKIE_NAME);
await app.close();
});
it("accepts an empty form-encoded POST (curl -d '' 的默认)", async () => {
const app = await buildApp();
const res = await app.inject({
method: "POST",
url: "/auth/logout",
headers: { "content-type": "application/x-www-form-urlencoded" },
payload: "",
});
expect(res.statusCode).toBe(204);
await app.close();
});
it("accepts a form-encoded POST with a body, ignoring it", async () => {
const app = await buildApp();
const res = await app.inject({
method: "POST",
url: "/auth/logout",
headers: { "content-type": "application/x-www-form-urlencoded" },
payload: "role=OWNER&x=1",
});
expect(res.statusCode).toBe(204);
await app.close();
});
it("accepts application/json with an empty body", async () => {
const app = await buildApp();
const res = await app.inject({
method: "POST",
url: "/auth/logout",
headers: { "content-type": "application/json" },
payload: "",
});
expect(res.statusCode).toBe(204);
await app.close();
});
// 护栏:catch-all parser 不得泄漏到作用域外的路由。
it("does NOT make unrelated POST routes accept form-encoded bodies", async () => {
const app = await buildApp();
const res = await app.inject({
method: "POST",
url: "/api/unrelated",
headers: { "content-type": "application/x-www-form-urlencoded" },
payload: "role=OWNER",
});
expect(res.statusCode).toBe(415);
expect(res.json().code).toBe("FST_ERR_CTP_INVALID_MEDIA_TYPE");
await app.close();
});
});