diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index ed0f01f..d8c77c3 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -14,6 +14,6 @@ jobs: cache: npm - run: npm ci - run: node scripts/prepare-production.mjs - - run: npm run typecheck && npm run typecheck:client && npm run test:i18n + - run: npm run typecheck && npm run typecheck:client && npm run test:i18n && npm run test:permissions && npm run test:git - run: npm run build - run: I18N_CHECK_BUILD=1 npm run test:i18n diff --git a/GIT_EDITING.md b/GIT_EDITING.md new file mode 100644 index 0000000..13df40b --- /dev/null +++ b/GIT_EDITING.md @@ -0,0 +1,25 @@ +# 使用 Git 编辑文档 + +登录 Wiki,在一篇文档的“更多 → Git 提交”中获取仓库地址并生成 Git 专用令牌。每篇文档对应一个 HTTPS Git 仓库,只有 `main` 分支与 `page.md` 文件。用户名可任意填写,密码使用令牌;不要把令牌写入仓库或远程 URL。令牌只显示一次,30 天后失效,可随时撤销或更换。 + +```sh +git clone https://www.nodeloc.wiki/git/pages/<文档ID>.git +cd <文档ID> +# 编辑 page.md +git add page.md +git commit -m "补充安装步骤" +git pull --rebase +git push origin main +``` + +服务器同时检查 `git:push`、`wiki:edit`、文档 ACL、私有文档访问权限、封禁状态和最新版本。只有密码认证的 Git 写入才会跳过浏览器 Turnstile;同样的编辑内容仍经过 Wiki 编辑器的校验、审批规则与缓存更新。需要审批的提交可在网页处理;Git 不会提前更新分支。 + +远端只接受当前 `main` 的线性快进提交。所有用户,包括最高管理员,都不能通过 Git 覆盖历史、删除或创建分支、写入标签、上传符号链接或提交合并节点。过期的基准会被拒绝,请先 pull/rebase。Git 协议不传递客户端的 `--force` 标志,因此服务器禁止的是它所尝试的非快进覆盖;即使带上 `--force` 也不能改写远端历史。 + +网页修订在 Git 访问时追加为提交,Git 原始提交及其 SHA 在仓库中保留。一次包含多个 Git 提交的推送生成一条 Wiki 修订,摘要带上最终提交 SHA,Wiki 作者始终为认证的账号。Git 的 author/email 是用户提供的信息,不代表已验证身份。 + +第一版限制每次推送最多 40 个提交、200 个 pack 对象、4 MiB 压缩数据,`page.md` 最大 512 KiB;服务端会进一步检查膨胀后的对象与 delta 大小。首次接入导入最多 20 条可见修订,每次读取最多 100 条提交及 8 MiB 正文,较旧历史成为 shallow 边界。只支持标准 HTTPS smart HTTP,暂不支持 SSH、客户端指定 depth、其他文件或批量新建文档。 + +私有页面要求相应权限,删除的页面停止提供仓库。隐藏、永久清除或已物理删除的修订会截断下载的历史,Git 对象不能通过公共媒体接口读取。已经下载到用户电脑上的内容无法远程撤回。 + +开发验证:`npm run test:git` 使用真实 Git CLI 验证 clone、push、网页修改后的 pull、拒绝 force/delete、权限撤销、私有访问与隐藏修订边界。 diff --git a/README.md b/README.md index 614d1db..3cb5e7d 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,11 @@ Markdown 页面、分类、重定向、模板引用、修订历史、Diff、回 ## 开源与来源 VireoWiki 当前版本采用 [Mozilla Public License 2.0](LICENSE)(MPL-2.0);所引入 CloudWiki 代码的原始 [MIT 许可证与版权声明](LICENSE-UPSTREAM) 单独保存。此前已发布版本所授予的权利不受此次调整影响。原作者的说明保存在 [UPSTREAM_README.md](UPSTREAM_README.md)。VireoWiki 的改动包括 NodeLoc OAuth 提供方、独立项目配置与构建修复。尚未在真实 NodeLoc 账号和 Cloudflare 资源上完成端到端部署验证。 + +## 权限组、站点协议与 Git 编辑 + +最高管理员可在后台“权限组”分别配置普通用户、讨论管理员、管理员的创建、编辑、删除、恢复、移动、回退、隐藏修订、媒体及 Git 提交权限。各组独立保存,撤销普通用户权限不会改变其他组。最高管理员、后台访问资格及永久删除权限保持固定。 + +后台“服务条款与隐私政策”提供两份可编辑的 Markdown 初始模板,发布后作为普通 Wiki 文档保留修订历史。页脚入口为 `/terms`、`/privacy`,使用站点配置中的协议文档地址;请按本站实际服务修改模板。 + +文档的“更多 → Git 提交”支持使用标准 Git 客户端提交 `page.md`,详见 [Git 编辑说明](GIT_EDITING.md)。网页与 Git 共用 Wiki 权限和编辑校验,服务器拒绝所有非快进覆盖及分支删除。 diff --git a/migrations/schema.sql b/migrations/schema.sql index 390dfcc..e200056 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -664,3 +664,19 @@ CREATE TABLE IF NOT EXISTS mcp_api_keys ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_mcp_api_keys_expires ON mcp_api_keys(expires_at); + +CREATE TABLE IF NOT EXISTS permission_groups (id INTEGER PRIMARY KEY CHECK (id = 1), permissions TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 0, updated_by INTEGER, updated_at INTEGER); + +-- Git smart HTTP credentials and append-only page repository references. +CREATE TABLE IF NOT EXISTS git_tokens ( + user_id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, + masked_token TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS git_page_heads ( + page_id INTEGER PRIMARY KEY, head TEXT, version INTEGER NOT NULL DEFAULT 0, + lease TEXT, lease_until INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS git_commit_links ( + page_id INTEGER NOT NULL, oid TEXT NOT NULL, revision_id INTEGER, user_id INTEGER, + PRIMARY KEY(page_id, oid) +); diff --git a/package-lock.json b/package-lock.json index 4f7400e..774b0aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,16 @@ ], "dependencies": { "@block65/webcrypto-web-push": "^1.0.2", - "hono": "^4.4.0" + "hono": "^4.4.0", + "isomorphic-git": "^1.38.6", + "pako": "^1.0.11" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@types/node": "^25.6.0", + "@types/pako": "^2.0.4", "astro": "^6.4.2", "esbuild": "^0.27.3", "parse5": "^7.3.0", @@ -2276,6 +2279,13 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2290,6 +2300,18 @@ "dev": true, "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2499,6 +2521,27 @@ } } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2529,6 +2572,26 @@ "node": ">= 0.6.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -2543,6 +2606,77 @@ "dev": true, "license": "ISC" }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2619,6 +2753,12 @@ "node": ">=8" } }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "license": "Apache-2.0" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2681,6 +2821,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/crossws": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", @@ -2803,6 +2955,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -2868,6 +3052,12 @@ "node": ">=0.3.1" } }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2950,6 +3140,20 @@ "node": ">=4" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2973,6 +3177,24 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2980,6 +3202,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3042,6 +3276,15 @@ "dev": true, "license": "MIT" }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -3049,6 +3292,15 @@ "dev": true, "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3134,6 +3386,21 @@ "node": ">=20" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3149,6 +3416,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "5.0.0-beta.4", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", @@ -3172,6 +3485,18 @@ "dev": true, "license": "ISC" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -3190,6 +3515,57 @@ "uncrypto": "^0.1.3" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -3411,6 +3787,41 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -3421,6 +3832,18 @@ "url": "https://github.com/sponsors/brc-dd" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", @@ -3485,6 +3908,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3501,6 +3939,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isomorphic-git": { + "version": "1.38.6", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.6.tgz", + "integrity": "sha512-lCmZ1m2R0LqbZ4QATtLCxhwNBQPNJc74R81dfyF90yLqP8xs2DZLedcnkVuRj6th/L2HYoIvWP5lBpS1ooAagw==", + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3846,6 +4315,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-definitions": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", @@ -4682,6 +5160,18 @@ ], "license": "MIT" }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/miniflare": { "version": "4.20260518.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260518.0.tgz", @@ -4703,6 +5193,24 @@ "node": ">=22.0.0" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -4830,6 +5338,15 @@ "dev": true, "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -4902,6 +5419,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-latin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", @@ -4975,6 +5498,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -5014,6 +5555,15 @@ "node": ">=6" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5032,6 +5582,22 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -5385,6 +5951,26 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -5435,6 +6021,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -5500,6 +6123,51 @@ "node": ">=20" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -5541,6 +6209,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5639,6 +6316,20 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5680,6 +6371,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6167,6 +6872,27 @@ "node": ">=4" } }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/workerd": { "version": "1.20260518.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260518.1.tgz", @@ -6223,6 +6949,12 @@ } } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", diff --git a/package.json b/package.json index 0de6ee0..2efde76 100644 --- a/package.json +++ b/package.json @@ -18,23 +18,28 @@ "typecheck:client": "tsc --noEmit -p tsconfig.client.json", "generate:rules": "node scripts/generate-rules.mjs", "deploy:do": "wrangler deploy -c do-worker/wrangler.toml", - "test:i18n": "node scripts/test-i18n.mjs" + "test:i18n": "node scripts/test-i18n.mjs", + "test:permissions": "node scripts/test-permissions.mjs", + "test:git": "node scripts/test-git.mjs" }, "dependencies": { "@block65/webcrypto-web-push": "^1.0.2", - "hono": "^4.4.0" + "hono": "^4.4.0", + "isomorphic-git": "^1.38.6", + "pako": "^1.0.11" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@types/node": "^25.6.0", + "@types/pako": "^2.0.4", "astro": "^6.4.2", + "esbuild": "^0.27.3", + "parse5": "^7.3.0", "typescript": "^5.5.0", "vite": "^8.0.10", - "wrangler": "^4.78.0", - "parse5": "^7.3.0", - "esbuild": "^0.27.3" + "wrangler": "^4.78.0" }, "license": "MPL-2.0" } diff --git a/packages/wiki-shared/src/i18n/en.json b/packages/wiki-shared/src/i18n/en.json index 6a73493..abcb013 100644 --- a/packages/wiki-shared/src/i18n/en.json +++ b/packages/wiki-shared/src/i18n/en.json @@ -3429,5 +3429,65 @@ "m_302bae1279382d2b": "Image", "m_b68c7d1bc9592573": "Category", "m_25f0d827e03ff670": "+$1 lines", - "m_75cb099b9144f625": "-$1 lines" + "m_75cb099b9144f625": "-$1 lines", + "permissions.title": "Permission groups", + "permissions.help": "Choose operations for each group. Super administrators retain all permissions. Guest/banned access, dashboard access and permanent deletion remain protected. Media deletion, user management and page permission management require an administrator. Changes apply to new requests; reload open pages.", + "permissions.save": "Save permissions", + "permissions.defaults": "Select defaults", + "permissions.operation": "Operation", + "permissions.role.user": "Users", + "permissions.role.discussion_manager": "Discussion managers", + "permissions.role.admin": "Administrators", + "permissions.loading": "Loading…", + "permissions.loadFailed": "Could not load. Reload and try again.", + "permissions.saveFailed": "Could not save. Try again.", + "permissions.saved": "Permissions saved. Reload relevant pages to update action buttons.", + "permissions.unsaved": "Changes have not been saved.", + "permissions.ownerOnly": "Only a super administrator can manage permission groups.", + "permissions.invalid": "Invalid permission group settings.", + "permissions.conflict": "Permission groups changed in another session. Reload and try again.", + "permissions.denied": "You do not have permission to perform this operation.", + "policies.title": "Terms and privacy policy", + "policies.help": "Edit and publish these public documents using Wiki/Markdown syntax. Adapt the default templates to your site. Publishing preserves revision history and restricts editing to administrators.", + "policies.terms": "Terms of service", + "policies.privacy": "Privacy policy", + "policies.publish": "Save and publish", + "policies.template": "Use default template", + "policies.view": "View published page", + "policies.confirmTemplate": "Replace the editor content with the default template? Unsaved edits will be overwritten.", + "policies.empty": "Content cannot be empty.", + "policies.updateSummary": "Update site terms or privacy policy", + "policies.saved": "Published. Revision history has been preserved.", + "permissions.key.wiki:create": "Create documents", + "permissions.key.wiki:edit": "Edit documents", + "permissions.key.wiki:delete": "Delete documents (recoverable)", + "permissions.key.wiki:restore": "Restore deleted documents", + "permissions.key.wiki:move": "Move / rename documents", + "permissions.key.wiki:revert": "Revert documents", + "permissions.key.revision:delete": "Hide historical revisions", + "permissions.key.wiki:private": "View / set private documents", + "permissions.key.media:upload": "Upload media", + "permissions.key.media:delete": "Delete media", + "permissions.key.comment:create": "Start discussions / post comments", + "permissions.key.discussion:manage": "Manage discussions / comments", + "permissions.key.ticket:create": "Create tickets", + "permissions.key.ticket:manage": "Manage / delete tickets", + "permissions.key.user:manage": "Approve / ban users", + "permissions.key.wiki:manage": "Manage page permissions / edit restrictions", + "policies.footer.terms": "Terms of service", + "policies.footer.privacy": "Privacy policy", + "git.title": "Git editing", + "git.intro": "Each page has its own repository containing page.md. Use any username and your Git token as the password.", + "git.limits": "Only linear fast-forward updates to main are supported. History replacement, branch deletion, tags and merge commits are rejected. Limit: 40 commits and 4 MiB per push; page.md at most 512 KiB. Initial import includes up to 20 revisions; fetch includes up to 100 visible commits. Hidden or purged revisions form a shallow boundary. A batch of Git commits creates one Wiki revision.", + "git.token": "Git access token", + "git.generate": "Generate / rotate token", + "git.revoke": "Revoke token", + "git.clone": "Clone and push", + "git.commands": "After editing page.md:", + "git.back": "Back to Wiki", + "git.tokenHint": "The token is shown once and expires in 30 days. Rotating or revoking immediately invalidates the previous token. Page permissions and ACLs still apply. Git author fields are not verified identities.", + "git.updated": "Token updated. Open Git from a page’s More menu to get its repository URL.", + "git.failed": "Operation failed. Sign in again and retry.", + "git.confirm": "Generating a new token immediately invalidates the old token. Continue?", + "permissions.key.git:push": "Push through Git" } diff --git a/packages/wiki-shared/src/i18n/source.json b/packages/wiki-shared/src/i18n/source.json index 5f5d6a8..168e5fd 100644 --- a/packages/wiki-shared/src/i18n/source.json +++ b/packages/wiki-shared/src/i18n/source.json @@ -3429,5 +3429,65 @@ "m_302bae1279382d2b": "이미지", "m_b68c7d1bc9592573": "카테고리", "m_25f0d827e03ff670": "+$1줄", - "m_75cb099b9144f625": "-$1줄" + "m_75cb099b9144f625": "-$1줄", + "permissions.title": "权限组", + "permissions.help": "分别设置用户、讨论管理员和管理员可执行的操作。最高管理员始终拥有全部权限;游客、封禁用户、后台访问和永久删除保持固定保护。媒体删除、用户管理和页面权限管理需要管理员身份。保存后,新请求立即使用新权限,已打开的页面需刷新。", + "permissions.save": "保存权限", + "permissions.defaults": "恢复默认勾选", + "permissions.operation": "操作", + "permissions.role.user": "普通用户", + "permissions.role.discussion_manager": "讨论管理员", + "permissions.role.admin": "管理员", + "permissions.loading": "正在加载…", + "permissions.loadFailed": "加载失败,请刷新后重试。", + "permissions.saveFailed": "保存失败,请重试。", + "permissions.saved": "权限已保存。刷新相关页面后可看到按钮变化。", + "permissions.unsaved": "尚未保存。", + "permissions.ownerOnly": "只有最高管理员可以管理权限组。", + "permissions.invalid": "权限组设置无效。", + "permissions.conflict": "权限组已被其他页面修改,请刷新后重试。", + "permissions.denied": "你没有执行此操作的权限。", + "policies.title": "服务条款与隐私协议", + "policies.help": "在这里编辑并发布两份公开文档。支持 Wiki/Markdown 语法,默认模板可直接修改;发布会保留修订历史,文档仅允许管理员编辑。请根据实际运营情况完善模板。", + "policies.terms": "服务条款", + "policies.privacy": "隐私政策", + "policies.publish": "保存并发布", + "policies.template": "填入默认模板", + "policies.view": "查看公开页面", + "policies.confirmTemplate": "用默认模板替换编辑框内容?尚未保存的修改会被覆盖。", + "policies.empty": "内容不能为空。", + "policies.updateSummary": "更新站点服务条款或隐私政策", + "policies.saved": "已发布,文档修订历史已保留。", + "permissions.key.wiki:create": "创建文档", + "permissions.key.wiki:edit": "编辑文档", + "permissions.key.wiki:delete": "删除文档(可恢复)", + "permissions.key.wiki:restore": "恢复已删除文档", + "permissions.key.wiki:move": "修改文档地址", + "permissions.key.wiki:revert": "回退文档版本", + "permissions.key.revision:delete": "隐藏历史修订", + "permissions.key.wiki:private": "查看及设置私密文档", + "permissions.key.media:upload": "上传图片与文件", + "permissions.key.media:delete": "删除图片与文件", + "permissions.key.comment:create": "发起讨论及发表评论", + "permissions.key.discussion:manage": "管理讨论与讨论评论", + "permissions.key.ticket:create": "创建工单", + "permissions.key.ticket:manage": "管理及删除工单", + "permissions.key.user:manage": "审核用户及封禁账号", + "permissions.key.wiki:manage": "管理页面权限与编辑限制", + "policies.footer.terms": "服务条款", + "policies.footer.privacy": "隐私政策", + "git.title": "Git 편집", + "git.intro": "각 문서는 page.md 파일을 포함한 독립 저장소를 가집니다. 사용자 이름은 임의로 입력하고 Git 토큰을 비밀번호로 사용하세요.", + "git.limits": "main 브랜치의 선형 빨리 감기만 지원합니다. 이력 덮어쓰기, 브랜치 삭제, 태그 및 병합 커밋은 거부합니다. 푸시당 40개 커밋, 4 MiB, 본문은 512 KiB까지 가능합니다. 최초 가져오기는 최대 20개 리비전, 조회는 최대 100개 공개 커밋이며 숨김·영구 삭제 리비전에서 이력이 잘립니다. Git 커밋 묶음은 하나의 위키 리비전으로 기록됩니다.", + "git.token": "Git 전용 토큰", + "git.generate": "토큰 생성 / 교체", + "git.revoke": "토큰 폐기", + "git.clone": "복제 및 푸시", + "git.commands": "page.md를 편집한 후:", + "git.back": "위키로 돌아가기", + "git.tokenHint": "토큰은 한 번만 표시되며 30일 후 만료됩니다. 교체나 폐기 시 이전 토큰은 즉시 무효화됩니다. 문서 권한과 ACL이 적용되며 Git 작성자 정보는 검증된 신원이 아닙니다.", + "git.updated": "토큰이 업데이트되었습니다. 문서의 더보기 메뉴에서 Git을 열어 저장소 주소를 확인하세요.", + "git.failed": "작업에 실패했습니다. 다시 로그인한 후 재시도하세요.", + "git.confirm": "새 토큰을 생성하면 이전 토큰이 즉시 무효화됩니다. 계속할까요?", + "permissions.key.git:push": "Git으로 제출" } diff --git a/packages/wiki-shared/src/i18n/zh-CN.json b/packages/wiki-shared/src/i18n/zh-CN.json index abe4d5d..ebc37de 100644 --- a/packages/wiki-shared/src/i18n/zh-CN.json +++ b/packages/wiki-shared/src/i18n/zh-CN.json @@ -3429,5 +3429,65 @@ "m_302bae1279382d2b": "图片", "m_b68c7d1bc9592573": "分类", "m_25f0d827e03ff670": "+$1 行", - "m_75cb099b9144f625": "-$1 行" + "m_75cb099b9144f625": "-$1 行", + "permissions.title": "权限组", + "permissions.help": "分别设置用户、讨论管理员和管理员可执行的操作。最高管理员始终拥有全部权限;游客、封禁用户、后台访问和永久删除保持固定保护。媒体删除、用户管理和页面权限管理需要管理员身份。保存后,新请求立即使用新权限,已打开的页面需刷新。", + "permissions.save": "保存权限", + "permissions.defaults": "恢复默认勾选", + "permissions.operation": "操作", + "permissions.role.user": "普通用户", + "permissions.role.discussion_manager": "讨论管理员", + "permissions.role.admin": "管理员", + "permissions.loading": "正在加载…", + "permissions.loadFailed": "加载失败,请刷新后重试。", + "permissions.saveFailed": "保存失败,请重试。", + "permissions.saved": "权限已保存。刷新相关页面后可看到按钮变化。", + "permissions.unsaved": "尚未保存。", + "permissions.ownerOnly": "只有最高管理员可以管理权限组。", + "permissions.invalid": "权限组设置无效。", + "permissions.conflict": "权限组已被其他页面修改,请刷新后重试。", + "permissions.denied": "你没有执行此操作的权限。", + "policies.title": "服务条款与隐私协议", + "policies.help": "在这里编辑并发布两份公开文档。支持 Wiki/Markdown 语法,默认模板可直接修改;发布会保留修订历史,文档仅允许管理员编辑。请根据实际运营情况完善模板。", + "policies.terms": "服务条款", + "policies.privacy": "隐私政策", + "policies.publish": "保存并发布", + "policies.template": "填入默认模板", + "policies.view": "查看公开页面", + "policies.confirmTemplate": "用默认模板替换编辑框内容?尚未保存的修改会被覆盖。", + "policies.empty": "内容不能为空。", + "policies.updateSummary": "更新站点服务条款或隐私政策", + "policies.saved": "已发布,文档修订历史已保留。", + "permissions.key.wiki:create": "创建文档", + "permissions.key.wiki:edit": "编辑文档", + "permissions.key.wiki:delete": "删除文档(可恢复)", + "permissions.key.wiki:restore": "恢复已删除文档", + "permissions.key.wiki:move": "修改文档地址", + "permissions.key.wiki:revert": "回退文档版本", + "permissions.key.revision:delete": "隐藏历史修订", + "permissions.key.wiki:private": "查看及设置私密文档", + "permissions.key.media:upload": "上传图片与文件", + "permissions.key.media:delete": "删除图片与文件", + "permissions.key.comment:create": "发起讨论及发表评论", + "permissions.key.discussion:manage": "管理讨论与讨论评论", + "permissions.key.ticket:create": "创建工单", + "permissions.key.ticket:manage": "管理及删除工单", + "permissions.key.user:manage": "审核用户及封禁账号", + "permissions.key.wiki:manage": "管理页面权限与编辑限制", + "policies.footer.terms": "服务条款", + "policies.footer.privacy": "隐私政策", + "git.title": "Git 提交", + "git.intro": "每篇文档拥有独立仓库,文件为 page.md。用户名可任意填写,密码填写下方生成的 Git 令牌。", + "git.limits": "仅支持 main 分支的线性快进提交。禁止覆盖历史、删除分支、标签及合并提交。每次推送最多 40 个提交、4 MiB;正文最大 512 KiB。初次导入最多 20 条修订,每次读取最多 100 个可见提交;隐藏或清除的修订会截断历史。批量 Git 提交对应一条 Wiki 修订。", + "git.token": "Git 专用令牌", + "git.generate": "生成 / 更换令牌", + "git.revoke": "撤销令牌", + "git.clone": "克隆与提交", + "git.commands": "编辑 page.md 后执行:", + "git.back": "返回 Wiki", + "git.tokenHint": "令牌只显示一次,30 天后过期。更换或撤销后旧令牌立即失效。提交仍受文档编辑权限和 ACL 限制;Git 作者字段不代表已验证的用户身份。", + "git.updated": "令牌已更新。请从文档的“更多”菜单进入 Git,获取该文档的仓库地址。", + "git.failed": "操作失败,请重新登录后重试。", + "git.confirm": "生成新令牌会使旧令牌立即失效,继续?", + "permissions.key.git:push": "通过 Git 提交" } diff --git a/public/components/footer.html b/public/components/footer.html index 765a46d..e595b47 100644 --- a/public/components/footer.html +++ b/public/components/footer.html @@ -2,6 +2,7 @@ diff --git a/scripts/localize-shells.mjs b/scripts/localize-shells.mjs index a278d03..ce05bbf 100644 --- a/scripts/localize-shells.mjs +++ b/scripts/localize-shells.mjs @@ -11,15 +11,27 @@ const attrs = new Set(['title', 'alt', 'placeholder', 'aria-label', 'content']); export function localizeShell(html, locale, catalog) { const document = parse(html); - const text = value => { + const text = (value, title = false) => { const key = lookup.get(value.trim()); - if (!key) return value; + if (!key) { + // Static layouts substitute the configured site name before localization. + // Translate only known title labels and preserve that user-provided name. + if (title) for (const [source, titleKey] of lookup) { + const suffix = 'VireoWiki'; + const translated = catalog[titleKey]; + if (source.endsWith(suffix) && translated?.endsWith(suffix)) { + const prefix = source.slice(0, -suffix.length); + if (prefix && value.trim().startsWith(prefix)) return translated.slice(0, -suffix.length) + value.trim().slice(prefix.length); + } + } + return value; + } return (value.match(/^\s*/)?.[0] ?? '') + (catalog[key] ?? value.trim()) + (value.match(/\s*$/)?.[0] ?? ''); }; function visit(node, excluded = false) { const protectedContent = excluded || ['script', 'style', 'pre', 'code'].includes(node.tagName) || (node.attrs ?? []).some(a => a.name === 'data-no-i18n'); - if (node.nodeName === '#text' && !protectedContent) node.value = text(node.value); + if (node.nodeName === '#text' && !protectedContent) node.value = text(node.value, node.parentNode?.tagName === 'title'); for (const attr of node.attrs ?? []) { if (node.tagName === 'html' && attr.name === 'lang') attr.value = locale; else if (attrs.has(attr.name) && !protectedContent) { diff --git a/scripts/test-git.mjs b/scripts/test-git.mjs new file mode 100644 index 0000000..94f7d15 --- /dev/null +++ b/scripts/test-git.mjs @@ -0,0 +1,11 @@ +import { build } from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +const dir = await mkdtemp(join(tmpdir(), 'vireo-git-')); +try { + const outfile = join(dir, 'git.test.mjs'); + await build({ entryPoints: ['tests/git.test.ts'], outfile, bundle: true, platform: 'node', format: 'esm', banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" } }); + process.exitCode = spawnSync(process.execPath, ['--test', outfile], { stdio: 'inherit' }).status ?? 1; +} finally { await rm(dir, { recursive: true, force: true }); } diff --git a/scripts/test-permissions.mjs b/scripts/test-permissions.mjs new file mode 100644 index 0000000..a7d2dae --- /dev/null +++ b/scripts/test-permissions.mjs @@ -0,0 +1,11 @@ +import { build } from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +const dir = await mkdtemp(join(tmpdir(), 'vireo-permissions-')); +try { + const outfile = join(dir, 'permissions.test.mjs'); + await build({ entryPoints: ['tests/permissions.test.ts'], outfile, bundle: true, platform: 'node', format: 'esm', banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" } }); + process.exitCode = spawnSync(process.execPath, ['--test', outfile], { stdio: 'inherit' }).status ?? 1; +} finally { await rm(dir, { recursive: true, force: true }); } diff --git a/src/client/admin/site-management.ts b/src/client/admin/site-management.ts new file mode 100644 index 0000000..5df4d38 --- /dev/null +++ b/src/client/admin/site-management.ts @@ -0,0 +1,109 @@ +import { ui } from '../../../packages/wiki-shared/src/i18n/client'; +import { editablePermission } from '../../shared/permissionCatalog'; +import { DEFAULT_SITE_POLICIES } from '../../shared/sitePolicies'; + +type Matrix = { roles: string[]; keys: string[]; groups: Record; defaults: Record; version: number }; +export function mountSiteManagement(showTab: (id: string) => void): void { + const user = (window as any).currentUser; + if (user?.role !== 'super_admin') return; + const nav = document.querySelector('.admin-sidebar-nav'), body = document.querySelector('.wiki-container'); + if (!nav || !body) return; + function addTab(id: string, label: string): HTMLElement { + const button = document.createElement('a'); + button.href = '#'; button.className = 'admin-nav-item'; button.textContent = label; + button.setAttribute('onclick', `showTab('${id}'); return false;`); + nav!.append(button); + const pane = document.createElement('section'); pane.id = id; pane.className = 'tab-pane'; body!.append(pane); + return pane; + } + const pane = addTab('tab-permission-groups', ui('permissions.title')); + const heading = document.createElement('h3'); heading.textContent = ui('permissions.title'); pane.append(heading); + const help = document.createElement('p'); help.textContent = ui('permissions.help'); pane.append(help); + const status = document.createElement('p'); status.setAttribute('role', 'status'); pane.append(status); + const table = document.createElement('div'); table.className = 'table-responsive'; pane.append(table); + const save = document.createElement('button'); save.type = 'button'; save.className = 'btn btn-wiki me-2'; save.textContent = ui('permissions.save'); save.disabled = true; + const reset = document.createElement('button'); reset.type = 'button'; reset.className = 'btn btn-wiki-outline'; reset.textContent = ui('permissions.defaults'); reset.disabled = true; + pane.append(save, reset); + let matrix: Matrix; + function render(groups: Record): void { + table.replaceChildren(); const grid = document.createElement('table'); grid.className = 'table align-middle'; + const head = document.createElement('thead'), header = document.createElement('tr'); + for (const text of [ui('permissions.operation'), ...matrix.roles.map(r => ui('permissions.role.' + r))]) { + const th = document.createElement('th'); th.textContent = text; header.append(th); + } + head.append(header); grid.append(head); const rows = document.createElement('tbody'); + for (const key of matrix.keys) { + const row = document.createElement('tr'), title = document.createElement('th'); title.scope = 'row'; title.textContent = ui('permissions.key.' + key); row.append(title); + for (const role of matrix.roles) { + const cell = document.createElement('td'), input = document.createElement('input'); input.type = 'checkbox'; input.className = 'form-check-input'; + input.dataset.role = role; input.dataset.permission = key; input.checked = groups[role]?.includes(key) ?? false; + input.disabled = !editablePermission(role, key); input.setAttribute('aria-label', `${ui('permissions.role.' + role)}: ${ui('permissions.key.' + key)}`); + cell.append(input); row.append(cell); + } + rows.append(row); + } + grid.append(rows); table.append(grid); + } + async function load() { + status.textContent = ui('permissions.loading'); + try { + const res = await fetch('/api/admin/permission-groups'); if (!res.ok) throw new Error(ui('permissions.loadFailed')); + matrix = await res.json(); render(matrix.groups); save.disabled = reset.disabled = false; status.textContent = ''; + } catch (e) { status.textContent = String((e as Error).message); } + } + save.addEventListener('click', async () => { + save.disabled = reset.disabled = true; + const groups: Record = Object.fromEntries(matrix.roles.map(r => [r, []])); + table.querySelectorAll('input:checked:not(:disabled)').forEach(input => groups[input.dataset.role!]!.push(input.dataset.permission!)); + try { + const res = await fetch('/api/admin/permission-groups', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ groups, version: matrix.version }) }); + const result = await res.json(); if (!res.ok) throw new Error(result.error); + matrix.version = result.version; status.textContent = ui('permissions.saved'); + } catch (e) { status.textContent = (e as Error).message; } + finally { save.disabled = reset.disabled = false; } + }); + reset.addEventListener('click', () => { render(matrix.defaults); status.textContent = ui('permissions.unsaved'); }); + void load(); + + const legal = addTab('tab-site-policies', ui('policies.title')); + const legalHeading = document.createElement('h3'); legalHeading.textContent = ui('policies.title'); legal.append(legalHeading); + const legalHelp = document.createElement('p'); legalHelp.textContent = ui('policies.help'); legal.append(legalHelp); + for (const kind of ['terms', 'privacy'] as const) { + const config = (window as any).appConfig; + const slug = (kind === 'terms' ? config.termsOfServiceSlug : config.privacyPolicySlug) || DEFAULT_SITE_POLICIES[kind].slug; + const section = document.createElement('div'); section.className = 'card p-3 mb-4'; + const label = document.createElement('h4'); label.textContent = ui('policies.' + kind); section.append(label); + const textarea = document.createElement('textarea'); textarea.className = 'form-control mb-3'; textarea.rows = 18; textarea.setAttribute('aria-label', label.textContent); section.append(textarea); + const actions = document.createElement('div'); section.append(actions); + const publish = document.createElement('button'); publish.type = 'button'; publish.className = 'btn btn-wiki me-2'; publish.textContent = ui('policies.publish'); publish.disabled = true; + const template = document.createElement('button'); template.type = 'button'; template.className = 'btn btn-wiki-outline me-2'; template.textContent = ui('policies.template'); + const view = document.createElement('a'); view.className = 'btn btn-wiki-outline'; view.href = '/w/' + encodeURIComponent(slug); view.target = '_blank'; view.rel = 'noopener'; view.textContent = ui('policies.view'); + const message = document.createElement('p'); message.className = 'mt-2'; message.setAttribute('role', 'status'); section.append(message); actions.append(publish, template, view); legal.append(section); + let version: number | null = null; + const defaultText = DEFAULT_SITE_POLICIES[kind].content.replaceAll('{{SITE_NAME}}', config.wikiName || 'Nodeloc Wiki'); + async function reload() { + try { + const res = await fetch('/api/w/' + encodeURIComponent(slug) + '?for_edit=true&nocache=1'); + if (res.status === 404) textarea.value = defaultText; + else { const page = await res.json(); if (!res.ok) throw new Error(page.error || ui('permissions.loadFailed')); textarea.value = page.content || ''; version = page.version; } + publish.disabled = false; + } catch (e) { message.textContent = (e as Error).message; } + } + template.addEventListener('click', () => { + if (!textarea.value || window.confirm(ui('policies.confirmTemplate'))) { textarea.value = defaultText; message.textContent = ui('permissions.unsaved'); } + }); + publish.addEventListener('click', async () => { + if (!textarea.value.trim()) { message.textContent = ui('policies.empty'); return; } + publish.disabled = true; + try { + const body: Record = { content: textarea.value, summary: ui('policies.updateSummary'), edit_acl: { flags: ['admin_only'], mode: 'all' } }; + if (version !== null) body.expected_version = version; + const res = await fetch('/api/w/' + encodeURIComponent(slug), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const data = await res.json(); if (!res.ok) throw new Error(data.error || ui('permissions.saveFailed')); + await reload(); message.textContent = ui('policies.saved'); + } catch (e) { message.textContent = (e as Error).message; } + finally { publish.disabled = false; } + }); + void reload(); + } +} diff --git a/src/client/pages/admin.ts b/src/client/pages/admin.ts index 40d9eae..6771fa9 100644 --- a/src/client/pages/admin.ts +++ b/src/client/pages/admin.ts @@ -14,6 +14,7 @@ // ── 탭 전환 로직 ── import { ui, getLocale } from '../../../packages/wiki-shared/src/i18n/client'; +import { mountSiteManagement } from '../admin/site-management'; function showTab(tabId) { document .querySelectorAll(".tab-pane") @@ -94,6 +95,7 @@ function showTab(tabId) { catAclPage = 1; renderCatAclList(); }); + mountSiteManagement(showTab); loadWikiSettings(); loadSignupPolicy(); loadDashStats(); diff --git a/src/client/pages/index.ts b/src/client/pages/index.ts index 13a6785..f0e47bb 100644 --- a/src/client/pages/index.ts +++ b/src/client/pages/index.ts @@ -48,7 +48,7 @@ import { createTocController } from '../article/toc'; getDoc: () => currentPage, getSlug: () => currentSlug || '', wikiName: () => (window.appConfig && window.appConfig.wikiName) || 'CloudWiki', - canCreateSubdoc: (slug) => !!(window.currentUser && window.currentUser.permissions && window.currentUser.permissions['wiki:edit']) && !slug.includes(':'), + canCreateSubdoc: (slug) => !!(window.currentUser && window.currentUser.permissions && window.currentUser.permissions['wiki:create']) && !slug.includes(':'), onCreateSubdoc: (slug) => createSubdoc(slug), includeAi: true, }; @@ -861,7 +861,7 @@ import { createTocController } from '../article/toc'; // 편집 권한 확인 const _aclAdminOnly = _aclFlags.includes('admin_only'); - const canEdit = window.currentUser && (isAdmin || !_aclAdminOnly); + const canEdit = window.currentUser?.permissions?.['wiki:edit'] && (isAdmin || !_aclAdminOnly); // 커맨드 팔레트 편집 단축키(`e`/"현재 문서 편집")용 정식 편집 대상 노출. // actionSlug 는 리다이렉트 시 canonical page.slug 이며, 편집 불가 시 null 로 비활성화한다. @@ -874,7 +874,7 @@ import { createTocController } from '../article/toc'; // 편집 요청 배지/드롭다운 — articleEditBtn 이 렌더된 뒤 실행해야 교체 대상이 존재한다. // 검토 가능한 편집 요청이 있고(서버 count>0, 검토 권한자 한정) 이 문서를 편집할 수 있는 사용자에게만 // 노출(admin_only 잠금 문서는 제외 — 승인은 서버에서 ACL 재평가하므로 UI 도 일치). 상단 배너도 같은 검토 UI. - const _canReviewEdit = window.currentUser && (isAdmin || !_aclAdminOnly); + const _canReviewEdit = window.currentUser?.permissions?.['wiki:edit'] && (isAdmin || !_aclAdminOnly); if (_canReviewEdit) { const _pendingSlug = page.slug; const _editSlug = actionSlug; @@ -920,22 +920,34 @@ import { createTocController } from '../article/toc'; moreActionsHtml += ui("m_6d209ac8e2b23236", [window.escapeHtml(_catName)]); } - if (canEdit) { - if (page.deleted_at && isAdmin) { + if (window.currentUser) { + if (page.deleted_at && (window.currentUser.permissions?.['wiki:restore'] || window.currentUser.role === 'super_admin')) { moreActionsHtml += ui("m_36c060161233f9b5", [window.escapeHtml(actionSlug), window.escapeHtml(actionSlug)]); } else if (!page.deleted_at) { - if (isAdmin) { + if (isAdmin || window.currentUser.permissions?.['wiki:move'] || window.currentUser.permissions?.['wiki:delete']) { const _isCategoryPage = _decodedActionSlug.startsWith('카테고리:'); const _categoryName = _isCategoryPage ? _decodedActionSlug.slice('카테고리:'.length) : ''; const _permButton = _isCategoryPage ? ui("m_05838fba19cd3545", [window.escapeHtml(_categoryName)]) : ui("m_8fe5b03b9e5c164c", [window.escapeHtml(actionSlug)]); - moreActionsHtml += ui("m_1eee44365beb0add", [_permButton, window.escapeHtml(actionSlug), window.escapeHtml(actionSlug)]); + moreActionsHtml += ui("m_1eee44365beb0add", [isAdmin && window.currentUser.permissions?.['wiki:manage'] ? _permButton : '', window.escapeHtml(actionSlug), window.escapeHtml(actionSlug)]); } } } } document.getElementById('articleMoreActions').innerHTML = moreActionsHtml; + if (window.currentUser && page.id && !page.deleted_at) { + const li = document.createElement('li'); + const link = document.createElement('a'); + link.className = 'dropdown-item'; link.href = '/git?page=' + page.id; link.textContent = ui('git.title'); + li.appendChild(link); document.getElementById('articleMoreActions').appendChild(li); + } + const operationButtons = [['promptMove', 'wiki:move'], ['confirmDelete', 'wiki:delete'], ['restorePage', 'wiki:restore']]; + for (const [handler, permission] of operationButtons) { + if (!window.currentUser?.permissions?.[permission]) document.querySelectorAll(`#articleMoreActions [onclick*="${handler}("]`).forEach(button => button.closest('li')?.remove()); + } + if (window.currentUser?.role !== 'super_admin') document.querySelectorAll('#articleMoreActions [onclick*="confirmHardDelete("]').forEach(button => button.closest('li')?.remove()); + // 주시 상태 확인 (로그인 시) if (window.currentUser && document.getElementById('watchToggleBtn')) { @@ -1114,7 +1126,7 @@ import { createTocController } from '../article/toc'; Swal.fire(ui("m_f56c6c82203b33f6"), ui("m_63f8fd344484659e"), 'info'); return; } - if (!(window.currentUser.permissions && window.currentUser.permissions['wiki:edit'])) { + if (!(window.currentUser.permissions && window.currentUser.permissions['wiki:create'])) { Swal.fire(ui("m_e32c8a6ddd7bcef3"), ui("m_8f827503b320e3e5"), 'error'); return; } @@ -1754,7 +1766,7 @@ import { createTocController } from '../article/toc'; // ── 문서 복원 (관리자) ── async function restorePage(slug) { - const isAdmin = window.currentUser && (window.currentUser.role === 'admin' || window.currentUser.role === 'super_admin'); + const isAdmin = window.currentUser?.permissions?.['wiki:restore']; if (!isAdmin) { Swal.fire(ui("m_e32c8a6ddd7bcef3"), ui("m_639865d45485dba9"), 'error'); return; diff --git a/src/client/pages/revisions.ts b/src/client/pages/revisions.ts index 4bd0287..27706ad 100644 --- a/src/client/pages/revisions.ts +++ b/src/client/pages/revisions.ts @@ -170,7 +170,7 @@ async function showRevisions(slug, page = 1) { // - 일반 admin: !isDeleted 일 때만 노출 → 클릭 시 소프트 삭제 확인만. // - super_admin: !isFullyPurged 일 때 노출 → 클릭 시 모달 내부의 체크박스로 영구 삭제 여부를 선택. // - 최신 리비전(last_revision_id)은 일관성 보호를 위해 모두 차단. - const canDeleteThisRow = isAdminView && !isLastRev && (canHardDelete ? !isFullyPurged : !isDeleted); + const canDeleteThisRow = (canHardDelete || window.currentUser?.permissions?.['revision:delete']) && !isLastRev && (canHardDelete ? !isFullyPurged : !isDeleted); const isPartialPurge = isPurged && !isFullyPurged; const deleteBtnLabel = isPartialPurge ? ui("m_530ec947a64b994f") : ui("m_2f9daa828907b93f"); const deleteBtnDangerClass = (canHardDelete && (isDeleted || isPartialPurge)) ? ' text-danger' : ''; @@ -195,6 +195,7 @@ async function showRevisions(slug, page = 1) { document.getElementById('loading').classList.add('d-none'); document.getElementById('revisionsPage').classList.remove('d-none'); + if (!window.currentUser?.permissions?.['wiki:revert']) listEl.querySelectorAll('[onclick*="confirmRevert("]').forEach(button => { button.disabled = true; button.setAttribute('aria-disabled', 'true'); }); window.initRoleIconPopovers(listEl); } catch (err) { diff --git a/src/durable/adminJob.ts b/src/durable/adminJob.ts index a80f1f5..38541cb 100644 --- a/src/durable/adminJob.ts +++ b/src/durable/adminJob.ts @@ -618,7 +618,7 @@ export class AdminJobDO { const payload = (await this.state.storage.get(PAYLOAD_KEY))!; const result = s.result as unknown as BulkDeleteResult; const db = this.env.DB; - const rbac = new RBAC(); + const rbac = await RBAC.load(db); const sink: Promise[] = []; const shim = this.makeShim(sink); @@ -803,7 +803,7 @@ export class AdminJobDO { const payload = (await this.state.storage.get(PAYLOAD_KEY))!; const result = s.result as unknown as BulkMoveResult; const db = this.env.DB; - const rbac = new RBAC(); + const rbac = await RBAC.load(db); const sink: Promise[] = []; const shim = this.makeShim(sink); diff --git a/src/git/objectStore.ts b/src/git/objectStore.ts new file mode 100644 index 0000000..4836b23 --- /dev/null +++ b/src/git/objectStore.ts @@ -0,0 +1,129 @@ +import { Buffer } from 'node:buffer'; +import { Inflate } from 'pako'; +import * as git from 'isomorphic-git'; +const GITDIR = '/repo/.git'; +function missing(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); } +/** Request-local quarantine. Only validated, reachable loose objects are persisted. */ +export class ObjectStore { + private files = new Map(); + private folders = new Set(['/repo', GITDIR, `${GITDIR}/objects`, `${GITDIR}/objects/pack`]); + private bytes = 0; + private reads = 0; + readonly cache = {}; + constructor(private bucket: R2Bucket, readonly pageId: number) {} + readonly promises = { + readFile: async (path: string, options?: any): Promise => { + let value = this.files.get(path); + if (!value) { + const match = path.match(/^\/repo\/\.git\/objects\/([a-f0-9]{2})\/([a-f0-9]{38})$/); + if (!match) throw missing(); + if (++this.reads > 800) throw new Error('Repository is too large for one request'); + const object = await this.bucket.get(`git/pages/${this.pageId}/objects/${match[1]}${match[2]}`); + if (!object) throw missing(); + value = Buffer.from(await object.arrayBuffer()); this.files.set(path, value); + } + return typeof options === 'string' || options?.encoding ? value.toString(typeof options === 'string' ? options as BufferEncoding : options.encoding) : value; + }, + writeFile: async (path: string, value: any): Promise => { + const data = Buffer.from(value); + this.bytes += data.length; + if (this.bytes > 16 * 1024 * 1024) throw new Error('Repository object limit exceeded'); + this.files.set(path, data); + }, + mkdir: async (path: string): Promise => { this.folders.add(path); }, + readdir: async (path: string): Promise => [...new Set([...this.files.keys(), ...this.folders].filter(p => p.startsWith(path + '/')).map(p => p.slice(path.length + 1).split('/')[0]))], + stat: async (path: string): Promise => { + if (this.folders.has(path)) return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false, size: 0, mode: 0o40755 }; + const data = await this.promises.readFile(path); + return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false, size: data.length, mode: 0o100644 }; + }, + lstat: async (path: string): Promise => this.promises.stat(path), + unlink: async (path: string): Promise => { this.files.delete(path); }, + rmdir: async (path: string): Promise => { this.folders.delete(path); }, + symlink: async (): Promise => { throw new Error('Symlinks are disabled'); }, + readlink: async (): Promise => { throw missing(); }, + }; + get args() { return { fs: this as any, dir: '/repo', gitdir: GITDIR, cache: this.cache }; } + async blob(content: string) { return git.writeBlob({ ...this.args, blob: Buffer.from(content) }); } + async tree(blob: string) { return git.writeTree({ ...this.args, tree: [{ mode: '100644', path: 'page.md', oid: blob, type: 'blob' }] }); } + async snapshot(content: string, parent: string | null, author: string, timestamp: number, message: string) { + const tree = await this.tree(await this.blob(content)); + return git.writeCommit({ ...this.args, commit: { tree, parent: parent ? [parent] : [], + author: { name: author.replace(/[<>\r\n]/g, ' '), email: 'wiki@localhost', timestamp, timezoneOffset: 0 }, + committer: { name: 'VireoWiki', email: 'wiki@localhost', timestamp, timezoneOffset: 0 }, message: message.replace(/\0/g, '') } }); + } + async content(oid: string): Promise<{ text: string; objects: string[] }> { + const commit = (await git.readCommit({ ...this.args, oid })).commit; + const tree = (await git.readTree({ ...this.args, oid: commit.tree })).tree; + if (tree.length !== 1 || tree[0].path !== 'page.md' || tree[0].type !== 'blob' || tree[0].mode !== '100644') throw new Error('The repository must contain only a regular page.md file'); + const blob = await git.readBlob({ ...this.args, oid: tree[0].oid }); + if (blob.blob.length > 512 * 1024) throw new Error('page.md exceeds 512 KiB'); + const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(blob.blob); + if (text.includes('\0')) throw new Error('Binary files are not supported'); + return { text, objects: [oid, commit.tree, tree[0].oid] }; + } + async persist(oids: string[]) { + for (const oid of new Set(oids)) { + const object = await git.readObject({ ...this.args, oid, format: 'content' }); + // Re-encode each verified object as a loose object; quarantine pack files never become public. + const written = await git.writeObject({ ...this.args, type: object.type as 'blob' | 'tree' | 'commit' | 'tag', object: object.object as Uint8Array, format: 'content' }); + if (written !== oid) throw new Error('Object identity mismatch'); + const value = await this.promises.readFile(`${GITDIR}/objects/${oid.slice(0, 2)}/${oid.slice(2)}`); + await this.bucket.put(`git/pages/${this.pageId}/objects/${oid}`, value); + } + } + async unpack(pack: Uint8Array) { + if (pack.length < 32 || pack.length > 4 * 1024 * 1024 || Buffer.from(pack.subarray(0, 4)).toString() !== 'PACK') throw new Error('Invalid or oversized pack'); + if (new DataView(pack.buffer, pack.byteOffset, pack.byteLength).getUint32(8) > 200) throw new Error('Too many pack objects'); + validatePackBounds(pack); + const trailer = Buffer.from(pack.subarray(pack.length - 20)).toString('hex'); + const checksum = Buffer.from(await crypto.subtle.digest('SHA-1', pack.subarray(0, pack.length - 20))).toString('hex'); + if (trailer !== checksum) throw new Error('Pack checksum mismatch'); + const path = `.git/objects/pack/pack-${trailer}.pack`; + await this.promises.writeFile(`/repo/${path}`, pack); + return git.indexPack({ ...this.args, filepath: path }); + } + async pack(oids: string[]) { return (await git.packObjects({ ...this.args, oids: [...new Set(oids)] })).packfile!; } +} +export async function newCommits(store: ObjectStore, oldOid: string, newOid: string): Promise { + const commits: string[] = []; let cursor = newOid; + while (cursor !== oldOid) { + if (commits.length >= 40 || commits.includes(cursor)) throw new Error('Push requires a linear history of at most 40 commits'); + const commit = (await git.readCommit({ ...store.args, oid: cursor })).commit; + if (commit.parent.length !== 1) throw new Error('Non-fast-forward and merge pushes are disabled'); + commits.push(cursor); cursor = commit.parent[0]; + } + return commits.reverse(); +} + +/** Bound inflated object sizes before the Git indexer allocates delta buffers. */ +export function validatePackBounds(pack: Uint8Array) { + let offset = 12, total = 0; + const count = new DataView(pack.buffer, pack.byteOffset, pack.byteLength).getUint32(8); + for (let object = 0; object < count; object++) { + let byte = pack[offset++], size = byte & 15, shift = 4; + const type = (byte >> 4) & 7; + while (byte & 128) { + if (shift > 28 || offset >= pack.length - 20) throw new Error('Invalid object size'); + byte = pack[offset++]; size += (byte & 127) * 2 ** shift; shift += 7; + } + if (size > 1024 * 1024 || ![1, 2, 3, 6, 7].includes(type)) throw new Error('Invalid or oversized object'); + if (type === 7) offset += 20; + if (type === 6) { let value; do { value = pack[offset++]; if (offset >= pack.length - 20) throw new Error('Invalid delta'); } while (value & 128); } + const inflate: any = new Inflate({ chunkSize: 16384 }); + let inflated = 0; const delta: Uint8Array[] = []; + inflate.onData = (chunk: Uint8Array) => { if (type === 6 || type === 7) delta.push(chunk); inflated += chunk.length; total += chunk.length; if (inflated > 1024 * 1024 || total > 8 * 1024 * 1024) throw new Error('Inflated pack limit exceeded'); }; + inflate.push(pack.subarray(offset, pack.length - 20), false); + if (!inflate.ended || inflate.err || inflated !== size || !inflate.strm.next_in) throw new Error('Invalid compressed object'); + if (type === 6 || type === 7) { + const data = Buffer.concat(delta); let cursor = 0; + for (let field = 0; field < 2; field++) { + let value = 0, shift = 0, byte; + do { if (cursor >= data.length || shift > 28) throw new Error('Invalid delta size'); byte = data[cursor++]; value += (byte & 127) * 2 ** shift; shift += 7; } while (byte & 128); + if (value > 1024 * 1024) throw new Error('Delta result exceeds object limit'); + } + } + offset += inflate.strm.next_in; + } + if (offset !== pack.length - 20) throw new Error('Unexpected pack data'); +} diff --git a/src/git/protocol.ts b/src/git/protocol.ts new file mode 100644 index 0000000..cbe372d --- /dev/null +++ b/src/git/protocol.ts @@ -0,0 +1,43 @@ +import { Buffer } from 'node:buffer'; +export const ZERO = '0'.repeat(40); +export const OID = /^[0-9a-f]{40}$/; +export const REF = 'refs/heads/main'; +export function packet(value: string | Uint8Array): Buffer { + const data = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value); + if (data.length > 65516) throw new Error('Packet too large'); + return Buffer.concat([Buffer.from((data.length + 4).toString(16).padStart(4, '0')), data]); +} +export function parsePackets(data: Uint8Array): { lines: string[]; rest: Buffer } { + const input = Buffer.from(data), lines: string[] = []; + let offset = 0; + while (offset + 4 <= input.length) { + const prefix = input.subarray(offset, offset + 4).toString(); + if (!/^[0-9a-fA-F]{4}$/.test(prefix)) throw new Error('Invalid packet'); + const length = parseInt(prefix, 16); offset += 4; + if (length === 0) return { lines, rest: input.subarray(offset) }; + if (length < 4 || length > 65520 || offset + length - 4 > input.length) throw new Error('Truncated packet'); + lines.push(input.subarray(offset, offset + length - 4).toString()); offset += length - 4; + if (lines.length > 4096) throw new Error('Too many packets'); + } + throw new Error('Missing flush packet'); +} +export function parseUpdate(data: Uint8Array) { + const { lines, rest } = parsePackets(data); + if (lines.length !== 1) throw new Error('Exactly one branch update is supported'); + const [command, capabilities = ''] = lines[0].split('\0'); + const [oldOid, newOid, ref, extra] = command.trim().split(' '); + if (!OID.test(oldOid) || !OID.test(newOid) || extra) throw new Error('Invalid reference update'); + if (ref !== REF || oldOid === ZERO || newOid === ZERO) throw new Error('Branch creation, deletion and other references are disabled'); + if (capabilities.trim().split(/\s+/).some(c => !['report-status', 'ofs-delta', 'agent=git', ''].includes(c) && !c.startsWith('agent='))) throw new Error('Unsupported push capability'); + return { oldOid, newOid, ref, pack: rest }; +} +export function advertisement(head: string, service: string, shallow: string | null = null) { + const caps = service === 'git-upload-pack' ? 'ofs-delta shallow symref=HEAD:refs/heads/main' : 'report-status ofs-delta'; + return Buffer.concat([packet(`# service=${service}\n`), Buffer.from('0000'), + packet(`${head} ${service === 'git-upload-pack' ? 'HEAD' : REF}\0${caps}\n`), + ...(service === 'git-upload-pack' ? [packet(`${head} ${REF}\n`)] : []), ...(shallow && service === 'git-upload-pack' ? [packet(`shallow ${shallow}\n`)] : []), Buffer.from('0000')]); +} +export function status(ok: boolean, message = 'rejected') { + const safe = message.replace(/[\r\n\0]/g, ' ').slice(0, 350); + return Buffer.concat([packet('unpack ok\n'), packet(`${ok ? 'ok' : 'ng'} ${REF}${ok ? '' : ' ' + safe}\n`), Buffer.from('0000')]); +} diff --git a/src/git/storage.ts b/src/git/storage.ts new file mode 100644 index 0000000..6d5f57e --- /dev/null +++ b/src/git/storage.ts @@ -0,0 +1,70 @@ +import type { Env } from '../types'; +import { ObjectStore } from './objectStore'; +import { getRevisionContent } from '../utils/r2'; +const migrations = new WeakMap>(); +export function ensureGit(db: D1Database) { + let pending = migrations.get(db); + if (!pending) { + pending = db.batch([ + db.prepare('CREATE TABLE IF NOT EXISTS git_tokens (user_id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, masked_token TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'), + db.prepare("CREATE TABLE IF NOT EXISTS git_page_heads (page_id INTEGER PRIMARY KEY, head TEXT, version INTEGER NOT NULL DEFAULT 0, lease TEXT, lease_until INTEGER NOT NULL DEFAULT 0)"), + db.prepare('CREATE TABLE IF NOT EXISTS git_commit_links (page_id INTEGER NOT NULL, oid TEXT NOT NULL, revision_id INTEGER, user_id INTEGER, PRIMARY KEY(page_id, oid))'), + ]).then(() => {}); + migrations.set(db, pending); pending.catch(() => migrations.delete(db)); + } + return pending; +} +export type GitPage = { id: number; slug: string; version: number; content: string; is_private: number; deleted_at: number | null; last_revision_id: number | null }; +export async function page(db: D1Database, id: number) { + return db.prepare('SELECT id, slug, version, content, is_private, deleted_at, last_revision_id FROM pages WHERE id = ?').bind(id).first(); +} +export async function acquire(db: D1Database, id: number) { + await ensureGit(db); + await db.prepare('INSERT OR IGNORE INTO git_page_heads (page_id) VALUES (?)').bind(id).run(); + const lease = crypto.randomUUID(); + const result = await db.prepare('UPDATE git_page_heads SET lease = ?, lease_until = unixepoch() + 120 WHERE page_id = ? AND lease_until < unixepoch()').bind(lease, id).run(); + if (!result.meta.changes) throw new Error('Repository is busy; retry shortly'); + return lease; +} +export async function release(db: D1Database, id: number, lease: string) { + await db.prepare('UPDATE git_page_heads SET lease = NULL, lease_until = 0 WHERE page_id = ? AND lease = ?').bind(id, lease).run(); +} +export async function recordHead(db: D1Database, id: number, lease: string, oid: string, version: number, revision: number | null, user: number | null, commits = [oid]) { + const updated = await db.batch([ + ...commits.map(commit => db.prepare('INSERT OR IGNORE INTO git_commit_links (page_id, oid, revision_id, user_id) SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM git_page_heads WHERE page_id = ? AND lease = ? AND lease_until >= unixepoch())').bind(id, commit, revision, user, id, lease)), + db.prepare('UPDATE git_page_heads SET head = ?, version = ?, lease_until = unixepoch() + 120 WHERE page_id = ? AND lease = ? AND lease_until >= unixepoch()').bind(oid, version, id, lease), + ]); + if (!updated.at(-1)!.meta.changes) throw new Error('Repository lease expired; fetch before retrying'); +} +/** Web changes are appended, never replace the existing Git head. Initial import is bounded. */ +export async function synchronize(env: Env['Bindings'], store: ObjectStore, current: GitPage, lease: string, origin: string) { + const db = env.DB; + let saved = await db.prepare('SELECT head, version FROM git_page_heads WHERE page_id = ?').bind(current.id).first<{ head: string | null; version: number }>(); + if (!saved) throw new Error('Repository not initialized'); + if (saved.version === current.version && saved.head) return saved.head; + const revisions = await db.prepare(`SELECT r.id, r.page_version, r.content, r.r2_key, r.summary, r.created_at, u.name + FROM revisions r LEFT JOIN users u ON u.id = r.author_id + WHERE r.page_id = ? AND r.page_version > ? AND r.page_version <= ? AND r.is_virtual = 0 AND r.deleted_at IS NULL AND r.purged_at IS NULL + ORDER BY r.page_version DESC LIMIT 20`).bind(current.id, saved.version, current.version) + .all<{ id: number; page_version: number; content: string; r2_key: string | null; summary: string | null; created_at: number; name: string | null }>(); + let head = saved.head; + for (const rev of revisions.results.reverse()) { + const content = await getRevisionContent(env.MEDIA, rev, origin); + const oid = await store.snapshot(content, head, rev.name || 'Wiki editor', rev.created_at, rev.summary || `Wiki revision #${rev.id}`); + await store.persist((await store.content(oid)).objects); + await recordHead(db, current.id, lease, oid, rev.page_version, rev.id, null); + head = oid; saved.version = rev.page_version; + } + if (saved.version !== current.version || !head) { + // Legacy pages may not have a revision row. Do not read a hidden/purged revision as fallback. + if (current.last_revision_id) { + const rev = await db.prepare('SELECT deleted_at, purged_at FROM revisions WHERE id = ?').bind(current.last_revision_id).first<{ deleted_at: number | null; purged_at: number | null }>(); + if (!rev || rev.deleted_at || rev.purged_at) throw new Error('Current revision is unavailable'); + } + const oid = await store.snapshot(current.content, head, 'Wiki editor', Math.floor(Date.now() / 1000), `Wiki version ${current.version}`); + await store.persist((await store.content(oid)).objects); + await recordHead(db, current.id, lease, oid, current.version, current.last_revision_id, null); + head = oid; + } + return head!; +} diff --git a/src/index.ts b/src/index.ts index 05ac928..e7f2407 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +import gitRoutes from './routes/git'; +import { HTTPException } from 'hono/http-exception'; +import permissionGroupsRoutes from './routes/permissionGroups'; import { ui, getLocale, localeMiddleware } from './i18n/server'; import { Hono, Context } from 'hono'; import robotsTxtBase from './robots-txt'; @@ -52,6 +55,7 @@ app.use('*', secureHeaders()); // /oauth/authorize 는 위키 도메인의 동의 폼에서 POST 되므로 CSRF 적용 (Origin 자동 검증). app.use('*', (c, next) => { const path = c.req.path; + if (/^\/git\/pages\/[1-9][0-9]*\.git\/git-(upload|receive)-pack$/.test(path)) return next(); if (path === '/api/mcp' || path.startsWith('/api/mcp/')) return next(); if (path === '/oauth/token' || path === '/oauth/register' || path === '/oauth/revoke') return next(); return csrf()(c, next); @@ -149,10 +153,12 @@ app.use('*', async (c, next) => { }); // ── 라우트 등록 ── +app.route('/', gitRoutes); app.route('/', authRoutes); app.route('/api', wikiRoutes); app.route('/api', searchRoutes); app.route('/', mediaRoutes); +app.route('/api/admin/permission-groups', permissionGroupsRoutes); app.route('/api/admin', adminRoutes); app.route('/api/admin', adminJobRoutes); app.route('/api', discussionRoutes); @@ -171,6 +177,9 @@ app.route('/api', qrLoginRoutes); // ── Service Worker (/sw.js) ── // Vite 빌드 산출물을 /dist/sw.js 로 두고, 루트 스코프 부여를 위해 /sw.js 로 위임한다. // Service-Worker-Allowed: / 헤더와 짧은 캐시를 함께 부여. +app.get('/terms', c => c.redirect('/w/' + encodeURIComponent(c.env.TERMS_OF_SERVICE || 'Wiki/服务条款'))); +app.get('/privacy', c => c.redirect('/w/' + encodeURIComponent(c.env.PRIVACY_POLICY || 'Wiki/隐私政策'))); + app.get('/sw.js', async (c) => { const url = new URL(c.req.url); url.pathname = '/dist/sw.js'; @@ -1176,6 +1185,7 @@ app.notFound(async (c) => { // ── 에러 핸들러 ── app.onError(async (err, c) => { + if (err instanceof HTTPException) return err.getResponse(); console.error('Unhandled error:', err); trackError(c, c.req.path, 500, err.message || 'Internal Server Error'); if (c.req.path.startsWith('/api/') || c.req.path.startsWith('/assets/')) { diff --git a/src/middleware/session.ts b/src/middleware/session.ts index 25b7985..159f237 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -14,7 +14,7 @@ import type { Env, User } from '../types'; * ROLE_PERMISSIONS_JSON 환경변수 오버라이드는 폐기됨. */ export const rbacMiddleware = createMiddleware(async (c, next) => { - c.set('rbac', new RBAC()); + c.set('rbac', await RBAC.load(c.env.DB)); await next(); }); diff --git a/src/routes/admin-mcp.ts b/src/routes/admin-mcp.ts index cb05a94..4f1e42c 100644 --- a/src/routes/admin-mcp.ts +++ b/src/routes/admin-mcp.ts @@ -1,3 +1,4 @@ +import { HTTPException } from 'hono/http-exception'; // 추가 MCP 도구 정의 + 디스패처 (일반 유저 편집 도구 + 관리자 전용 도구). // // 통합 MCP 엔드포인트 (/api/mcp) 가 호출 시점에 사용자 역할을 보고 이 모듈의 도구를 @@ -656,6 +657,7 @@ export async function applyExistingPageUpdate( content: string, opts: { summary: string | null; + requiredPermission?: 'wiki:edit' | 'wiki:revert'; category?: string | null; // undefined → 기존 유지, null/string → 덮어쓰기 redirectTo?: string | null; // undefined → 기존 유지, null/string → 덮어쓰기 title?: string | null; // undefined → 기존 유지, null → 제거, string → 설정. 호출자가 사전 충돌 검증을 마쳤다고 가정. @@ -669,6 +671,7 @@ export async function applyExistingPageUpdate( awaitLinkCategoryIndex?: boolean; // true 면 page_links/page_categories 재색인을 waitUntil 대신 await — 같은 페이지에 연속 리비전을 만드는 2-리비전 승인에서 rev1 의 재색인이 rev2 의 것과 경합/역전돼 중간 리비전 인덱스가 남는 것을 막는다(rev1 에만 사용). } ): Promise<{ revision_id: number; new_version: number; rows: number; characters: number }> { + if (!c.get('rbac').can(user.role, opts.requiredPermission ?? 'wiki:edit')) throw new HTTPException(403, { message: ui('permissions.denied') }); const db = c.env.DB; const enabledExt = getEnabledExtensions(c.env); const isR2Only = isR2OnlyNamespace(opts.slug, enabledExt); @@ -805,6 +808,7 @@ export async function applyNewPageInsert( awaitLinkCategoryIndex?: boolean; // true 면 page_links/page_categories 재색인을 await — 2-리비전 승인의 rev1(신규 생성)에서 rev2 재색인과의 경합/역전을 막는다. } ): Promise<{ page_id: number; revision_id: number; rows: number; characters: number }> { + if (!c.get('rbac').can(user.role, 'wiki:create')) throw new HTTPException(403, { message: ui('permissions.denied') }); const db = c.env.DB; const enabledExt = getEnabledExtensions(c.env); const isR2Only = isR2OnlyNamespace(slug, enabledExt); @@ -1609,7 +1613,8 @@ export async function dispatchAdminEditTool(c: Context, user: User, toolNam } if (toolName === 'revert_page') { - if (!rbac.can(user.role, 'wiki:edit')) { + if (!rbac.can(user.role, 'wiki:revert')) return asTextResult(ui('permissions.denied'), true); + if (!rbac.can(user.role, 'wiki:revert')) { return asTextResult(ui("m_eaf5dbfe425cfa3f"), true); } const slug = String(args.title || '').trim(); @@ -1678,6 +1683,7 @@ export async function dispatchAdminEditTool(c: Context, user: User, toolNam try { const result = await applyExistingPageUpdate(c, user, page, revContent, { + requiredPermission: 'wiki:revert', summary, slug, }); @@ -1707,10 +1713,11 @@ export async function dispatchAdminEditTool(c: Context, user: User, toolNam const hard = args.hard === true; const page = await db - .prepare('SELECT id, edit_acl FROM pages WHERE slug = ? AND deleted_at IS NULL') + .prepare('SELECT id, edit_acl, is_private FROM pages WHERE slug = ? AND deleted_at IS NULL') .bind(slug) - .first<{ id: number; edit_acl: string | null }>(); + .first<{ id: number; edit_acl: string | null; is_private: number }>(); if (!page) return asTextResult(ui("m_f4ec1684e2260eeb"), true); + if (page.is_private && !rbac.can(user.role, 'wiki:private')) return asTextResult(ui('permissions.denied'), true); if (hard) { if (!rbac.can(user.role, '*')) return asTextResult(ui("m_a1dbee63d898ee47"), true); @@ -1763,14 +1770,16 @@ export async function dispatchAdminEditTool(c: Context, user: User, toolNam } if (toolName === 'restore_page') { + if (!rbac.can(user.role, 'wiki:restore')) return asTextResult(ui('permissions.denied'), true); const slug = String(args.title || '').trim(); if (!slug) return asTextResult(ui("m_8ab6728713ad8858"), true); - if (!rbac.can(user.role, 'wiki:delete')) return asTextResult(ui("m_e71689f947fff666"), true); + if (!rbac.can(user.role, 'wiki:restore')) return asTextResult(ui("m_e71689f947fff666"), true); if (slug.startsWith('이미지:')) return asTextResult(ui("m_c0cae7a74ebe04c8"), true); if (slug.startsWith('map:')) return asTextResult(ui("m_0c5192f5f86de2aa"), true); - const page = await db.prepare('SELECT id, deleted_at FROM pages WHERE slug = ?').bind(slug).first<{ id: number; deleted_at: number | null }>(); + const page = await db.prepare('SELECT id, deleted_at, is_private FROM pages WHERE slug = ?').bind(slug).first<{ id: number; deleted_at: number | null; is_private: number }>(); if (!page) return asTextResult(ui("m_36f26fa461c07fd6"), true); + if (page.is_private && !rbac.can(user.role, 'wiki:private')) return asTextResult(ui('permissions.denied'), true); if (!page.deleted_at) return asTextResult(ui("m_f3cd5840152553ef"), true); await db.prepare('UPDATE pages SET deleted_at = NULL WHERE id = ?').bind(page.id).run(); @@ -1788,6 +1797,7 @@ export async function dispatchAdminEditTool(c: Context, user: User, toolNam } if (toolName === 'move_page') { + if (!rbac.can(user.role, 'wiki:move')) return asTextResult(ui('permissions.denied'), true); const oldSlug = String(args.title || '').trim(); const newSlug = String(args.new_title || '').trim(); if (!oldSlug || !newSlug) return asTextResult(ui("m_14e60317e5fae82c"), true); @@ -2149,6 +2159,7 @@ export async function dispatchAdminEditTool(c: Context, user: User, toolNam } if (toolName === 'set_page_status') { + if (!rbac.can(user.role, 'wiki:manage')) return asTextResult(ui('permissions.denied'), true); const slug = String(args.title || '').trim(); if (!slug) return asTextResult(ui("m_8ab6728713ad8858"), true); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 8524e90..66ebf00 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -12,7 +12,7 @@ import { refreshRecentChangesCache, invalidatePaletteUsers, } from '../utils/cacheInvalidation'; -import { requireAdmin } from '../middleware/session'; +import { requireAdmin, requirePermission } from '../middleware/session'; import { isSuperAdmin, getSuperAdmins, PRIVATE_AVATAR_PATH } from '../utils/auth'; import { RBAC, ROLE_CASE_SQL, enrichRoles } from '../utils/role'; import { fetchMediaTagMap, sanitizeTags } from '../utils/mediaTags'; @@ -65,6 +65,17 @@ import type { const adminRoutes = new Hono(); adminRoutes.use('*', requireAdmin); +adminRoutes.use('*', async (c, next) => { + if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { + const path = c.req.path; + const permission = /\/(users|signup-requests)(\/|$)/.test(path) ? 'user:manage' + : /\/(category-acl|doc-setting-prefix-rules|pages)(\/|$)/.test(path) ? 'wiki:manage' + : /\/(categories|category-prefix-rules|palettes)(\/|$)/.test(path) ? 'wiki:edit' : null; + if (permission && !c.get('rbac').can(c.get('user')!.role, permission)) return c.json({ error: ui('permissions.denied') }, 403); + } + await next(); +}); + // ── 관리 로그 기록 헬퍼 ── export function writeAdminLog(c: any, type: string, log: string, userId: number) { @@ -223,7 +234,7 @@ adminRoutes.put('/users/:id/role', async (c) => { return c.json({ success: true }); }); -adminRoutes.put('/users/:id/ban', async (c) => { +adminRoutes.put('/users/:id/ban', requirePermission('user:manage'), async (c) => { const db = c.env.DB; const targetUserId = c.req.param('id'); const { days } = await c.req.json(); @@ -1969,7 +1980,7 @@ adminRoutes.get('/media/:id/backlinks', async (c) => { * DELETE /media/:id * 이미지 삭제 (R2 + DB) */ -adminRoutes.delete('/media/:id', async (c) => { +adminRoutes.delete('/media/:id', requirePermission('media:delete'), async (c) => { const db = c.env.DB; const id = c.req.param('id'); diff --git a/src/routes/auth/index.ts b/src/routes/auth/index.ts index 2160623..738f32f 100644 --- a/src/routes/auth/index.ts +++ b/src/routes/auth/index.ts @@ -1,3 +1,4 @@ +import { PERMISSION_KEYS } from '../../utils/permissionGroups'; import { ui } from '../../i18n/server'; import { Hono } from 'hono'; import type { Context } from 'hono'; @@ -355,11 +356,7 @@ auth.get('/api/me', (c) => { return c.json({ error: ui("m_640f50ae9c6b8e41") }, 401); } const rbac = c.get('rbac') as RBAC; - const permissionKeys = [ - 'wiki:read', 'wiki:edit', 'wiki:delete', 'wiki:private', - 'comment:create', 'ticket:create', 'ticket:manage', - 'media:upload', 'discussion:manage', 'admin:access', 'user:manage', - ] as const; + const permissionKeys = [...PERMISSION_KEYS, 'wiki:read', 'admin:access']; const permissions: Record = {}; for (const key of permissionKeys) { permissions[key] = rbac.can(user.role, key); diff --git a/src/routes/git.ts b/src/routes/git.ts new file mode 100644 index 0000000..5f48acb --- /dev/null +++ b/src/routes/git.ts @@ -0,0 +1,171 @@ +import { Hono } from 'hono'; +import { Buffer } from 'node:buffer'; +import * as git from 'isomorphic-git'; +import type { Env, User } from '../types'; +import { requireAuth } from '../middleware/session'; +import { isSuperAdmin } from '../utils/auth'; +import { ObjectStore, newCommits } from '../git/objectStore'; +import { advertisement, parsePackets, parseUpdate, packet, status, OID } from '../git/protocol'; +import { acquire, release, ensureGit, page, synchronize, recordHead } from '../git/storage'; +import wikiRoutes from './wiki'; +import { ui } from '../i18n/server'; +const routes = new Hono(); +const hash = async (token: string) => Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))).toString('hex'); +const challenge = () => new Response('Git authentication required', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="VireoWiki Git", charset="UTF-8"', 'Cache-Control': 'no-store' } }); +const transport = (body: Uint8Array, service: string) => new Response(Buffer.from(body), { headers: { 'Content-Type': `application/x-${service}-result`, 'Cache-Control': 'no-store' } }); +async function boundedBody(request: Request, limit: number) { + if (Number(request.headers.get('Content-Length') || 0) > limit) throw new Error('Request too large'); + if (!request.body) return Buffer.alloc(0); + const reader = request.body.getReader(), chunks: Uint8Array[] = []; let size = 0; + try { while (true) { const { value, done } = await reader.read(); if (done) break; size += value.length; if (size > limit) throw new Error('Request too large'); chunks.push(value); } } + finally { await reader.cancel().catch(() => {}); } + return Buffer.concat(chunks); +} +// Browser token management uses the normal session and CSRF protection. Passwords are one-time output. +routes.get('/api/me/git-token', requireAuth, async c => { + await ensureGit(c.env.DB); + const token = await c.env.DB.prepare('SELECT masked_token, expires_at FROM git_tokens WHERE user_id = ?').bind(c.get('user')!.id).first(); + return c.json({ token }, 200, { 'Cache-Control': 'no-store' }); +}); +routes.post('/api/me/git-token', requireAuth, async c => { + await ensureGit(c.env.DB); + const raw = `git_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')}`; + const expires = Math.floor(Date.now() / 1000) + 30 * 86400; + await c.env.DB.prepare('INSERT OR REPLACE INTO git_tokens (user_id, token_hash, masked_token, expires_at, created_at) VALUES (?, ?, ?, ?, unixepoch())') + .bind(c.get('user')!.id, await hash(raw), raw.slice(0, 8) + '…' + raw.slice(-4), expires).run(); + return c.json({ token: raw, expires_at: expires }, 200, { 'Cache-Control': 'no-store' }); +}); +routes.delete('/api/me/git-token', requireAuth, async c => { await ensureGit(c.env.DB); await c.env.DB.prepare('DELETE FROM git_tokens WHERE user_id = ?').bind(c.get('user')!.id).run(); return c.json({ success: true }); }); +// Basic credentials are intentionally restricted to the Git transport, never accepted for browser APIs. +routes.use('/git/pages/*', async (c, next) => { + const authorization = c.req.header('Authorization') || ''; + if (!authorization.startsWith('Basic ')) return challenge(); + let token: string; + try { const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8'); token = decoded.slice(decoded.indexOf(':') + 1); } + catch { return challenge(); } + if (!/^git_[a-f0-9]{64}$/.test(token)) return challenge(); + await ensureGit(c.env.DB); + const user = await c.env.DB.prepare(`SELECT u.* FROM users u JOIN git_tokens t ON t.user_id = u.id WHERE t.token_hash = ? AND t.expires_at > unixepoch()`) + .bind(await hash(token)).first(); + if (!user || user.role === 'deleted' || (user.banned_until && user.banned_until > Math.floor(Date.now() / 1000))) return challenge(); + if (isSuperAdmin(user.email, c.env)) user.role = 'super_admin'; + else if (user.role === 'banned') { if (!user.banned_until) return challenge(); user.role = 'user'; } + c.set('user', user); await next(); +}); +async function accessible(c: any) { + const idText = c.req.param('repository'); + if (!/^[1-9][0-9]*\.git$/.test(idText)) return null; + const id = Number(idText.slice(0, -4)); + if (!Number.isSafeInteger(id)) return null; + const current = await page(c.env.DB, id); + if (!current || current.deleted_at || (current.is_private && !c.get('rbac').can(c.get('user').role, 'wiki:private'))) return null; + return current; +} + +async function exportHistory(db: D1Database, store: ObjectStore, id: number, head: string) { + const objects: string[] = [], commits: string[] = []; + let exportedBytes = 0; + let cursor: string | null = head, shallow: string | null = null, last: string | null = null; + while (cursor) { + const link = await db.prepare(`SELECT l.revision_id, r.id AS revision_present, r.deleted_at, r.purged_at FROM git_commit_links l LEFT JOIN revisions r ON r.id = l.revision_id WHERE l.page_id = ? AND l.oid = ?`) + .bind(id, cursor).first<{ revision_id: number | null; revision_present: number | null; deleted_at: number | null; purged_at: number | null }>(); + if (!link || link.deleted_at || link.purged_at || commits.length >= 100) { shallow = last; break; } + // A physically removed revision must not leak through a previously exported Git object. + if (link.revision_id && !link.revision_present) { shallow = last; break; } + const commit: git.CommitObject = (await git.readCommit({ ...store.args, oid: cursor })).commit; + const snapshot = await store.content(cursor); + exportedBytes += Buffer.byteLength(snapshot.text); + if (exportedBytes > 8 * 1024 * 1024) { shallow = last; break; } + objects.push(...snapshot.objects); commits.push(cursor); last = cursor; cursor = commit.parent[0] || null; + } + + if (!commits.length) throw new Error('Revision unavailable'); + return { objects, shallow }; +} +routes.get('/git/pages/:repository/info/refs', async c => { + const service = c.req.query('service'); + if (service !== 'git-upload-pack' && service !== 'git-receive-pack') return c.text('Unsupported Git service', 400); + const current = await accessible(c); + if (!current) return c.text('Repository unavailable', 404); + if (service === 'git-receive-pack' && (!c.get('rbac').can(c.get('user')!.role, 'git:push') || !c.get('rbac').can(c.get('user')!.role, 'wiki:edit'))) return c.text('Editing permission required', 403); + let lease: string | undefined; + try { + lease = await acquire(c.env.DB, current.id); + const store = new ObjectStore(c.env.MEDIA, current.id); + const head = await synchronize(c.env, store, current, lease, new URL(c.req.url).origin); + const { shallow } = await exportHistory(c.env.DB, store, current.id, head); + return new Response(advertisement(head, service, shallow), { headers: { 'Content-Type': `application/x-${service}-advertisement`, 'Cache-Control': 'no-store' } }); + } catch (e) { return c.text((e as Error).message, 409); } + finally { if (lease) await release(c.env.DB, current.id, lease); } +}); +routes.post('/git/pages/:repository/git-upload-pack', async c => { + const current = await accessible(c); if (!current) return c.text('Repository unavailable', 404); + if (!c.req.header('Content-Type')?.startsWith('application/x-git-upload-pack-request')) return c.text('Invalid content type', 415); + try { + const body = await boundedBody(c.req.raw, 256 * 1024); + const { lines, rest } = parsePackets(body); + const saved = await c.env.DB.prepare('SELECT head, version FROM git_page_heads WHERE page_id = ?').bind(current.id).first<{ head: string; version: number }>(); + if (!saved || saved.version !== current.version) return c.text('Wiki changed; fetch again', 409); + const wants = lines.filter(s => s.startsWith('want ')).map(s => s.split(' ')[1]); + if (wants.length !== 1 || wants[0] !== saved.head) return c.text('Only the advertised head can be fetched', 403); + if (lines.some(s => s.startsWith('deepen'))) return c.text('Client-selected shallow depth is not supported', 400); + const store = new ObjectStore(c.env.MEDIA, current.id); + const { objects } = await exportHistory(c.env.DB, store, current.id, saved.head); + // Recheck visibility after the object walk, before any content leaves the worker. + const fresh = await accessible(c); if (!fresh || fresh.version !== current.version) return c.text('Wiki changed; fetch again', 409); + const done = rest.includes(Buffer.from('done\n')) || lines.some(line => line.trim() === 'done'); + if (!done) return transport(packet('NAK\n'), 'git-upload-pack'); + return transport(Buffer.concat([packet('NAK\n'), Buffer.from(await store.pack(objects))]), 'git-upload-pack'); + } catch (e) { return c.text((e as Error).message, 400); } +}); +routes.post('/git/pages/:repository/git-receive-pack', async c => { + const current = await accessible(c); if (!current) return c.text('Repository unavailable', 404); + const user = c.get('user')!, rbac = c.get('rbac'); + if (!rbac.can(user.role, 'git:push') || !rbac.can(user.role, 'wiki:edit')) return c.text('Editing permission required', 403); + if (!c.req.header('Content-Type')?.startsWith('application/x-git-receive-pack-request')) return c.text('Invalid content type', 415); + let lease: string | undefined; + try { + const update = parseUpdate(await boundedBody(c.req.raw, 4 * 1024 * 1024)); + lease = await acquire(c.env.DB, current.id); + const store = new ObjectStore(c.env.MEDIA, current.id); + const head = await synchronize(c.env, store, current, lease, new URL(c.req.url).origin); + if (update.oldOid !== head) throw new Error('Stale head; pull before pushing'); + await store.unpack(update.pack); + const commits = await newCommits(store, head, update.newOid); + if (!commits.length) return transport(status(true), 'git-receive-pack'); + const objects: string[] = []; + for (const oid of commits) objects.push(...(await store.content(oid)).objects); + const final = await store.content(update.newOid); + const commit = (await git.readCommit({ ...store.args, oid: update.newOid })).commit; + // Use the existing Wiki writer so ACLs, locks, edit queues, validation and conflict checks remain authoritative. + const writer = new Hono(); + writer.use('*', async (ctx, next) => { ctx.set('user', user); ctx.set('rbac', rbac); ctx.set('gitAuthenticated', true); ctx.set('gitTargetPageId', current.id); await next(); }); + writer.route('/api', wikiRoutes); + const write = await writer.fetch(new Request(new URL(`/api/w/${encodeURIComponent(current.slug)}`, c.req.url), { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: final.text, + expected_version: current.version, summary: `[Git ${update.newOid.slice(0, 12)}] ${commit.message.trim().slice(0, 180)}${commits.length > 1 ? ` (${commits.length} commits)` : ''}` }), + }), c.env, c.executionCtx); + const result = await write.json() as { version?: number; error?: string; pending?: boolean }; + if (!write.ok || result.pending) throw new Error(result.error || 'Edit was not applied; use the website approval workflow'); + const after = await page(c.env.DB, current.id); + if (!after || after.deleted_at || (result.version && after.version !== result.version)) throw new Error('Wiki changed concurrently; fetch before retrying'); + await store.persist(objects); + await recordHead(c.env.DB, current.id, lease, update.newOid, after.version, after.last_revision_id, user.id, commits); + return transport(status(true), 'git-receive-pack'); + } catch (e) { return transport(status(false, (e as Error).message), 'git-receive-pack'); } + finally { if (lease) await release(c.env.DB, current.id, lease); } +}); +const escape = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); +routes.get('/git', requireAuth, c => { + const id = c.req.query('page'); + const url = id && /^[1-9][0-9]*$/.test(id) ? `${new URL(c.req.url).origin}/git/pages/${id}.git` : ''; + const texts = Object.fromEntries(['title', 'intro', 'token', 'generate', 'revoke', 'clone', 'commands', 'limits', 'back', 'tokenHint', 'updated', 'failed', 'confirm'].map(key => [key, ui(`git.${key}`)])); + return c.html(`${escape(texts.title)}
${escape(texts.back)}

