Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion GIT_EDITING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 使用 Git 编辑文档

登录 Wiki,在一篇文档的“更多 → Git 提交”中获取仓库地址并生成 Git 专用令牌。每篇文档对应一个 HTTPS Git 仓库,只有 `main` 分支与 `page.md` 文件。用户名可任意填写,密码使用令牌;不要把令牌写入仓库或远程 URL。令牌只显示一次,30 天后失效,可随时撤销或更换。
登录 Wiki,在一篇文档的“更多 → Git 提交”中获取仓库地址并生成 通用 API 令牌。每篇文档对应一个 HTTPS Git 仓库,只有 `main` 分支与 `page.md` 文件。用户名可任意填写,密码使用令牌;不要把令牌写入仓库或远程 URL。令牌只显示一次,默认 30 天有效,可选择 7/30/90/365 天、自定义到期时间或永不过期,也可随时撤销或更换。个人设置和 `/tokens` 页面也能管理令牌。

```sh
git clone https://www.nodeloc.wiki/git/pages/<文档ID>.git
Expand All @@ -23,3 +23,10 @@ git push origin main
私有页面要求相应权限,删除的页面停止提供仓库。隐藏、永久清除或已物理删除的修订会截断下载的历史,Git 对象不能通过公共媒体接口读取。已经下载到用户电脑上的内容无法远程撤回。

开发验证:`npm run test:git` 使用真实 Git CLI 验证 clone、push、网页修改后的 pull、拒绝 force/delete、权限撤销、私有访问与隐藏修订边界。


## 通用 API 和 MCP

Git、Wiki API 和 MCP 共用同一个个人令牌。API 请求使用 `Authorization: Bearer <令牌>`;Git 仍使用 HTTP Basic,令牌作为密码。现有 `git_` 令牌继续有效,新生成的令牌使用 `wiki_` 前缀。权限每次请求按当前账号与文档 ACL 检查,封禁、撤销和到期立即生效。

`GET/POST/DELETE /api/me/api-token` 用于网页登录后的管理,原 `/api/me/git-token` 保留兼容。POST JSON 可指定 `expires_at`(Unix 秒,必须为未来时间),`0` 或 `null` 表示永不过期,不传则为 30 天。令牌本身不能创建或管理令牌;生成新令牌会替换旧令牌。服务端仅保存 SHA-256 摘要。
15 changes: 11 additions & 4 deletions packages/wiki-shared/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3477,17 +3477,24 @@
"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.intro": "Each page has its own repository containing page.md. Use any username and your personal API 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.token": "Personal API 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.tokenHint": "Shown once. Choose an expiration; rotation or revocation invalidates the previous token immediately. Tokens use your current account permissions and page ACLs. 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"
"permissions.key.git:push": "Push through Git",
"tokens.title": "Personal API token",
"tokens.expiry": "Lifetime (days)",
"tokens.customExpiry": "Custom expiration",
"tokens.never": "Never expires",
"tokens.apiHint": "Use the same token as your Git password or send Authorization: Bearer TOKEN to the Wiki API and MCP. Keep it private.",
"tokens.invalidExpiry": "Choose a future expiration time.",
"tokens.sessionRequired": "Sign in through the browser to manage tokens."
}
15 changes: 11 additions & 4 deletions packages/wiki-shared/src/i18n/source.json
Original file line number Diff line number Diff line change
Expand Up @@ -3477,17 +3477,24 @@
"policies.footer.terms": "服务条款",
"policies.footer.privacy": "隐私政策",
"git.title": "Git 편집",
"git.intro": "각 문서는 page.md 파일을 포함한 독립 저장소를 가집니다. 사용자 이름은 임의로 입력하고 Git 토큰을 비밀번호로 사용하세요.",
"git.intro": "각 문서는 page.md를 포함한 독립 저장소입니다. 사용자 이름은 임의로 입력하고 개인 API 토큰을 비밀번호로 사용하세요.",
"git.limits": "main 브랜치의 선형 빨리 감기만 지원합니다. 이력 덮어쓰기, 브랜치 삭제, 태그 및 병합 커밋은 거부합니다. 푸시당 40개 커밋, 4 MiB, 본문은 512 KiB까지 가능합니다. 최초 가져오기는 최대 20개 리비전, 조회는 최대 100개 공개 커밋이며 숨김·영구 삭제 리비전에서 이력이 잘립니다. Git 커밋 묶음은 하나의 위키 리비전으로 기록됩니다.",
"git.token": "Git 전용 토큰",
"git.token": "개인 API 토큰",
"git.generate": "토큰 생성 / 교체",
"git.revoke": "토큰 폐기",
"git.clone": "복제 및 푸시",
"git.commands": "page.md를 편집한 후:",
"git.back": "위키로 돌아가기",
"git.tokenHint": "토큰은 한 번만 표시되며 30일 후 만료됩니다. 교체나 폐기 시 이전 토큰은 즉시 무효화됩니다. 문서 권한과 ACL이 적용되며 Git 작성자 정보는 검증된 신원이 아닙니다.",
"git.tokenHint": "토큰은 한 번만 표시됩니다. 만료일을 선택할 수 있으며 교체나 폐기 시 이전 토큰은 즉시 무효화됩니다. 현재 계정 권한과 문서 ACL이 적용됩니다. Git 작성자 정보는 검증된 신원이 아닙니다.",
"git.updated": "토큰이 업데이트되었습니다. 문서의 더보기 메뉴에서 Git을 열어 저장소 주소를 확인하세요.",
"git.failed": "작업에 실패했습니다. 다시 로그인한 후 재시도하세요.",
"git.confirm": "새 토큰을 생성하면 이전 토큰이 즉시 무효화됩니다. 계속할까요?",
"permissions.key.git:push": "Git으로 제출"
"permissions.key.git:push": "Git으로 제출",
"tokens.title": "개인 API 토큰",
"tokens.expiry": "유효 기간 (일)",
"tokens.customExpiry": "사용자 지정 만료 시간",
"tokens.never": "만료 없음",
"tokens.apiHint": "같은 토큰을 Git 비밀번호로 사용하거나 Authorization: Bearer TOKEN 헤더로 Wiki API와 MCP를 호출하세요. 안전하게 보관하세요.",
"tokens.invalidExpiry": "미래 만료 시간을 선택하세요.",
"tokens.sessionRequired": "토큰 관리는 브라우저 로그인이 필요합니다."
}
15 changes: 11 additions & 4 deletions packages/wiki-shared/src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3477,17 +3477,24 @@
"policies.footer.terms": "服务条款",
"policies.footer.privacy": "隐私政策",
"git.title": "Git 提交",
"git.intro": "每篇文档拥有独立仓库,文件为 page.md。用户名可任意填写,密码填写下方生成的 Git 令牌。",
"git.intro": "每篇文档拥有独立仓库,文件为 page.md。用户名可任意填写,密码填写下方生成的通用 API 令牌。",
"git.limits": "仅支持 main 分支的线性快进提交。禁止覆盖历史、删除分支、标签及合并提交。每次推送最多 40 个提交、4 MiB;正文最大 512 KiB。初次导入最多 20 条修订,每次读取最多 100 个可见提交;隐藏或清除的修订会截断历史。批量 Git 提交对应一条 Wiki 修订。",
"git.token": "Git 专用令牌",
"git.token": "通用 API 令牌",
"git.generate": "生成 / 更换令牌",
"git.revoke": "撤销令牌",
"git.clone": "克隆与提交",
"git.commands": "编辑 page.md 后执行:",
"git.back": "返回 Wiki",
"git.tokenHint": "令牌只显示一次,30 天后过期。更换或撤销后旧令牌立即失效。提交仍受文档编辑权限和 ACL 限制;Git 作者字段不代表已验证的用户身份。",
"git.tokenHint": "令牌只显示一次,可自选有效期,更换或撤销后旧令牌立即失效。令牌拥有当前账号的操作权限,仍受文档 ACL 限制;Git 作者字段不代表已验证的身份。",
"git.updated": "令牌已更新。请从文档的“更多”菜单进入 Git,获取该文档的仓库地址。",
"git.failed": "操作失败,请重新登录后重试。",
"git.confirm": "生成新令牌会使旧令牌立即失效,继续?",
"permissions.key.git:push": "通过 Git 提交"
"permissions.key.git:push": "通过 Git 提交",
"tokens.title": "通用 API 令牌",
"tokens.expiry": "有效期(天)",
"tokens.customExpiry": "自定义到期时间",
"tokens.never": "永不过期",
"tokens.apiHint": "同一令牌可用作 Git 密码,也可通过 Authorization: Bearer TOKEN 调用 Wiki API 和 MCP。请妥善保管。",
"tokens.invalidExpiry": "请选择未来的到期时间。",
"tokens.sessionRequired": "请通过网页登录管理令牌。"
}
6 changes: 6 additions & 0 deletions src/client/pages/mypage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,12 @@ document.addEventListener('DOMContentLoaded', async () => {
const listEl = document.getElementById('mcpClientsList');
if (!section || !listEl) return;

const tokenContainer = document.getElementById('mcpApiKeyContainer');
if (tokenContainer && !document.getElementById('personalApiTokenLink')) {
const link = document.createElement('a'); link.id = 'personalApiTokenLink';
link.href = '/tokens'; link.className = 'btn btn-outline-wiki mb-3';
link.textContent = ui('tokens.title'); tokenContainer.before(link);
}
// 위키 MCP 엔드포인트 URL 및 API 키 JSON 스니펫 세팅 (origin + /api/mcp)
const wikiEndpointEl = document.getElementById('wikiMcpEndpointUrl');
if (wikiEndpointEl) wikiEndpointEl.textContent = window.location.origin + '/api/mcp';
Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,21 @@ app.use('*', localeMiddleware);
// Secure Headers
app.use('*', secureHeaders());

// RBAC 초기화 및 세션 미들웨어 (모든 요청에서 유저 정보를 주입)
app.use('*', rbacMiddleware);
app.use('*', sessionMiddleware);
// CSRF 보호 (GET/HEAD/OPTIONS 제외)
// MCP / OAuth 토큰 엔드포인트는 외부 서비스(Claude 등)에서 호출하므로 CSRF 제외.
// /oauth/authorize 는 위키 도메인의 동의 폼에서 POST 되므로 CSRF 적용 (Origin 자동 검증).
app.use('*', (c, next) => {
const path = c.req.path;
if (path.startsWith('/api/') && c.get('apiTokenAuthenticated') && !['/api/me/api-token','/api/me/git-token','/api/me/mcp-api-key'].includes(path)) return next();
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);
});

// RBAC 초기화 및 세션 미들웨어 (모든 요청에서 유저 정보를 주입)
app.use('*', rbacMiddleware);
app.use('*', sessionMiddleware);

// ── closed 위키에서 banned 유저의 접근 제한 ──
// WIKI_VISIBILITY=closed 인 환경의 banned 사용자는 다음 세 슬러그(=wrangler.toml 환경변수)
// 와 인증·정적 자산 경로만 허용한다 — 차단된 사용자가 위키 본 콘텐츠를 우회 열람하지 못하도록.
Expand Down
14 changes: 14 additions & 0 deletions src/middleware/session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {authenticatePersonalToken} from '../utils/personalTokens';
import { ui } from '../i18n/server';
import { createMiddleware } from 'hono/factory';
import { getCookie } from 'hono/cookie';
Expand Down Expand Up @@ -28,6 +29,13 @@ export const rbacMiddleware = createMiddleware<Env>(async (c, next) => {
const SESSION_CACHE_TTL = 1800; // KV 캐시 TTL: 30분

export const sessionMiddleware = createMiddleware<Env>(async (c, next) => {
const authorization = c.req.header('Authorization') || '';
if (c.req.path.startsWith('/api/') && /^Bearer (?:wiki|git)_/i.test(authorization)) {
const user = await authenticatePersonalToken(c.env, authorization.slice(7).trim());
if (!user) return c.json({ error: 'Invalid or expired API token' }, 401, { 'Cache-Control': 'no-store' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let MCP return its bearer authentication challenge

When an expired, revoked, or malformed wiki_/git_ token is sent to /api/mcp, this early response prevents resolveBearerAuth from handling it. The MCP-specific handler deliberately returns a WWW-Authenticate header containing the protected-resource metadata so clients can initiate reauthentication, whereas this response has no challenge header; clients using a personal token will therefore stop with a generic 401 when it expires instead of entering the MCP authentication flow. Skip personal-token session handling for /api/mcp and let its existing bearer resolver validate these tokens.

Useful? React with 👍 / 👎.

c.set('user', user); c.set('apiTokenAuthenticated', true);
return next();
Comment on lines +33 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent API tokens from minting browser sessions via QR login

Because this authenticates personal tokens for every /api/* route, a token holder can call /api/qr-login/start, approve that request through /api/qr-login/approve using the bearer token, and redeem the secret for a six-hour browser session cookie. That cookie then passes requireBrowserSession, allowing the holder to rotate/revoke personal tokens or MCP keys despite the intended browser-session-only boundary, and a stolen token can therefore be converted into persistent account access. Exclude the QR approval flow from bearer authentication or explicitly require a browser session there.

Useful? React with 👍 / 👎.

}
const sessionId = getCookie(c, 'wiki_session');

if (!sessionId) {
Expand Down Expand Up @@ -176,3 +184,9 @@ export function requirePermission(permission: string) {
return next();
});
}

/** Credential management is reserved for browser sessions, never personal bearer tokens. */
export const requireBrowserSession = createMiddleware<Env>(async (c, next) => {
if (c.get('apiTokenAuthenticated')) return c.json({error:ui('tokens.sessionRequired')},403);
return next();
});
11 changes: 8 additions & 3 deletions src/routes/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requireBrowserSession } from '../../middleware/session';
import { PERMISSION_KEYS } from '../../utils/permissionGroups';
import { ui } from '../../i18n/server';
import { Hono } from 'hono';
Expand Down Expand Up @@ -1192,6 +1193,10 @@ auth.delete('/api/me/account', requireAuth, async (c) => {
if (!err?.message?.includes('no such table')) throw err;
}

// Revoke personal API/Git credentials on account deletion.
try { await db.prepare('DELETE FROM git_tokens WHERE user_id = ?').bind(user.id).run(); }
catch (err: any) { if (!err?.message?.includes('no such table')) throw err; }

// 5. KV 세션 캐시 무효화 (현재 세션)
const sessionId = getCookie(c, 'wiki_session');
if (sessionId) {
Expand All @@ -1208,7 +1213,7 @@ auth.delete('/api/me/account', requireAuth, async (c) => {
* GET /api/me/mcp-api-key
* 현재 로그인한 사용자의 MCP API 키 정보 조회
*/
auth.get('/api/me/mcp-api-key', requireAuth, async (c) => {
auth.get('/api/me/mcp-api-key', requireAuth, requireBrowserSession, async (c) => {
const user = c.get('user')!;
const db = c.env.DB;
try {
Expand All @@ -1228,7 +1233,7 @@ auth.get('/api/me/mcp-api-key', requireAuth, async (c) => {
* POST /api/me/mcp-api-key
* MCP API 키 생성 또는 갱신 (30일 고정 수명)
*/
auth.post('/api/me/mcp-api-key', requireAuth, async (c) => {
auth.post('/api/me/mcp-api-key', requireAuth, requireBrowserSession, async (c) => {
const user = c.get('user')!;
const db = c.env.DB;

Expand Down Expand Up @@ -1267,7 +1272,7 @@ auth.post('/api/me/mcp-api-key', requireAuth, async (c) => {
* DELETE /api/me/mcp-api-key
* MCP API 키 삭제
*/
auth.delete('/api/me/mcp-api-key', requireAuth, async (c) => {
auth.delete('/api/me/mcp-api-key', requireAuth, requireBrowserSession, async (c) => {
const user = c.get('user')!;
const db = c.env.DB;

Expand Down
Loading
Loading