forked from EduCraft/curriculum-project-hub
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87bbf0bd57 | |||
| 8eda04f1d9 | |||
| 1dab83f9db | |||
| 8c26c42e0c | |||
| fe38d0b8a9 | |||
| e194670d74 | |||
| 3b544d99f4 | |||
| ce5fbfb9a6 | |||
| 4849a765da | |||
| 82241afb56 | |||
| a306c58db2 | |||
| 50ddf32cc2 | |||
| e6e23294a2 |
@@ -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
@@ -23,12 +23,18 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
|
||||
# HUB_AGENT_MAX_TURNS=25
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
|
||||
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_FILE_BYTES="26214400"
|
||||
HUB_HTTP_REQUESTS_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
|
||||
# absolute path outside the deployment/release tree; install_service.sh defaults
|
||||
# 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
|
||||
# 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"
|
||||
|
||||
# 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
|
||||
# reverse proxy; both values are validated and honored by the HTTP server.
|
||||
HOST="127.0.0.1"
|
||||
|
||||
Generated
+302
-47
@@ -7,6 +7,17 @@
|
||||
"": {
|
||||
"name": "filelib-web",
|
||||
"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": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
@@ -19,11 +30,161 @@
|
||||
"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": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -35,7 +196,6 @@
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -46,7 +206,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -103,11 +262,94 @@
|
||||
"@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": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -126,7 +368,6 @@
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
@@ -146,7 +387,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -163,7 +403,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -180,7 +419,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -197,7 +435,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -214,7 +451,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -231,7 +467,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -251,7 +486,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -271,7 +505,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -291,7 +524,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -311,7 +543,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -331,7 +562,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -351,7 +581,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -368,7 +597,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -387,7 +615,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -404,7 +631,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -418,7 +644,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
@@ -808,7 +1033,6 @@
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -895,6 +1119,34 @@
|
||||
"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": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -905,6 +1157,12 @@
|
||||
"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": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -919,7 +1177,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -975,7 +1232,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -993,7 +1249,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1025,7 +1280,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -1045,7 +1300,6 @@
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@@ -1078,7 +1332,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1099,7 +1352,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1120,7 +1372,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1141,7 +1392,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1162,7 +1412,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1183,7 +1432,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1207,7 +1455,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1231,7 +1478,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1255,7 +1501,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1279,7 +1524,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1300,7 +1544,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1355,7 +1598,6 @@
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1388,14 +1630,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1408,7 +1648,6 @@
|
||||
"version": "8.5.22",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
||||
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1451,7 +1690,6 @@
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
@@ -1520,12 +1758,17 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/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": {
|
||||
"version": "5.56.7",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz",
|
||||
@@ -1604,7 +1847,6 @@
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -1631,7 +1873,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
@@ -1653,7 +1894,6 @@
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
@@ -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": {
|
||||
"version": "1.1.3",
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
||||
@@ -20,5 +20,16 @@
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^5.7.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +1,22 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 文件编辑器(模态框形式)。打开后加载文件内容并使用 CodeMirror 编辑,
|
||||
* 支持语法高亮、版本冲突处理、历史查看与删除。
|
||||
*/
|
||||
import { api, ApiError } from "./api.js";
|
||||
import { toastOk, toastErr, toast } from "./stores.js";
|
||||
import type { FileContent, VersionInfo, Role } from "./types.js";
|
||||
import Modal from "./Modal.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 draft = $state("");
|
||||
@@ -15,6 +26,7 @@
|
||||
let history = $state<VersionInfo[]>([]);
|
||||
|
||||
const canEdit = $derived(role !== "VIEW");
|
||||
const filename = $derived(path.split("/").pop() ?? "");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
@@ -35,6 +47,11 @@
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (file === null) return;
|
||||
// 内容未变化时不提交,避免产生空 commit。
|
||||
if (draft === file.content) {
|
||||
toastOk("内容无变化,未提交");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api<{ version: string }>(`/database/api/projects/${projectId}/file/commits`, {
|
||||
method: "POST",
|
||||
@@ -81,6 +98,7 @@
|
||||
toastOk("已删除");
|
||||
file = null;
|
||||
onchanged();
|
||||
onclose();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@@ -104,58 +122,80 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loadError}
|
||||
<div class="panel text-xs text-danger">{loadError}</div>
|
||||
{:else if file}
|
||||
<div class="panel">
|
||||
<div class="mb-2.5 flex items-center justify-between">
|
||||
<span class="file-meta">{file.path} @ {file.version}</span>
|
||||
<!-- 主编辑器模态框:宽屏 overlay -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
>
|
||||
<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">
|
||||
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
|
||||
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
|
||||
{#if canEdit}
|
||||
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button>
|
||||
{/if}
|
||||
{#if onclose}
|
||||
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览">✕</button>
|
||||
{#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>
|
||||
{#if canEdit}
|
||||
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="btn !px-2.5" onclick={onclose} title="关闭" aria-label="关闭编辑器">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if file.encoding === "base64"}
|
||||
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
|
||||
{:else}
|
||||
<textarea rows="14" class="textarea !leading-7" bind:value={draft} readonly={!canEdit}></textarea>
|
||||
{/if}
|
||||
<!-- 编辑器主体 -->
|
||||
<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>
|
||||
{:else}
|
||||
<CodeEditor value={draft} filename={path} readonly={!canEdit} onchange={(v) => (draft = v)} />
|
||||
{/if}
|
||||
|
||||
{#if canEdit && file.encoding !== "base64"}
|
||||
<div class="mt-3 flex justify-end">
|
||||
{#if conflict}
|
||||
<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>
|
||||
<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 flex justify-end">
|
||||
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 底栏 -->
|
||||
{#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}
|
||||
|
||||
{#if conflict}
|
||||
<div class="mt-3.5 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
|
||||
<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>
|
||||
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel quiet">加载中…</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showHistory}
|
||||
<Modal title="版本历史" onclose={() => (showHistory = false)}>
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#each history as v (v.version)}
|
||||
<div class="border-t border-line-soft py-2 text-xs first:border-t-0">
|
||||
<span class="font-mono text-accent">{v.version}</span> {v.message}
|
||||
<div class="text-ink-3">{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}</div>
|
||||
<div class="flex items-center gap-2.5 border-t border-line-soft py-2.5 first:border-t-0">
|
||||
<span
|
||||
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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { api } from "./api.js";
|
||||
import { toastOk, toastErr } from "./stores.js";
|
||||
import { selectedFilePath, filesVersion } from "./browser.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import type { FileEntry, NodeDetail } from "./types.js";
|
||||
import Modal from "./Modal.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
@@ -13,11 +14,56 @@
|
||||
let showNewFile = $state(false);
|
||||
let newPath = $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 不会正确更新。
|
||||
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");
|
||||
|
||||
/**
|
||||
* 当前浏览目录("" = 根,否则以 "/" 结尾)。文件夹是从扁平 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> {
|
||||
try {
|
||||
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`);
|
||||
@@ -34,17 +80,86 @@
|
||||
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> {
|
||||
const path = newPath.trim();
|
||||
if (path === "") return;
|
||||
const message = newMessage.trim();
|
||||
try {
|
||||
await api(`/database/api/projects/${node.id}/file`, {
|
||||
method: "PUT",
|
||||
body: { path, content: newContent },
|
||||
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
|
||||
body: message === "" ? { path, content: newContent } : { path, content: newContent, message },
|
||||
});
|
||||
toastOk("已创建");
|
||||
showNewFile = false;
|
||||
newPath = ""; newContent = "";
|
||||
newPath = ""; newContent = ""; newMessage = "";
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
@@ -60,28 +175,51 @@
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
async function doUpload(e: Event): Promise<void> {
|
||||
/** 选文件只负责暂存与预填;真正上传在弹窗确认后。 */
|
||||
function pickFile(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toastErr("文件超过 10MB 上限");
|
||||
// 上限取后端值;拉不到就不在前端拦 —— 后端横竖会以 413 file_too_large 兼底,
|
||||
// 前端这道只是省一次往返。
|
||||
if (maxFileBytes !== null && file.size > maxFileBytes) {
|
||||
toastErr(`文件超过 ${maxLabel} 上限`);
|
||||
return;
|
||||
}
|
||||
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
|
||||
if (!targetPath) return;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const isBinary = bytes.includes(0);
|
||||
const body = isBinary
|
||||
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
|
||||
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
|
||||
pendingFile = file;
|
||||
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 isBinary = bytes.includes(0);
|
||||
const message = uploadMessage.trim();
|
||||
const body: Record<string, string> = isBinary
|
||||
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
|
||||
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
|
||||
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
|
||||
if (message !== "") body["message"] = message;
|
||||
await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body });
|
||||
toastOk("已上传 " + file.name);
|
||||
toastOk(`已上传 ${file.name}`);
|
||||
cancelUpload();
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
toastErr(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -91,9 +229,9 @@
|
||||
<div class="section-title">项目文件({files?.length ?? 0})</div>
|
||||
{#if canEdit}
|
||||
<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>
|
||||
<input bind:this={uploadInput} type="file" class="hidden" onchange={doUpload} />
|
||||
<input bind:this={uploadInput} type="file" class="hidden" onchange={pickFile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -104,20 +242,91 @@
|
||||
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
|
||||
{:else if files && files.length === 0}
|
||||
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
|
||||
{:else if files}
|
||||
<table class="list">
|
||||
<tbody>
|
||||
{#each files as f (f.path)}
|
||||
<tr
|
||||
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
{: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">
|
||||
<tbody>
|
||||
{#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
|
||||
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
onclick={() => selectedFilePath.set(f.path)}
|
||||
>
|
||||
<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>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</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)}
|
||||
>
|
||||
<td class="font-mono text-[12.5px] text-ink">{f.path}</td>
|
||||
<td class="file-meta text-right">{f.size} B</td>
|
||||
</tr>
|
||||
<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}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -131,9 +340,36 @@
|
||||
<label class="form-label" for="nf-content">内容</label>
|
||||
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
|
||||
</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">
|
||||
<button class="btn" onclick={() => (showNewFile = false)}>取消</button>
|
||||
<button class="btn btn-primary" onclick={submitNewFile}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/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}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
}
|
||||
|
||||
async function revoke(g: Grant): Promise<void> {
|
||||
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return;
|
||||
if (!confirm(`收回「${g.principalName}」的 ${g.role} 授权?`)) return;
|
||||
try {
|
||||
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
|
||||
method: "DELETE",
|
||||
@@ -133,7 +133,8 @@
|
||||
<td>
|
||||
<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="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}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -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>
|
||||
@@ -19,6 +19,13 @@
|
||||
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
|
||||
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",
|
||||
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;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import { api } from "./api.js";
|
||||
import { me, toastErr, toastOk } from "./stores.js";
|
||||
import { logout } from "./session.js";
|
||||
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js";
|
||||
import type { NodeChild } from "./types.js";
|
||||
import { treeVersion, bumpTree, currentNode, breadcrumb, selectedFilePath, clearSelectedFile, bumpFiles, restoreNodeId } from "./browser.js";
|
||||
import type { NodeChild, NodeDetail, BreadcrumbEntry } from "./types.js";
|
||||
import TreeNode from "./TreeNode.svelte";
|
||||
import NodeDetailPanel from "./NodeDetailPanel.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(() => {
|
||||
void $treeVersion;
|
||||
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>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
@@ -97,7 +112,8 @@
|
||||
{#if showUserFooter}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -108,17 +124,15 @@
|
||||
<NodeDetailPanel />
|
||||
</main>
|
||||
|
||||
<!-- 右侧:文件预览/编辑栏(选中文件时出现) -->
|
||||
<!-- 文件编辑器(模态框) -->
|
||||
{#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
|
||||
projectId={$currentNode.id}
|
||||
path={$selectedFilePath}
|
||||
role={$currentNode.role}
|
||||
onchanged={bumpFiles}
|
||||
onclose={clearSelectedFile}
|
||||
/>
|
||||
</section>
|
||||
<FileEditor
|
||||
projectId={$currentNode.id}
|
||||
path={$selectedFilePath}
|
||||
role={$currentNode.role}
|
||||
onchanged={bumpFiles}
|
||||
onclose={clearSelectedFile}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<script lang="ts">
|
||||
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 OverviewPanel from "./OverviewPanel.svelte";
|
||||
import FilesPanel from "./FilesPanel.svelte";
|
||||
import GrantsPanel from "./GrantsPanel.svelte";
|
||||
import HistoryPanel from "./HistoryPanel.svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
type Tab = "detail" | "files" | "grants";
|
||||
let tab = $state<Tab>("detail");
|
||||
type Tab = "detail" | "files" | "history" | "grants";
|
||||
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 newName = $state("");
|
||||
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
|
||||
@@ -20,21 +26,38 @@
|
||||
const canManage = $derived(node?.role === "MANAGE");
|
||||
const canEdit = $derived(canManage || node?.role === "EDIT");
|
||||
|
||||
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE
|
||||
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;修改历史仅 PROJECT;授权仅 MANAGE
|
||||
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
|
||||
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
|
||||
const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
|
||||
if (node?.kind === "PROJECT") out.push(["files", "文件"]);
|
||||
if (node?.kind === "PROJECT") out.push(["history", "修改历史"]);
|
||||
if (canManage) out.push(["grants", "授权"]);
|
||||
return out;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void node?.id;
|
||||
tab = "detail";
|
||||
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";
|
||||
}
|
||||
clearSelectedFile();
|
||||
});
|
||||
|
||||
// tab 变化时同步到持久化 store。
|
||||
$effect(() => {
|
||||
activeTab.set(tab);
|
||||
});
|
||||
|
||||
async function createChild(): Promise<void> {
|
||||
const name = newName.trim();
|
||||
if (name === "" || node === null) return;
|
||||
@@ -129,6 +152,8 @@
|
||||
<GrantsPanel {node} />
|
||||
{:else if tab === "files" && node.kind === "PROJECT"}
|
||||
<FilesPanel {node} />
|
||||
{:else if tab === "history" && node.kind === "PROJECT"}
|
||||
<HistoryPanel {node} />
|
||||
{:else}
|
||||
<OverviewPanel {node} />
|
||||
{#if node.kind === "FOLDER"}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
import { currentNode } from "./browser.js";
|
||||
import type { ExportJob, NodeDetail } from "./types.js";
|
||||
import Modal from "./Modal.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
let { node }: { node: NodeDetail } = $props();
|
||||
|
||||
let showEditDesc = $state(false);
|
||||
let descDraft = $state("");
|
||||
let exportJob = $state<ExportJob | null>(null);
|
||||
let exporting = $state(false);
|
||||
|
||||
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
||||
const canManage = $derived(node.role === "MANAGE");
|
||||
@@ -34,6 +36,7 @@
|
||||
$effect(() => {
|
||||
void node.id;
|
||||
exportJob = null;
|
||||
exporting = false;
|
||||
});
|
||||
|
||||
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 {
|
||||
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
|
||||
method: "POST",
|
||||
body: { target: "manifest" },
|
||||
body: { target: "pdf" },
|
||||
});
|
||||
toastOk("导出已提交");
|
||||
void pollExport(r.jobId);
|
||||
await pollExport(r.jobId);
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollExport(jobId: string): Promise<void> {
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
let job: ExportJob;
|
||||
try {
|
||||
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
||||
exportJob = job;
|
||||
if (job.status === "DONE" || job.status === "FAILED") break;
|
||||
} catch {
|
||||
break;
|
||||
job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
||||
} catch (e) {
|
||||
exporting = false;
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
return;
|
||||
}
|
||||
exportJob = job;
|
||||
if (job.status === "DONE") {
|
||||
exporting = false;
|
||||
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>
|
||||
|
||||
<div class="panel">
|
||||
@@ -124,18 +159,16 @@
|
||||
|
||||
<div class="section-title mb-2">导出</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
|
||||
<button class="btn" onclick={submitExport}>开始导出</button>
|
||||
{#if exportJob}
|
||||
<button class="btn" onclick={exportPdf} disabled={exporting}>
|
||||
<Icon name="download" size={13} />
|
||||
{exporting ? "导出中…" : "导出 PDF"}
|
||||
</button>
|
||||
{#if exportJob?.status === "DONE"}
|
||||
<span class="file-meta">
|
||||
{#if exportJob.status === "DONE"}
|
||||
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
|
||||
{:else if exportJob.status === "FAILED"}
|
||||
失败:{exportJob.error ?? ""}
|
||||
{:else}
|
||||
{exportJob.status}…
|
||||
{/if}
|
||||
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download" download>重新下载</a>
|
||||
</span>
|
||||
{:else if exportJob?.status === "FAILED"}
|
||||
<span class="file-meta text-danger">失败:{exportJob.error ?? "未知原因"}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,17 +1,62 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { writable, get } from "svelte/store";
|
||||
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 breadcrumb = writable<BreadcrumbEntry[]>([]);
|
||||
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,列表随之重载)。 */
|
||||
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 {
|
||||
treeVersion.update((v) => v + 1);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { api } from "./api.js";
|
||||
export interface AppConfig {
|
||||
readonly orgSlug: string;
|
||||
readonly devLoginEnabled: boolean;
|
||||
/** 单文件上传上限(字节)。后端 `HUB_FILELIB_MAX_FILE_BYTES` 的生效值。 */
|
||||
readonly maxFileBytes: number;
|
||||
}
|
||||
|
||||
let cached: AppConfig | null = null;
|
||||
|
||||
@@ -63,6 +63,15 @@ export interface VersionInfo {
|
||||
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 {
|
||||
readonly id: string;
|
||||
readonly nodeId: string;
|
||||
@@ -72,21 +81,6 @@ export interface ExportJob {
|
||||
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 拼树。 */
|
||||
export interface MemberGroupNode {
|
||||
readonly id: string;
|
||||
@@ -114,6 +108,8 @@ export interface Grant {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
/** 后端解析好的展示名(USER→displayName / GROUP→组名);取不到行时回落为 principalId。 */
|
||||
readonly principalName: string;
|
||||
readonly role: Role;
|
||||
/** 创建者授权不可收回、不可改(契约 8.1)。 */
|
||||
readonly isCreatorGrant: boolean;
|
||||
|
||||
@@ -28,9 +28,18 @@
|
||||
|
||||
const BASE = "/database/dashboard";
|
||||
|
||||
onMount(async () => {
|
||||
await loadSession();
|
||||
if ($me === null) void goto("/database/admin", { replaceState: true });
|
||||
onMount(loadSession);
|
||||
|
||||
/**
|
||||
* 未登录一律回登录页 —— 必须是 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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。
|
||||
@@ -8,7 +9,7 @@ import { defineConfig } from "vite";
|
||||
const backend = "http://127.0.0.1:8788";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
plugins: [wasm(), tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
@@ -268,9 +268,30 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/logout", async (_request, reply) => {
|
||||
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
return reply.status(204).send();
|
||||
// 退出登录不读 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: "/" });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/auth/feishu/complete", async (request, reply) => {
|
||||
|
||||
@@ -110,7 +110,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust |
|
||||
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
|
||||
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
|
||||
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) |
|
||||
| `filelib/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/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(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
|
||||
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
|
||||
|
||||
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
|
||||
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
|
||||
> 持久化 git 包到位后此问题消失。
|
||||
## 存储布局(ADR-0030)
|
||||
|
||||
- **文件夹不落盘。** `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),目前不可达。
|
||||
|
||||
关键语义速查:
|
||||
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
*/
|
||||
|
||||
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 { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
|
||||
import type { FileDeps } from "./fileService.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ExportAdapterInput {
|
||||
readonly storageDir: 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 产物存储(进程内存,重启即失;生产替换为持久存储)。
|
||||
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 {
|
||||
readonly adapters: readonly ExportAdapter[];
|
||||
@@ -136,7 +281,7 @@ async function runExportJob(
|
||||
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
|
||||
readFile: (path) => deps.versionStore.read(storageDir, path),
|
||||
});
|
||||
artifacts.set(jobId, artifact);
|
||||
retainArtifact(jobId, artifact);
|
||||
await deps.prisma.fileLibExportJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
|
||||
|
||||
@@ -10,14 +10,43 @@
|
||||
|
||||
import { FileLibError } from "./model.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import type { CommitResult, FileEntry, VersionInfo, VersionStore } from "./versionStore.js";
|
||||
import type { CommitResult, FileEntry, VersionInfo, VersionStore, ProjectCommitInfo } from "./versionStore.js";
|
||||
import type { AccessDeps, FileLibActor } from "./treeService.js";
|
||||
import { requireAccessInTx } from "./treeService.js";
|
||||
import type { FileLibNode, PrismaClient } from "@prisma/client";
|
||||
|
||||
export const FILE_PATH_MAX_LENGTH = 512;
|
||||
export const FILE_PATH_MAX_DEPTH = 32;
|
||||
export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值
|
||||
|
||||
/** 单文件上限的出厂默认值(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 FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
|
||||
@@ -52,6 +81,8 @@ export function validateFilePath(raw: string): string {
|
||||
export interface FileDeps extends AccessDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly versionStore: VersionStore;
|
||||
/** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */
|
||||
readonly maxFileBytes?: number | undefined;
|
||||
}
|
||||
|
||||
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") };
|
||||
}
|
||||
|
||||
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;
|
||||
if (bytes > FILE_CONTENT_MAX_BYTES) {
|
||||
throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`);
|
||||
if (bytes > maxBytes) {
|
||||
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(
|
||||
deps: FileDeps,
|
||||
actor: FileLibActor,
|
||||
@@ -185,6 +228,17 @@ export async function fileHistory(
|
||||
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(
|
||||
deps: FileDeps,
|
||||
actor: FileLibActor,
|
||||
@@ -221,14 +275,21 @@ export async function commitFile(
|
||||
): Promise<{ readonly version: string }> {
|
||||
const filePath = validateFilePath(input.path);
|
||||
const content = decodeContent(input.content, input.encoding ?? "utf8");
|
||||
checkSize(content);
|
||||
checkSize(content, deps.maxFileBytes ?? resolveMaxFileBytes());
|
||||
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, {
|
||||
baseVersion: input.baseVersion,
|
||||
content,
|
||||
message: input.message,
|
||||
author: actor.userId,
|
||||
message,
|
||||
// 身份:name=displayName(回退 userId),email=<userId>@域名(git 实现里拼)。
|
||||
author: { userId: actor.userId, displayName: actor.displayName },
|
||||
});
|
||||
|
||||
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,
|
||||
project,
|
||||
filePath,
|
||||
{ version: result.version, message: input.message ?? null },
|
||||
{ version: result.version, message },
|
||||
);
|
||||
return { version: result.version };
|
||||
}
|
||||
@@ -261,7 +322,10 @@ export async function deleteFile(
|
||||
): Promise<void> {
|
||||
const filePath = validateFilePath(rawPath);
|
||||
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") {
|
||||
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
|
||||
baseVersion,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,16 +25,24 @@ export interface GrantDto {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
/**
|
||||
* 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
|
||||
* USER → `User.displayName`;GROUP → `MemberGroup.name`;
|
||||
* 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
|
||||
* 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
|
||||
*/
|
||||
readonly principalName: string;
|
||||
readonly role: FileLibRole;
|
||||
readonly isCreatorGrant: boolean;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
function toDto(grant: FileLibGrant): GrantDto {
|
||||
function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
|
||||
return {
|
||||
id: grant.id,
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
principalName: principalName ?? grant.principalId,
|
||||
role: grant.role,
|
||||
isCreatorGrant: grant.isCreatorGrant,
|
||||
createdAt: grant.createdAt,
|
||||
@@ -44,6 +52,35 @@ function toDto(grant: FileLibGrant): GrantDto {
|
||||
type Tx = Prisma.TransactionClient;
|
||||
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 时用调用方事务(与后续写同绳),不带时自开一个。 */
|
||||
async function requireManage(
|
||||
deps: Deps,
|
||||
@@ -66,7 +103,7 @@ export async function listGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return grants.map(toDto);
|
||||
return toDtosWithNames(deps.prisma, grants);
|
||||
}
|
||||
|
||||
export interface PutGrantsResult {
|
||||
@@ -137,7 +174,7 @@ export async function putGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
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 },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: grants.map(toDto) };
|
||||
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -43,5 +43,7 @@ export async function requireFileLibActor(
|
||||
return {
|
||||
userId: auth.user.id,
|
||||
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
|
||||
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
|
||||
displayName: auth.user.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface FileLibRouteDeps {
|
||||
readonly groupResolver: GroupResolver;
|
||||
readonly versionStore: VersionStore;
|
||||
readonly exportAdapters: readonly ExportAdapter[];
|
||||
/** 单文件字节上限(`HUB_FILELIB_MAX_FILE_BYTES`)。 */
|
||||
readonly maxFileBytes: number;
|
||||
}
|
||||
|
||||
/** 组装 treeService 依赖(路由处理内直接使用)。 */
|
||||
|
||||
@@ -35,6 +35,11 @@ export interface FileLibActor {
|
||||
readonly userId: string;
|
||||
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
/**
|
||||
* 展示名(飞书昵称)。只用于生成 commit message 的【用户名】部分;
|
||||
* 权限判定一律用 userId。缺失时回退到 userId。
|
||||
*/
|
||||
readonly displayName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TreeServiceDeps {
|
||||
@@ -229,6 +234,8 @@ export async function createNode(
|
||||
}
|
||||
|
||||
if (input.kind === "PROJECT") {
|
||||
// ADR-0030:项目扁平居于同一根下、以 uuid 命名;名字不进路径(所以 rename
|
||||
// 不动磁盘)。FOLDER 永不赋值 —— 文件夹不落盘,只存在于 DB 的 parentId/pathIds。
|
||||
storageDir = path.join(deps.storageRoot, id);
|
||||
}
|
||||
|
||||
@@ -308,6 +315,7 @@ export async function createNode(
|
||||
});
|
||||
|
||||
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
|
||||
// ADR-0030:这一步真的建目录并 `git init`;宿主无 git 则此处 provision_failed。
|
||||
if (input.kind === "PROJECT" && storageDir !== null) {
|
||||
try {
|
||||
await deps.versionStore.init(storageDir);
|
||||
|
||||
@@ -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)、
|
||||
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
|
||||
*
|
||||
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
|
||||
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。
|
||||
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复。ADR-0030 之后
|
||||
* 已无生产调用点 —— 生产走 git,不再有这份进程级 JSON 快照。
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
@@ -15,12 +17,21 @@ import { FileLibError } from "./model.js";
|
||||
|
||||
export type VersionId = string;
|
||||
|
||||
/**
|
||||
* 提交者身份。`userId` 是稳定主键(追溯用),`displayName` 只影响展示。
|
||||
* git 实现把它们映射成 `name <userId@域名>`(ADR-0030)。
|
||||
*/
|
||||
export interface CommitAuthor {
|
||||
readonly userId: string;
|
||||
readonly displayName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface CommitRequest {
|
||||
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
|
||||
readonly baseVersion: VersionId | null;
|
||||
readonly content: string | Buffer;
|
||||
readonly message?: string | undefined;
|
||||
readonly author?: string | undefined;
|
||||
readonly author?: CommitAuthor | undefined;
|
||||
}
|
||||
|
||||
export type CommitResult =
|
||||
@@ -34,6 +45,16 @@ export interface VersionInfo {
|
||||
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 {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
@@ -45,9 +66,17 @@ export interface VersionStore {
|
||||
head(projectDir: string, filePath: string): Promise<VersionId>;
|
||||
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
|
||||
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>;
|
||||
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
|
||||
/** 项目级提交历史(所有文件),新→旧排列。 */
|
||||
projectHistory(projectDir: string, limit?: number): Promise<ProjectCommitInfo[]>;
|
||||
}
|
||||
|
||||
interface StoredVersion {
|
||||
@@ -80,6 +109,13 @@ function toBuffer(content: string | Buffer): Buffer {
|
||||
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 以版本团队为准)。 */
|
||||
function naiveDiff(fromText: string, toText: string): string {
|
||||
const a = fromText.split("\n");
|
||||
@@ -221,7 +257,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
version,
|
||||
content: toBuffer(req.content),
|
||||
message: req.message ?? `commit ${version}`,
|
||||
author: req.author,
|
||||
author: authorLabel(req.author),
|
||||
committedAt: new Date().toISOString(),
|
||||
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> => {
|
||||
const repo = requireRepo(projectDir);
|
||||
const chain = repo.files.get(filePath) ?? [];
|
||||
@@ -247,7 +283,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
version,
|
||||
content: Buffer.alloc(0),
|
||||
message: `remove ${filePath}`,
|
||||
author: undefined,
|
||||
author: authorLabel(author),
|
||||
committedAt: new Date().toISOString(),
|
||||
deleted: true,
|
||||
});
|
||||
@@ -276,5 +312,24 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
const ordered = infos.reverse();
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ import { registerFileLibRoutes } from "./filelibRoutes.js";
|
||||
import { registerFileRoutes } from "./fileRoutes.js";
|
||||
import { registerMemberGroupRoutes } from "./memberGroupRoutes.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 { 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 { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
|
||||
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||
@@ -60,6 +61,8 @@ export async function registerDatabaseRoutes(
|
||||
app.get("/database/config", async () => ({
|
||||
orgSlug: config.siloOrganizationSlug,
|
||||
devLoginEnabled: config.allowDevLoginBypass,
|
||||
// 上传上限由后端下发,前端不再写死 —— 两处各自硬编码会随配置漂。
|
||||
maxFileBytes: resolveMaxFileBytes(),
|
||||
}));
|
||||
|
||||
// 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
|
||||
@@ -132,7 +135,8 @@ export async function registerDatabaseRoutes(
|
||||
}
|
||||
|
||||
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
|
||||
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
||||
// 依赖装配:VersionStore 是真 git —— 一项目一仓库 <storageRoot>/<nodeId>,
|
||||
// VersionId = commit hash(ADR-0030);
|
||||
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
|
||||
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
||||
@@ -145,7 +149,7 @@ export async function registerDatabaseRoutes(
|
||||
return;
|
||||
}
|
||||
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 filelibDeps: FileLibRouteDeps = {
|
||||
prisma: config.prisma,
|
||||
@@ -156,7 +160,8 @@ export async function registerDatabaseRoutes(
|
||||
? createMemberGroupResolver(config.prisma)
|
||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
exportAdapters: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)],
|
||||
maxFileBytes: resolveMaxFileBytes(),
|
||||
};
|
||||
await registerFileLibRoutes(app, filelibDeps);
|
||||
await registerFileRoutes(app, filelibDeps);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
diffFile,
|
||||
fileHistory,
|
||||
listFiles,
|
||||
projectHistory,
|
||||
readFile,
|
||||
readFileRaw,
|
||||
type FileContentEncoding,
|
||||
@@ -39,6 +40,7 @@ export async function registerFileRoutes(
|
||||
organizationId: deps.organizationId,
|
||||
groupResolver: deps.groupResolver,
|
||||
versionStore: deps.versionStore,
|
||||
maxFileBytes: deps.maxFileBytes,
|
||||
};
|
||||
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 异步) */
|
||||
|
||||
app.post("/database/api/projects/:id/exports", async (request, reply) => {
|
||||
@@ -224,9 +242,13 @@ export async function registerFileRoutes(
|
||||
try {
|
||||
const { jobId } = request.params as { jobId: string };
|
||||
const artifact = await downloadExport(exportDeps, actor, jobId);
|
||||
// PDF 报真实 MIME,浏览器才能内联预览/正确命名;其余产物保守为二进制流。
|
||||
const contentType = artifact.filename.toLowerCase().endsWith(".pdf")
|
||||
? "application/pdf"
|
||||
: "application/octet-stream";
|
||||
return reply
|
||||
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
|
||||
.type("application/octet-stream")
|
||||
.type(contentType)
|
||||
.send(artifact.content);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
|
||||
@@ -54,6 +54,7 @@ beforeEach(async () => {
|
||||
groupResolver: createStaticGroupResolver({}),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
maxFileBytes: 10 * 1024 * 1024,
|
||||
};
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
@@ -149,6 +150,42 @@ describe("filelib http · 8.1 授权矩阵", () => {
|
||||
expect(revoke.statusCode).toBe(403);
|
||||
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 · 文件冲突流", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,11 @@ describe("InMemoryVersionStore · C1 语义", () => {
|
||||
it("新建(baseVersion null)→ ok;head/read 命中", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
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((await store.read(DIR, "a.md")).toString()).toBe("v1 内容");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user