${escape(texts.title)}

${escape(texts.intro)}

${escape(texts.limits)}

${escape(texts.token)}

${escape(texts.tokenHint)}

${escape(texts.clone)}

${url ? `
git clone ${escape(url)}\ncd ${id}\n# ${escape(texts.commands)}\ngit add page.md\ngit commit -m "Update page"\ngit pull --rebase\ngit push origin main
` : `

${escape(texts.updated)}

`}
`); +}); +export default routes; diff --git a/src/routes/permissionGroups.ts b/src/routes/permissionGroups.ts new file mode 100644 index 0000000..2e68073 --- /dev/null +++ b/src/routes/permissionGroups.ts @@ -0,0 +1,33 @@ +import { Hono } from 'hono'; +import type { Env } from '../types'; +import { RBAC } from '../utils/role'; +import { EDITABLE_ROLES, PERMISSION_KEYS, editablePermission, loadPermissionGroups, validateOverrides } from '../utils/permissionGroups'; +import { ui } from '../i18n/server'; +const routes = new Hono(); +routes.use('*', async (c, next) => { + if (!c.get('user') || c.get('user')!.role !== 'super_admin') return c.json({ error: ui('permissions.ownerOnly') }, 403); + await next(); +}); +routes.get('/', async c => { + const saved = await loadPermissionGroups(c.env.DB); + const current = new RBAC(saved.overrides), defaults = new RBAC(); + const matrix = (rbac: RBAC) => Object.fromEntries(EDITABLE_ROLES.map(role => [role, + PERMISSION_KEYS.filter(key => editablePermission(role, key) && rbac.can(role, key))])); + return c.json({ roles: EDITABLE_ROLES, keys: PERMISSION_KEYS, groups: matrix(current), defaults: matrix(defaults), + version: saved.version }, 200, { 'Cache-Control': 'no-store' }); +}); +routes.put('/', async c => { + let data: { groups: unknown; version: number }; + try { data = await c.req.json(); } catch { return c.json({ error: ui('permissions.invalid') }, 400); } + if (!data || !Number.isSafeInteger(data.version) || data.version < 0) return c.json({ error: ui('permissions.invalid') }, 400); + let groups; + try { groups = validateOverrides(data.groups); } catch { return c.json({ error: ui('permissions.invalid') }, 400); } + await loadPermissionGroups(c.env.DB); + const result = await c.env.DB.prepare('UPDATE permission_groups SET permissions = ?, version = version + 1, updated_by = ?, updated_at = unixepoch() WHERE id = 1 AND version = ?') + .bind(JSON.stringify(groups), c.get('user')!.id, data.version).run(); + if (!result.meta.changes) return c.json({ error: ui('permissions.conflict') }, 409); + await c.env.DB.prepare('INSERT INTO admin_log (type, log, user) VALUES (?, ?, ?)') + .bind('permission_groups', JSON.stringify(groups), c.get('user')!.id).run(); + return c.json({ success: true, version: data.version + 1 }); +}); +export default routes; diff --git a/src/routes/wiki.ts b/src/routes/wiki.ts index aa0db85..44453dc 100644 --- a/src/routes/wiki.ts +++ b/src/routes/wiki.ts @@ -1837,7 +1837,7 @@ wiki.get('/w/:slug/edit-permission', requireAuth, async (c) => { // 기본 권한 — wiki:edit 가 없으면 그 자체로 차단 (관리자 admin:access 는 우회). const isAdmin = rbac.can(user.role, 'admin:access'); - if (!rbac.can(user.role, 'wiki:edit') && !isAdmin) { + if (!rbac.can(user.role, 'wiki:edit') && !rbac.can(user.role, 'wiki:create')) { return c.json({ allowed: false, reason: "no_permission", @@ -1893,6 +1893,8 @@ wiki.get('/w/:slug/edit-permission', requireAuth, async (c) => { .bind(slug) .first<{ id: number; is_private: number; edit_acl: string | null; deleted_at: number | null }>(); + if (!rbac.can(user.role, page ? 'wiki:edit' : 'wiki:create')) return c.json({ allowed: false, reason: 'no_permission' }, 403); + const minAge = await getEditAclMinAgeDays(db); // 소프트 삭제된 페이지는 PUT 경로에서 비관리자에게 410 으로 거부되고, 관리자도 같은 슬러그 @@ -2010,7 +2012,7 @@ wiki.get('/w/:slug/edit-permission', requireAuth, async (c) => { }); }); -wiki.put('/w/:slug', requireAuth, requirePermission('wiki:edit'), async (c) => { +wiki.put('/w/:slug', requireAuth, async (c) => { const slug = normalizeSlug(c.req.param('slug')); // 슬러그가 비어 있으면 거부 (normalizeSlug 가 앞뒤 슬래시/공백을 모두 떼어낸 결과) @@ -2064,7 +2066,7 @@ wiki.put('/w/:slug', requireAuth, requirePermission('wiki:edit'), async (c) => { } // Turnstile 검증 - if (c.env.TURNSTILE_SECRET_KEY) { + if (c.env.TURNSTILE_SECRET_KEY && !c.get('gitAuthenticated')) { const token = body.turnstileToken; if (!token) { return c.json({ error: ui("m_e6b962a8eaff5533") }, 403); @@ -2208,6 +2210,10 @@ wiki.put('/w/:slug', requireAuth, requirePermission('wiki:edit'), async (c) => { .bind(slug) .first<{ id: number; version: number; is_private: number; edit_acl: string | null; redirect_to: string | null; content: string; deleted_at: number | null; title: string | null; last_revision_id: number | null; category: string | null }>(); + if (c.get('gitTargetPageId') !== undefined && existing?.id !== c.get('gitTargetPageId')) return c.json({ error: ui('permissions.conflict') }, 409); + const operation = existing ? 'wiki:edit' : 'wiki:create'; + if (!rbac.can(user.role, operation)) return c.json({ error: ui('permissions.denied') }, 403); + // edit_acl body 입력 검증 (관리자만 반영). 비관리자는 기존 값 마스킹. let requestedEditAcl: { provided: boolean; value: EditAcl | null } = { provided: false, value: null }; if (Object.prototype.hasOwnProperty.call(body, 'edit_acl')) { @@ -2216,6 +2222,7 @@ wiki.put('/w/:slug', requireAuth, requirePermission('wiki:edit'), async (c) => { return c.json({ error: norm.error }, 400); } requestedEditAcl = { provided: true, value: norm.value }; + if (isAdmin && !rbac.can(user.role, 'wiki:manage') && serializeEditAcl(norm.value) !== serializeEditAcl(parseEditAcl(existing?.edit_acl))) return c.json({ error: ui('permissions.denied') }, 403); } // 신규 title 이 다른 페이지의 slug 또는 title 과 충돌하면 거부. @@ -2352,6 +2359,7 @@ wiki.put('/w/:slug', requireAuth, requirePermission('wiki:edit'), async (c) => { // ── 기존 문서 수정 ── // Optimistic Locking 체크 if (body.expected_version !== undefined && body.expected_version !== existing.version) { + if (c.get('gitAuthenticated')) return c.json({ error: ui('permissions.conflict') }, 409); // 내용이 완전히 동일하면 충돌로 보지 않고 진행 (Idempotent). // 레거시 저장 본문은 CRLF 일 수 있으므로 비교 양쪽을 LF 로 정규화한다. // (요청 본문은 이미 위에서 정규화됨) 제로폭 문자도 요청 본문과 동일하게 @@ -3184,13 +3192,15 @@ wiki.delete('/w/:slug', requireAuth, async (c) => { // Fetch page first to check permissions. // 영구 삭제(hard)는 이미 소프트삭제된 문서도 대상이므로 deleted_at 필터 없이 조회한다. // (deleted_at IS NULL 로 조회하면 소프트삭제된 문서를 영구 삭제할 때 "문서를 찾을 수 없음"으로 오거부됨) - const page = await db.prepare('SELECT id, edit_acl, deleted_at FROM pages WHERE slug = ?') - .bind(slug).first<{ id: number; edit_acl: string | null; deleted_at: number | null }>(); + const page = await db.prepare('SELECT id, edit_acl, deleted_at, is_private FROM pages WHERE slug = ?') + .bind(slug).first<{ id: number; edit_acl: string | null; deleted_at: number | null; is_private: number }>(); if (!page) { return c.json({ error: ui("m_f4afd431e04afffc") }, 404); } + if (page.is_private && !rbac.can(user.role, 'wiki:private')) return c.json({ error: ui('permissions.denied') }, 403); + if (hard) { if (!rbac.can(user.role, '*')) { return c.json({ error: ui("m_43389606b46c0865") }, 403); @@ -3294,13 +3304,13 @@ wiki.delete('/w/:slug', requireAuth, async (c) => { * 문서 복원 (관리자 전용) * - Soft Delete된 문서를 복구 */ -wiki.post('/w/:slug/restore', requireAuth, async (c) => { +wiki.post('/w/:slug/restore', requireAuth, requirePermission('wiki:restore'), async (c) => { const slug = c.req.param('slug'); const user = c.get('user')!; const rbac = c.get('rbac') as RBAC; const db = c.env.DB; - if (!rbac.can(user.role, 'wiki:delete')) { + if (!rbac.can(user.role, 'wiki:restore')) { return c.json({ error: ui("m_a20ccb3e6b01e324") }, 403); } @@ -3313,12 +3323,14 @@ wiki.post('/w/:slug/restore', requireAuth, async (c) => { return c.json({ error: ui("m_c9c2979d1a731a43") }, 400); } - const page = await db.prepare('SELECT id, deleted_at FROM pages WHERE slug = ?').bind(slug).first<{ id: number; deleted_at: number | null }>(); + const page = await db.prepare('SELECT id, deleted_at, is_private FROM pages WHERE slug = ?').bind(slug).first<{ id: number; deleted_at: number | null; is_private: number }>(); if (!page) { return c.json({ error: ui("m_f4afd431e04afffc") }, 404); } + if (page.is_private && !rbac.can(user.role, 'wiki:private')) return c.json({ error: ui('permissions.denied') }, 403); + if (!page.deleted_at) { return c.json({ error: ui("m_3c225b5dab81e41e") }, 400); } @@ -3374,6 +3386,7 @@ export async function movePage( rbac: RBAC, options?: { updateBacklinks?: boolean }, ): Promise { + if (!rbac.can(user.role, 'wiki:move')) return { ok: false, status: 403, error: ui('permissions.denied'), old_slug: currentSlug }; const db: D1Database = c.env.DB; const fail = (status: 400 | 403 | 404 | 409, error: string): MovePageOutcome => ({ ok: false, status, error, old_slug: currentSlug }); @@ -3556,7 +3569,7 @@ export async function movePage( * POST /w/:slug/move * 문서 이동 (이름 변경) — 관리자 전용. 핵심 로직은 공유 헬퍼 movePage 에 위임한다. */ -wiki.post('/w/:slug/move', requireAdmin, async (c) => { +wiki.post('/w/:slug/move', requireAuth, requirePermission('wiki:move'), async (c) => { const currentSlug = c.req.param('slug'); const { new_slug, update_backlinks } = await c.req.json<{ new_slug: string; update_backlinks?: boolean }>(); const user = c.get('user')!; @@ -3586,7 +3599,7 @@ wiki.post('/w/:slug/move', requireAdmin, async (c) => { * POST /w/:slug/revert * 문서 되돌리기 */ -wiki.post('/w/:slug/revert', requireAuth, requirePermission('wiki:edit'), async (c) => { +wiki.post('/w/:slug/revert', requireAuth, requirePermission('wiki:revert'), async (c) => { const slug = c.req.param('slug'); const { revision_id } = await c.req.json<{ revision_id: number }>(); const user = c.get('user')!; @@ -3781,7 +3794,7 @@ async function loadRevisionForDeletion( * 리비전 단위 소프트 삭제 — 권한 없는 사용자에게 해당 리비전이 처음부터 없었던 것처럼 가린다. * 편집 요약은 DB 에 보존되어 관리자에게만 노출된다. */ -wiki.post('/w/:slug/revisions/:id/delete', requireAuth, requirePermission('wiki:delete'), async (c) => { +wiki.post('/w/:slug/revisions/:id/delete', requireAuth, requirePermission('revision:delete'), async (c) => { const slug = c.req.param('slug'); const revId = parseInt(c.req.param('id'), 10); const user = c.get('user')!; diff --git a/src/shared/permissionCatalog.ts b/src/shared/permissionCatalog.ts new file mode 100644 index 0000000..61f9478 --- /dev/null +++ b/src/shared/permissionCatalog.ts @@ -0,0 +1,11 @@ +export const EDITABLE_ROLES = ['user', 'discussion_manager', 'admin'] as const; +export const PERMISSION_KEYS = [ + 'wiki:create', 'wiki:edit', 'wiki:delete', 'wiki:restore', 'wiki:move', 'wiki:revert', 'git:push', + 'revision:delete', 'wiki:private', 'media:upload', 'media:delete', + 'comment:create', 'discussion:manage', 'ticket:create', 'ticket:manage', 'user:manage', 'wiki:manage', +] as const; +export type RoleOverrides = Partial>; +const adminOnly = new Set(['media:delete', 'user:manage', 'wiki:manage']); +export function editablePermission(role: string, key: string): boolean { + return (PERMISSION_KEYS as readonly string[]).includes(key) && (role === 'admin' || !adminOnly.has(key)); +} diff --git a/src/shared/sitePolicies.ts b/src/shared/sitePolicies.ts new file mode 100644 index 0000000..d787c06 --- /dev/null +++ b/src/shared/sitePolicies.ts @@ -0,0 +1,61 @@ +/** Editable starting documents. They are Wiki content, not UI translations. */ +export const DEFAULT_SITE_POLICIES = { + terms: { + slug: 'Wiki/服务条款', + content: `# {{SITE_NAME}} 服务条款 + +本条款适用于本 Wiki 的浏览、登录、编辑及文件上传。本页为站点的初始模板,管理员可根据实际运营情况修改并公布生效日期。 + +## 关于本站 +本站是社区维护的第三方 Wiki,主要整理与 Nodeloc 相关的资料。本站与 Nodeloc 官方团队不存在隶属、授权运营或合作关系。本站使用的修改版 Nodeloc Logo 已获得授权;该授权不代表 Nodeloc 官方参与本站运营或为本站内容背书。 + +## 账号与使用 +登录由站点启用的第三方账号服务提供。请妥善保管账号,不得冒用他人身份、共享管理员权限或利用站点进行违法及侵害他人权益的活动。 + +## 投稿与编辑 +请提交可核实的内容,并尊重他人的著作权、隐私和其他权益。请勿发布骚扰、欺诈、恶意代码、他人敏感个人信息或无权传播的材料。投稿者保留其原有权利,并允许本站为展示、维护及协作修订所需对投稿进行非独占使用。本站代码的开源许可证不自动适用于用户投稿;转载具体内容前,请确认其标注的许可。 + +## 内容管理 +管理员可以按社区规则修订、撤下内容或限制账号操作。对处理结果有异议,可联系站点管理员。内容修改会保留必要的修订与管理记录,删除页面不一定意味着相关历史记录立即消失。 + +## 服务与信息 +本站内容由用户协作维护,可能存在错误或遗漏。重要事项请自行核实。站点可能因维护、故障或运营调整暂时不可用。 + +## 条款更新与联系 +条款修改将在本页公布,重大变更应提供明显提示。涉及账号、内容或条款的问题,请通过站点管理员公开的联系方式反馈。 +` + }, + privacy: { + slug: 'Wiki/隐私政策', + content: `# {{SITE_NAME}} 隐私政策 + +本政策说明本 Wiki 为提供登录、协作编辑及内容管理功能处理信息的方式。本页为初始模板,管理员应按实际启用的功能与数据处理情况更新。 + +## 我们处理的信息 +使用第三方登录时,本站可能接收账号提供方返回的用户标识、显示名称、头像及电子邮箱。编辑和上传时,本站会保存您提交的正文、文件、标签、编辑摘要、时间及关联的账号标识。安全与管理功能可能保存登录会话、封禁信息、操作记录及举报处理记录。 + +## 信息用途 +这些信息用于识别账号、保持登录、展示投稿与修订历史、协作编辑、防止滥用和处理用户请求。管理员不应将收集的信息用于与上述目的无关的用途。 + +## Cookie 与本地存储 +本站使用登录会话 Cookie 维持账号状态,使用语言 Cookie 记住界面语言;主题和布局偏好可能保存在浏览器本地存储中。您可以在浏览器中清除这些数据,清除登录 Cookie 后需要重新登录。 + +## 公开信息 +公开页面、上传文件、显示名称及相应编辑历史可能被其他用户浏览或被搜索引擎收录。请勿把密码、证件、私人联系方式或不希望公开的信息写入页面、编辑摘要或上传文件。登录邮箱不作为公开署名展示,授权管理员可能为管理目的查看相关账号资料。 + +## 第三方服务 +本站使用 Cloudflare 托管与存储服务,并通过启用的第三方账号提供方完成登录。相关服务商会按照其自身政策处理提供服务所需的数据。若本站启用访问统计、通知或其他外部集成功能,应在本页补充对应服务、信息类别和用途。 + +## 保存与保护 +本站应将数据保存期限限制在运营、账号管理与安全所需范围内,并限制管理权限。修订、备份及安全记录可能与公开页面具有不同的保存期限;管理员应补充实际执行的保存与删除安排。 + +## 查询、修改与删除 +您可以在个人中心查看和修改可编辑的资料,也可以联系管理员提出账号或个人信息相关请求。删除账号或页面后,公开协作历史、必要的管理记录或备份可能不会立即全部消失,管理员应说明处理结果和保留原因。 + +## 政策更新与联系 +本政策更新将在本页公布。有关个人信息处理的问题,请通过站点管理员公开的联系方式联系本站。 +## Git 编辑 +Git 专用令牌仅保存不可逆摘要及有效期,默认有效期为 30 天,可以撤销或更换。Git 推送记录认证的 Wiki 账号;提交中的作者名字和邮箱由用户填写,不代表已验证的身份。用户下载到本地的 Git 内容无法由本站远程撤回,请勿在正文或提交信息中包含敏感资料。 +` + }, +} as const; diff --git a/src/types.ts b/src/types.ts index 30cec66..ae3fb89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,8 @@ export type Env = { VAPID_SUBJECT?: string; }; Variables: { + gitAuthenticated?: boolean; + gitTargetPageId?: number; user: User | null; rbac?: any; // To be defined or used as a helper }; diff --git a/src/utils/mcpDraftApply.ts b/src/utils/mcpDraftApply.ts index fd7b5ea..0c811f0 100644 --- a/src/utils/mcpDraftApply.ts +++ b/src/utils/mcpDraftApply.ts @@ -78,6 +78,7 @@ export async function applyDraftMutation( finalSummary: string | null, ): Promise { const slug = draft.slug; + if (!rbac.can(user.role, draft.action === 'create' ? 'wiki:create' : 'wiki:edit')) return { ok: false, status: 403, body: { error: ui('permissions.denied') } }; if (draft.action === 'update') { const page = await c.env.DB.prepare( diff --git a/src/utils/permissionGroups.ts b/src/utils/permissionGroups.ts new file mode 100644 index 0000000..292963c --- /dev/null +++ b/src/utils/permissionGroups.ts @@ -0,0 +1,54 @@ +import type { RolePermissions } from '../types'; + +import { EDITABLE_ROLES, PERMISSION_KEYS, editablePermission, type RoleOverrides } from '../shared/permissionCatalog'; +export { EDITABLE_ROLES, PERMISSION_KEYS, editablePermission, type RoleOverrides } from '../shared/permissionCatalog'; +export function validateOverrides(input: unknown): RoleOverrides { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('invalid permission groups'); + const result: RoleOverrides = {}; + for (const [role, permissions] of Object.entries(input)) { + if (!(EDITABLE_ROLES as readonly string[]).includes(role) || !Array.isArray(permissions) || + permissions.some(p => typeof p !== 'string' || !editablePermission(role, p))) throw new Error('invalid permission groups'); + result[role as keyof RoleOverrides] = [...new Set(permissions)]; + } + return result; +} +export function applyOverrides(defaults: RolePermissions, overrides: RoleOverrides): RolePermissions { + const collect = (role: string, seen = new Set()): string[] => { + if (seen.has(role)) return []; + seen.add(role); + const entry = defaults.roles[role]; + if (!entry) return []; + return [...new Set([...(entry.permissions ?? []), ...(entry.inherits ?? []).flatMap(parent => collect(parent, seen))])]; + }; + const roles = Object.fromEntries(Object.keys(defaults.roles).map(role => [role, { permissions: collect(role), inherits: [] }])); + for (const role of EDITABLE_ROLES) { + if (!Object.hasOwn(overrides, role)) continue; + // Dashboard access and public reading stay tied to the built-in role. + const fixed = role === 'admin' ? ['wiki:read', 'admin:access'] : ['wiki:read']; + roles[role] = { permissions: [...fixed, ...overrides[role]!], inherits: [] }; + } + return { roles }; +} +const initialized = new WeakMap>(); +export async function ensurePermissionGroups(db: D1Database): Promise { + let pending = initialized.get(db); + if (!pending) { + pending = db.prepare(`CREATE TABLE IF NOT EXISTS permission_groups ( + id INTEGER PRIMARY KEY CHECK (id = 1), permissions TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 0, updated_by INTEGER, updated_at INTEGER + )`).run().then(async () => { + await db.prepare("INSERT OR IGNORE INTO permission_groups (id, permissions, version) VALUES (1, '{}', 0)").run(); + }); + initialized.set(db, pending); + pending.catch(() => initialized.delete(db)); + } + await pending; +} +export async function loadPermissionGroups(db: D1Database) { + await ensurePermissionGroups(db); + const row = await db.prepare('SELECT permissions, version FROM permission_groups WHERE id = 1') + .first<{ permissions: string; version: number }>(); + if (!row) throw new Error('permission groups unavailable'); + // Never silently widen permissions if stored data is invalid. + return { overrides: validateOverrides(JSON.parse(row.permissions)), version: row.version }; +} diff --git a/src/utils/role.ts b/src/utils/role.ts index 0fd87d6..078ee64 100644 --- a/src/utils/role.ts +++ b/src/utils/role.ts @@ -1,18 +1,23 @@ +import { applyOverrides, loadPermissionGroups, type RoleOverrides } from './permissionGroups'; import { isSuperAdmin } from './auth'; import type { Env, RolePermissions } from '../types'; /** * RBAC (역할 기반 접근 제어) 관리 클래스. * - * 권한 정의는 getDefaultPermissions() 단일 소스로 고정한다. + * 기본 권한은 getDefaultPermissions()에서 정의하며 D1의 검증된 권한 그룹으로 재정의한다. * 과거에는 wrangler.toml 의 ROLE_PERMISSIONS_JSON 환경변수로 오버라이드했으나, * 운영 중 손볼 일이 거의 없고 잘못 수정하면 전 사이트 권한이 망가지므로 폐기했다. */ export class RBAC { private permissions: RolePermissions; - constructor() { - this.permissions = RBAC.getDefaultPermissions(); + constructor(overrides: RoleOverrides = {}) { + this.permissions = applyOverrides(RBAC.getDefaultPermissions(), overrides); + } + + static async load(db: D1Database): Promise { + return new RBAC((await loadPermissionGroups(db)).overrides); } /** @@ -61,7 +66,7 @@ export class RBAC { inherits: [] }, user: { - permissions: ['wiki:read', 'wiki:edit', 'comment:create', 'ticket:create', 'media:upload'], + permissions: ['wiki:read', 'wiki:create', 'wiki:edit', 'wiki:revert', 'git:push', 'comment:create', 'ticket:create', 'media:upload'], inherits: [] }, discussion_manager: { @@ -69,7 +74,7 @@ export class RBAC { inherits: ['user'] }, admin: { - permissions: ['admin:access', 'wiki:delete', 'wiki:private', 'user:manage', 'ticket:manage'], + permissions: ['admin:access', 'wiki:delete', 'wiki:restore', 'wiki:move', 'revision:delete', 'media:delete', 'wiki:manage', 'wiki:private', 'user:manage', 'ticket:manage'], inherits: ['discussion_manager'] }, super_admin: { diff --git a/tests/git.test.ts b/tests/git.test.ts new file mode 100644 index 0000000..c0e95a0 --- /dev/null +++ b/tests/git.test.ts @@ -0,0 +1,124 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { readFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createServer } from 'node:http'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { Hono } from 'hono'; +import { RBAC } from '../src/utils/role'; +import routes from '../src/routes/git'; +import { packet, parseUpdate, REF, ZERO } from '../src/git/protocol'; +import { deflate } from 'pako'; +import { ObjectStore, validatePackBounds } from '../src/git/objectStore'; +const exec = promisify(execFile); +(globalThis as any).caches = { default: { delete: async () => true, match: async () => undefined, put: async () => {} } }; +function database() { + const sql = new DatabaseSync(':memory:'); + const db: any = { prepare(query: string) { + let params: any[] = []; + const statement = { bind(...values: any[]) { params = values; return statement; }, + async first() { return sql.prepare(query).get(...params) || null; }, + async all() { return { results: sql.prepare(query).all(...params), success: true }; }, + async run() { const result = sql.prepare(query).run(...params); return { success: true, meta: { changes: Number(result.changes), last_row_id: Number(result.lastInsertRowid) } }; } + }; return statement; + }, async batch(items: any[]) { sql.exec('BEGIN'); try { const output = []; for (const item of items) output.push(await item.run()); sql.exec('COMMIT'); return output; } catch (error) { sql.exec('ROLLBACK'); throw error; } } }; + return { sql, db }; +} +class Bucket { + objects = new Map(); + async put(key: string, body: any) { this.objects.set(key, typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body)); return {}; } + async get(key: string) { const value = this.objects.get(key); return value ? { arrayBuffer: async () => value.slice().buffer, text: async () => new TextDecoder().decode(value), body: new Blob([value]).stream(), httpMetadata: {} } : null; } + async delete(key: string) { this.objects.delete(key); } +} +test('reference parser rejects deletions, creations, alternate refs and multiple updates', () => { + const command = (old: string, next: string, ref = REF) => Buffer.concat([packet(`${old} ${next} ${ref}\0report-status\n`), Buffer.from('0000')]); + for (const data of [command('a'.repeat(40), ZERO), command(ZERO, 'b'.repeat(40)), command('a'.repeat(40), 'b'.repeat(40), 'refs/tags/x')]) assert.throws(() => parseUpdate(data)); + assert.equal(parseUpdate(command('a'.repeat(40), 'b'.repeat(40))).newOid, 'b'.repeat(40)); +}); +test('pack preflight rejects inflated bombs and oversized delta results', () => { + const header = Buffer.from('PACK00000000'); header.writeUInt32BE(2, 4); header.writeUInt32BE(1, 8); + const pack = (object: number[], data: Uint8Array) => Buffer.concat([header, Buffer.from(object), Buffer.from(deflate(data)), Buffer.alloc(20)]); + assert.throws(() => validatePackBounds(pack([0x31], Buffer.alloc(1024 * 1024 + 1))), /limit/); + assert.throws(() => validatePackBounds(pack([0x65, 1], new Uint8Array([0, 128, 128, 128, 8]))), /Delta result/); +}); +test('real Git clone, web pull, push, rejected force/delete, revoked permissions and privacy', { timeout: 120000 }, async () => { + const { sql, db } = database(); + sql.exec(await readFile('migrations/schema.sql', 'utf8')); + sql.exec("INSERT INTO users(id,provider,uid,email,name,role) VALUES(1,'nodeloc','1','test@example.com','Test editor','user'); INSERT INTO pages(id,slug,content,version) VALUES(1,'Git test','hello\\n',1); INSERT INTO revisions(id,page_id,page_version,content,author_id) VALUES(1,1,1,'hello\\n',1); UPDATE pages SET last_revision_id=1 WHERE id=1;"); + const bucket = new Bucket(), kv = new Map(); + const env: any = { DB: db, MEDIA: bucket, KV: { get: async (key: string) => kv.get(key) || null, put: async (key: string, value: string) => kv.set(key, value), delete: async (key: string) => kv.delete(key) }, + ENABLED_EXTENSIONS: '', WIKI_NAME: 'Test Wiki', WIKI_VISIBILITY: 'public', SUPER_ADMIN_EMAILS: '', MAX_UPLOAD_SIZE: '15728640', EDIT_REQUEST_ENABLED: 'false', ASSETS: { fetch: async () => new Response('', { status: 404 }) } }; + const app = new Hono(); + app.use('*', async (c, next) => { c.set('rbac', await RBAC.load(db)); c.set('user', c.req.path.startsWith('/api/') ? sql.prepare('SELECT * FROM users WHERE id=1').get() : null); await next(); }); + app.route('/', routes); + const pending: Promise[] = [], ctx: any = { waitUntil: (p: Promise) => pending.push(p), passThroughOnException() {} }; + const tokenResponse = await app.request('/api/me/git-token', { method: 'POST' }, env, ctx); + const token = (await tokenResponse.json() as any).token; + assert.match(token, /^git_/); + const server = createServer(async (req, res) => { + try { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const headers: any = req.headers; + const request = new Request(`http://127.0.0.1:${(server.address() as any).port}${req.url}`, { method: req.method, headers, ...(req.method !== 'GET' && req.method !== 'HEAD' ? { body: Buffer.concat(chunks) } : {}) }); + const response = await app.fetch(request, env, ctx); + res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); + } catch (error) { res.writeHead(500); res.end(String(error)); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as any).port; + const dir = await mkdtemp(join(tmpdir(), 'vireo-native-git-')); + const auth = Buffer.from(`wiki:${token}`).toString('base64'); + const git = (args: string[], cwd = dir) => exec('git', ['-c', `http.extraHeader=Authorization: Basic ${auth}`, ...args], { cwd, timeout: 20000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }); + try { + await git(['clone', `http://127.0.0.1:${port}/git/pages/1.git`, 'page']); + const repo = join(dir, 'page'); + assert.equal(await readFile(join(repo, 'page.md'), 'utf8'), 'hello\\n'); + await git(['config', 'user.name', 'Local Git Author'], repo); await git(['config', 'user.email', 'local@example.com'], repo); + const { writeFile } = await import('node:fs/promises'); + await writeFile(join(repo, 'page.md'), 'first git change\n'); + await git(['add', 'page.md'], repo); await git(['commit', '-m', 'First Git edit'], repo); + await writeFile(join(repo, 'page.md'), 'from git\n'); + await git(['add', 'page.md'], repo); await git(['commit', '-m', 'Native Git edit'], repo); + await git(['push', 'origin', 'main'], repo); + assert.equal((sql.prepare('SELECT content FROM pages WHERE id=1').get() as any).content, 'from git\n'); + assert.match((sql.prepare('SELECT summary FROM revisions ORDER BY id DESC LIMIT 1').get() as any).summary, /^\[Git /); + const accepted = (await git(['rev-parse', 'HEAD'], repo)).stdout.trim(); + await git(['reset', '--hard', 'HEAD~1'], repo); + await assert.rejects(() => git(['push', '--force', 'origin', 'main'], repo), /rejected|Non-fast-forward|linear/i); + await assert.rejects(() => git(['push', 'origin', ':main'], repo), /rejected|deletion/i); + assert.equal((sql.prepare('SELECT head FROM git_page_heads WHERE page_id=1').get() as any).head, accepted); + await git(['reset', '--hard', accepted], repo); + // A web revision appears as a descendant commit on the next fetch. + sql.exec("INSERT INTO revisions(page_id,page_version,content,summary,author_id) VALUES(1,3,'from web','Web edit',1); UPDATE pages SET content='from web',version=3,last_revision_id=last_insert_rowid() WHERE id=1;"); + await git(['pull', '--rebase'], repo); + assert.equal(await readFile(join(repo, 'page.md'), 'utf8'), 'from web'); + sql.exec('UPDATE revisions SET deleted_at=unixepoch() WHERE page_version=2'); + await git(['clone', `http://127.0.0.1:${port}/git/pages/1.git`, 'filtered']); + assert.equal((await git(['rev-list', '--count', 'HEAD'], join(dir, 'filtered'))).stdout.trim(), '1'); + sql.exec('UPDATE revisions SET deleted_at=NULL WHERE page_version=2'); + await writeFile(join(repo, 'README.md'), 'unsupported file'); + await git(['add', 'README.md'], repo); await git(['commit', '-m', 'Unsupported file'], repo); + await assert.rejects(() => git(['push', 'origin', 'main'], repo), /remote rejected/); + await git(['reset', '--hard', 'HEAD~1'], repo); + sql.prepare('UPDATE pages SET edit_acl=? WHERE id=1').run(JSON.stringify({flags:['admin_only']})); + await writeFile(join(repo, 'page.md'), 'ACL denied'); + await git(['add', 'page.md'], repo); await git(['commit', '-m', 'ACL denied'], repo); + await assert.rejects(() => git(['push', 'origin', 'main'], repo), /remote rejected/); + assert.equal((sql.prepare('SELECT content FROM pages WHERE id=1').get() as any).content, 'from web'); + await git(['reset', '--hard', 'HEAD~1'], repo); + sql.exec('UPDATE pages SET edit_acl=NULL WHERE id=1'); + await writeFile(join(repo, 'page.md'), 'denied edit'); await git(['add', 'page.md'], repo); await git(['commit', '-m', 'Denied edit'], repo); + await db.prepare('UPDATE permission_groups SET permissions=? WHERE id=1').bind(JSON.stringify({ user: ['wiki:edit'] })).run(); + await assert.rejects(() => git(['push', 'origin', 'main'], repo), /403/); + sql.exec('UPDATE pages SET is_private=1 WHERE id=1'); + await assert.rejects(() => git(['fetch'], repo), /404|not found/); + sql.exec('UPDATE pages SET is_private=0 WHERE id=1'); + await app.request('/api/me/git-token', { method: 'DELETE' }, env, ctx); + await assert.rejects(() => git(['fetch'], repo), /401|Authentication|could not read/i); + } finally { + await Promise.allSettled(pending); await new Promise(resolve => server.close(() => resolve())); await rm(dir, { recursive: true, force: true }); sql.close(); + } +}); diff --git a/tests/i18n-shells.test.mjs b/tests/i18n-shells.test.mjs index a899cc2..1d991c8 100644 --- a/tests/i18n-shells.test.mjs +++ b/tests/i18n-shells.test.mjs @@ -20,6 +20,13 @@ test('language selector has the correct accessible label', () => { assert.match(result, /aria-label="Interface language"/); }); +test('shell title localization preserves the configured site name', async () => { + const en = JSON.parse(await readFile(new URL('../packages/wiki-shared/src/i18n/en.json', import.meta.url), 'utf8')); + const localized = localizeShell('블로그 - My Custom Wiki', 'en', en); + assert.match(localized, /Blog - My Custom Wiki/); + assert.doesNotMatch(localized, /블로그/); +}); + // Build verification is available after npm run build without coupling unit tests to generated files. if (process.env.I18N_CHECK_BUILD === '1') test('all 42 generated shells use the requested language and an existing bootstrap', async () => { for (const locale of ['zh-CN', 'en']) { diff --git a/tests/permissions.test.ts b/tests/permissions.test.ts new file mode 100644 index 0000000..e9ef4d5 --- /dev/null +++ b/tests/permissions.test.ts @@ -0,0 +1,85 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { Hono } from 'hono'; +import { RBAC } from '../src/utils/role'; +import { validateOverrides, loadPermissionGroups, PERMISSION_KEYS } from '../src/utils/permissionGroups'; +import groupRoutes from '../src/routes/permissionGroups'; +import { applyDraftMutation } from '../src/utils/mcpDraftApply'; +import { applyExistingPageUpdate, applyNewPageInsert } from '../src/routes/admin-mcp'; + +function database() { + const sql = new DatabaseSync(':memory:'); + sql.exec('CREATE TABLE admin_log (type TEXT, log TEXT, user INTEGER)'); + const db = { prepare(query: string) { + let params: any[] = []; + const bound = { bind(...values: any[]) { params = values; return bound; }, + async first() { return sql.prepare(query).get(...params) ?? null; }, + async run() { return { meta: { changes: Number(sql.prepare(query).run(...params).changes) } }; } }; + return bound; + } } as unknown as D1Database; + return { db, sql }; +} + +test('defaults preserve existing grants and split independent operations', () => { + const rbac = new RBAC(); + for (const key of ['wiki:create', 'wiki:edit', 'wiki:revert', 'comment:create', 'ticket:create', 'media:upload']) assert.ok(rbac.can('user', key), key); + for (const key of ['wiki:delete', 'wiki:restore', 'wiki:move', 'revision:delete', 'media:delete']) assert.equal(rbac.can('user', key), false, key); + for (const key of PERMISSION_KEYS) assert.ok(rbac.can('super_admin', key), key); + assert.equal(rbac.can('discussion_manager', 'discussion:manage'), true); + assert.equal(rbac.can('banned', 'wiki:edit'), false); +}); + +test('revocations are independent across groups and cannot remove owner access', () => { + const rbac = new RBAC({ user: ['wiki:delete'], admin: ['wiki:restore'] }); + assert.equal(rbac.can('user', 'wiki:edit'), false); + assert.equal(rbac.can('user', 'wiki:delete'), true); + assert.equal(rbac.can('discussion_manager', 'wiki:edit'), true); + assert.equal(rbac.can('admin', 'wiki:delete'), false); + assert.equal(rbac.can('admin', 'wiki:restore'), true); + assert.equal(rbac.can('admin', 'admin:access'), true); + assert.equal(rbac.can('super_admin', '*'), true); + assert.equal(rbac.can('guest', 'wiki:delete'), false); +}); + +test('wildcards, protected roles, dashboard grants and unknown permissions are rejected', () => { + for (const value of [{ user: ['*'] }, { super_admin: [] }, { guest: ['wiki:edit'] }, { user: ['admin:access'] }, { user: ['user:manage'] }, { admin: ['made:up'] }]) { + assert.throws(() => validateOverrides(value)); + } +}); + +test('policy reads use the latest persisted version without a stale permission cache', async () => { + const { db } = database(); + assert.ok((await RBAC.load(db)).can('user', 'wiki:edit')); + await db.prepare('UPDATE permission_groups SET permissions = ?, version = 1 WHERE id = 1').bind(JSON.stringify({ user: [] })).run(); + assert.equal((await RBAC.load(db)).can('user', 'wiki:edit'), false); + assert.equal((await loadPermissionGroups(db)).version, 1); + await db.prepare('UPDATE permission_groups SET permissions = ? WHERE id = 1').bind('{bad').run(); + await assert.rejects(() => RBAC.load(db)); +}); + +test('only the owner can save groups; stale versions and malformed grants cannot write', async () => { + const { db } = database(); + const app = new Hono(); + app.use('*', async (c, next) => { c.set('user', { id: 1, role: c.req.header('X-Test-Role') || 'user' }); await next(); }); + app.route('/groups', groupRoutes); + const request = (role: string, data: unknown) => app.request('/groups', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Test-Role': role }, body: JSON.stringify(data) }, { DB: db }); + assert.equal((await request('admin', { groups: { user: [] }, version: 0 })).status, 403); + const current = await app.request('/groups', { headers: { 'X-Test-Role': 'super_admin' } }, { DB: db }); + assert.equal(current.status, 200); + assert.equal((await request('super_admin', { groups: { user: ['*'] }, version: 0 })).status, 400); + assert.equal((await request('super_admin', { groups: { user: [] }, version: 0 })).status, 200); + assert.equal((await request('super_admin', { groups: { user: ['wiki:edit'] }, version: 0 })).status, 409); + assert.equal((await RBAC.load(db)).can('user', 'wiki:edit'), false); +}); + +test('MCP and approval mutation primitives deny writes before touching storage', async () => { + const rbac = new RBAC({ user: [] }); + const user = { id: 1, role: 'user' } as any; + const c = { get: () => rbac, env: { DB: { prepare() { throw new Error('storage must not be touched'); } } } } as any; + const create = await applyDraftMutation(c, user, rbac, { slug: 'new', action: 'create' } as any, null); + const edit = await applyDraftMutation(c, user, rbac, { slug: 'old', action: 'update' } as any, null); + assert.equal(create.status, 403); assert.equal(edit.status, 403); + await assert.rejects(() => applyNewPageInsert(c, user, 'new', 'text', {} as any), (e: any) => e.status === 403); + await assert.rejects(() => applyExistingPageUpdate(c, user, {} as any, 'text', {} as any), (e: any) => e.status === 403); +}); diff --git a/wrangler example.toml b/wrangler example.toml index 3fa3280..56449cb 100644 --- a/wrangler example.toml +++ b/wrangler example.toml @@ -12,7 +12,7 @@ command = "npm run build" [assets] directory = "public" binding = "ASSETS" -run_worker_first = ["/", "/w/*", "/wiki/*", "/mypage", "/edit", "/memo", "/profile/*", "/search", "/admin", "/admin-media", "/admin-bulk-manage", "/tickets", "/tickets/*", "/setup-profile", "/error", "/sitemap.xml", "/robots.txt", "/login", "/recent-changes", "/explore", "/all", "/components-json-builder", "/auth/discord", "/auth/discord/callback", "/auth/nodeloc", "/auth/nodeloc/callback", "/auth/refresh-picture", "/blog", "/blog/*", "/blog-edit", "/qr-login/*", "/sw.js"] +run_worker_first = ["/git", "/git/*", "/terms", "/privacy", "/", "/w/*", "/wiki/*", "/mypage", "/edit", "/memo", "/profile/*", "/search", "/admin", "/admin-media", "/admin-bulk-manage", "/tickets", "/tickets/*", "/setup-profile", "/error", "/sitemap.xml", "/robots.txt", "/login", "/recent-changes", "/explore", "/all", "/components-json-builder", "/auth/discord", "/auth/discord/callback", "/auth/nodeloc", "/auth/nodeloc/callback", "/auth/refresh-picture", "/blog", "/blog/*", "/blog-edit", "/qr-login/*", "/sw.js"] [[d1_databases]] diff --git a/wrangler.production.toml b/wrangler.production.toml index 6e84a67..28454f1 100644 --- a/wrangler.production.toml +++ b/wrangler.production.toml @@ -12,7 +12,7 @@ command = "npm run build" [assets] directory = "public" binding = "ASSETS" -run_worker_first = ["/", "/w/*", "/wiki/*", "/mypage", "/edit", "/memo", "/profile/*", "/search", "/admin", "/admin-media", "/admin-bulk-manage", "/tickets", "/tickets/*", "/setup-profile", "/error", "/sitemap.xml", "/robots.txt", "/login", "/recent-changes", "/explore", "/all", "/components-json-builder", "/auth/discord", "/auth/discord/callback", "/auth/nodeloc", "/auth/nodeloc/callback", "/auth/refresh-picture", "/blog", "/blog/*", "/blog-edit", "/qr-login/*", "/sw.js"] +run_worker_first = ["/git", "/git/*", "/terms", "/privacy", "/", "/w/*", "/wiki/*", "/mypage", "/edit", "/memo", "/profile/*", "/search", "/admin", "/admin-media", "/admin-bulk-manage", "/tickets", "/tickets/*", "/setup-profile", "/error", "/sitemap.xml", "/robots.txt", "/login", "/recent-changes", "/explore", "/all", "/components-json-builder", "/auth/discord", "/auth/discord/callback", "/auth/nodeloc", "/auth/nodeloc/callback", "/auth/refresh-picture", "/blog", "/blog/*", "/blog-edit", "/qr-login/*", "/sw.js"] [observability] enabled = true