@claudelaw/taichu 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +13 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/docker-compose.yml +42 -0
- package/docs/ROADMAP.md +101 -0
- package/docs/api/README.md +102 -0
- package/docs/architecture/001-zero-dependency-core.md +61 -0
- package/docs/architecture/002-structured-content-model.md +70 -0
- package/docs/architecture/003-hook-based-extension.md +82 -0
- package/docs/architecture/004-api-first-architecture.md +122 -0
- package/docs/architecture/README.md +24 -0
- package/docs/logo.svg +40 -0
- package/docs/research/ai-era-cms-user-research.md +247 -0
- package/docs/zh/README.md +81 -0
- package/docs/zh/guides/deploy.md +75 -0
- package/docs/zh/guides/mcp.md +84 -0
- package/docs/zh/guides/promotion.md +51 -0
- package/marketplace.json +78 -0
- package/package.json +60 -0
- package/packages/core/src/auth.js +158 -0
- package/packages/core/src/content-type.js +244 -0
- package/packages/core/src/core.test.js +406 -0
- package/packages/core/src/errors.js +60 -0
- package/packages/core/src/hooks.js +104 -0
- package/packages/core/src/index.js +16 -0
- package/packages/core/src/server.test.js +149 -0
- package/packages/core/src/sm-crypto.js +31 -0
- package/packages/core/src/sqlite-store.js +354 -0
- package/packages/core/src/store.js +174 -0
- package/packages/core/src/tokenizer.js +89 -0
- package/packages/core/src/vector-index.js +131 -0
- package/packages/llm-providers/src/index.js +181 -0
- package/packages/mcp/src/index.js +355 -0
- package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
- package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
- package/packages/server/public/admin/index.html +28 -0
- package/packages/server/public/aurora/style.css +1173 -0
- package/packages/server/public/favicon.svg +46 -0
- package/packages/server/public/theme/index.html +288 -0
- package/packages/server/public/theme/style.css +133 -0
- package/packages/server/public/theme-minimal/index.html +223 -0
- package/packages/server/public/theme-minimal/style.css +109 -0
- package/packages/server/public/ws-test.html +106 -0
- package/packages/server/src/activitypub.js +228 -0
- package/packages/server/src/audit.js +104 -0
- package/packages/server/src/auth-provider.js +76 -0
- package/packages/server/src/body-parser.js +52 -0
- package/packages/server/src/bootstrap.js +272 -0
- package/packages/server/src/collab.js +154 -0
- package/packages/server/src/config.js +136 -0
- package/packages/server/src/context.js +86 -0
- package/packages/server/src/email.js +317 -0
- package/packages/server/src/index.js +195 -0
- package/packages/server/src/logger.js +78 -0
- package/packages/server/src/media-store.js +213 -0
- package/packages/server/src/middleware/auth.js +203 -0
- package/packages/server/src/middleware/cors.js +15 -0
- package/packages/server/src/middleware/error-handler.js +49 -0
- package/packages/server/src/middleware/rate-limit.js +118 -0
- package/packages/server/src/multipart.js +150 -0
- package/packages/server/src/notify.js +126 -0
- package/packages/server/src/pipeline.js +206 -0
- package/packages/server/src/plugin-installer.js +139 -0
- package/packages/server/src/plugin-manager.js +165 -0
- package/packages/server/src/relationships.js +217 -0
- package/packages/server/src/revisions.js +114 -0
- package/packages/server/src/router.js +194 -0
- package/packages/server/src/routes/activitypub.js +140 -0
- package/packages/server/src/routes/api.js +363 -0
- package/packages/server/src/routes/audit.js +222 -0
- package/packages/server/src/routes/auth.js +205 -0
- package/packages/server/src/routes/collab.js +90 -0
- package/packages/server/src/routes/export.js +77 -0
- package/packages/server/src/routes/graphql.js +344 -0
- package/packages/server/src/routes/media.js +169 -0
- package/packages/server/src/routes/plugin-marketplace.js +171 -0
- package/packages/server/src/routes/relationships.js +133 -0
- package/packages/server/src/routes/rss.js +92 -0
- package/packages/server/src/routes/sso.js +211 -0
- package/packages/server/src/routes/theme.js +119 -0
- package/packages/server/src/routes/webhook.js +94 -0
- package/packages/server/src/routes/wechat.js +115 -0
- package/packages/server/src/routes/workflow.js +157 -0
- package/packages/server/src/scheduler.js +96 -0
- package/packages/server/src/search.js +100 -0
- package/packages/server/src/server.test.js +295 -0
- package/packages/server/src/sso-analytics.js +78 -0
- package/packages/server/src/static.js +70 -0
- package/packages/server/src/theme-engine.js +119 -0
- package/packages/server/src/webhook.js +192 -0
- package/packages/server/src/websocket.js +308 -0
- package/scripts/cli.js +90 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit & Pipeline API Routes
|
|
3
|
+
*
|
|
4
|
+
* GET /api/audit — Query audit logs
|
|
5
|
+
* GET /api/audit/stats — Audit statistics
|
|
6
|
+
* GET /api/pipelines — List pipeline templates
|
|
7
|
+
* POST /api/pipelines/run — Execute a pipeline
|
|
8
|
+
* GET /api/site-settings — Get site settings (ICP, analytics, etc.)
|
|
9
|
+
* PUT /api/site-settings — Update site settings
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
13
|
+
import { query as queryAudit, cleanupOldLogs } from '../audit.js';
|
|
14
|
+
import { getStore } from '../context.js';
|
|
15
|
+
|
|
16
|
+
export async function auditRoutes(ctx) {
|
|
17
|
+
const { pathname } = ctx.url;
|
|
18
|
+
const method = ctx.req.method;
|
|
19
|
+
|
|
20
|
+
// GET /api/audit
|
|
21
|
+
if (pathname === '/api/audit' && method === 'GET') {
|
|
22
|
+
const authResult = await requireAuth(ctx);
|
|
23
|
+
if (!authResult.authenticated) {
|
|
24
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
25
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const entries = await queryAudit(Object.fromEntries(ctx.url.searchParams));
|
|
30
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
31
|
+
ctx.res.end(JSON.stringify({ entries, total: entries.length }));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// GET /api/audit/stats
|
|
36
|
+
if (pathname === '/api/audit/stats' && method === 'GET') {
|
|
37
|
+
const authResult = await requireAuth(ctx);
|
|
38
|
+
if (!authResult.authenticated) {
|
|
39
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
40
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const entries = await queryAudit({ limit: 1000 });
|
|
45
|
+
const byAction = {};
|
|
46
|
+
const byActor = {};
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
byAction[e.action] = (byAction[e.action] || 0) + 1;
|
|
49
|
+
const key = `${e.actorType}:${e.actorId.substring(0, 8)}`;
|
|
50
|
+
byActor[key] = (byActor[key] || 0) + 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
54
|
+
ctx.res.end(JSON.stringify({ total: entries.length, byAction, byActor }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// GET/PUT /api/site-settings
|
|
59
|
+
if (pathname === '/api/site-settings') {
|
|
60
|
+
const store = getStore();
|
|
61
|
+
|
|
62
|
+
if (method === 'GET') {
|
|
63
|
+
const docs = await store.list({ type: 'site_settings', limit: 1 });
|
|
64
|
+
const settings = docs[0]?.data || getDefaultSettings();
|
|
65
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
66
|
+
ctx.res.end(JSON.stringify(settings));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (method === 'PUT') {
|
|
71
|
+
const authResult = await requireAuth(ctx);
|
|
72
|
+
if (!authResult.authenticated) {
|
|
73
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
74
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const docs = await store.list({ type: 'site_settings', limit: 1 });
|
|
79
|
+
const existing = docs[0];
|
|
80
|
+
|
|
81
|
+
if (existing) {
|
|
82
|
+
const merged = { ...existing.data, ...ctx.body };
|
|
83
|
+
await store.update(existing.id, { data: merged });
|
|
84
|
+
} else {
|
|
85
|
+
await store.create({ type: 'site_settings', data: { ...getDefaultSettings(), ...ctx.body } });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
89
|
+
ctx.res.end(JSON.stringify({ success: true }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// GET /api/pipelines
|
|
95
|
+
if (pathname === '/api/pipelines' && method === 'GET') {
|
|
96
|
+
const { PipelineEngine } = await import('../pipeline.js');
|
|
97
|
+
const store = getStore();
|
|
98
|
+
const hooks = (await import('../context.js')).getHooks();
|
|
99
|
+
const engine = new PipelineEngine(store, hooks);
|
|
100
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
101
|
+
ctx.res.end(JSON.stringify({ templates: engine.listTemplates() }));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
106
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Revision Routes (also used by API) ────────────────────
|
|
110
|
+
|
|
111
|
+
import { getRevisions, diffObjects, restoreRevision } from '../revisions.js';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Handle revision routes.
|
|
115
|
+
* GET /api/content/:type/:id/revisions
|
|
116
|
+
* GET /api/content/:type/:id/revisions/diff?from=revId1&to=revId2
|
|
117
|
+
* POST /api/content/:type/:id/revisions/:revId/restore
|
|
118
|
+
*/
|
|
119
|
+
export async function revisionRoutes(ctx, type, id) {
|
|
120
|
+
const { pathname } = ctx.url;
|
|
121
|
+
const method = ctx.req.method;
|
|
122
|
+
|
|
123
|
+
const authResult = await requireAuth(ctx);
|
|
124
|
+
if (!authResult.authenticated) {
|
|
125
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
126
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// GET revisions diff between two versions
|
|
131
|
+
const diffMatch = pathname.match(/\/revisions\/diff$/);
|
|
132
|
+
if (diffMatch && method === 'GET') {
|
|
133
|
+
const fromId = ctx.url.searchParams.get('from');
|
|
134
|
+
const toId = ctx.url.searchParams.get('to');
|
|
135
|
+
|
|
136
|
+
if (!fromId || !toId) {
|
|
137
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
138
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Both "from" and "to" revision IDs are required' }));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const store = getStore();
|
|
143
|
+
const fromRev = await store.get(fromId);
|
|
144
|
+
const toRev = await store.get(toId);
|
|
145
|
+
|
|
146
|
+
if (!fromRev || fromRev.type !== 'revision' || fromRev.data.docId !== id) {
|
|
147
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
148
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Source revision not found' }));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!toRev || toRev.type !== 'revision' || toRev.data.docId !== id) {
|
|
152
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
153
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Target revision not found' }));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const dataDiff = diffObjects(fromRev.data.data, toRev.data.data);
|
|
158
|
+
const statusChanged = fromRev.data.status !== toRev.data.status;
|
|
159
|
+
|
|
160
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
161
|
+
ctx.res.end(JSON.stringify({
|
|
162
|
+
from: {
|
|
163
|
+
id: fromRev.id,
|
|
164
|
+
timestamp: fromRev.data.timestamp || fromRev.createdAt,
|
|
165
|
+
status: fromRev.data.status,
|
|
166
|
+
author: fromRev.data.author
|
|
167
|
+
},
|
|
168
|
+
to: {
|
|
169
|
+
id: toRev.id,
|
|
170
|
+
timestamp: toRev.data.timestamp || toRev.createdAt,
|
|
171
|
+
status: toRev.data.status,
|
|
172
|
+
author: toRev.data.author
|
|
173
|
+
},
|
|
174
|
+
statusChanged,
|
|
175
|
+
statusDiff: statusChanged ? { from: fromRev.data.status, to: toRev.data.status } : null,
|
|
176
|
+
fieldsChanged: dataDiff.length,
|
|
177
|
+
diff: dataDiff
|
|
178
|
+
}));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// GET revisions list
|
|
183
|
+
if (pathname.endsWith('/revisions') && method === 'GET') {
|
|
184
|
+
const revs = await getRevisions(id);
|
|
185
|
+
const result = revs.map((r, i, arr) => ({
|
|
186
|
+
...r,
|
|
187
|
+
diff: i < arr.length - 1 ? diffObjects(arr[i + 1].data, r.data) : []
|
|
188
|
+
}));
|
|
189
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
190
|
+
ctx.res.end(JSON.stringify({ revisions: result, total: result.length }));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// POST restore
|
|
195
|
+
const restoreMatch = pathname.match(/\/revisions\/([\w-]+)\/restore$/);
|
|
196
|
+
if (restoreMatch && method === 'POST') {
|
|
197
|
+
const doc = await restoreRevision(id, restoreMatch[1]);
|
|
198
|
+
if (!doc) {
|
|
199
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
200
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
204
|
+
ctx.res.end(JSON.stringify({ success: true, doc }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
209
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getDefaultSettings() {
|
|
213
|
+
return {
|
|
214
|
+
siteName: 'Taichu CMS',
|
|
215
|
+
siteDescription: '',
|
|
216
|
+
icpNumber: '',
|
|
217
|
+
gonganNumber: '',
|
|
218
|
+
analyticsId: '',
|
|
219
|
+
language: 'zh-CN',
|
|
220
|
+
timezone: 'Asia/Shanghai'
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Routes — 注册、登录、API Key 管理
|
|
3
|
+
*
|
|
4
|
+
* POST /api/auth/register — 注册新用户
|
|
5
|
+
* POST /api/auth/login — 用户登录,返回 JWT
|
|
6
|
+
* POST /api/auth/apikeys — 生成新 API Key(需认证)
|
|
7
|
+
* GET /api/auth/apikeys — 列出所有 API Key(需认证)
|
|
8
|
+
* DELETE /api/auth/apikeys/:prefix — 删除 API Key(需认证)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { hashPassword, verifyPassword, signJWT, generateAPIKey } from '../../../core/src/auth.js';
|
|
12
|
+
import { ValidationError, UnauthorizedError } from '../../../core/src/errors.js';
|
|
13
|
+
import { requireAuth, getJwtSecret } from '../middleware/auth.js';
|
|
14
|
+
import { getStore } from '../context.js';
|
|
15
|
+
|
|
16
|
+
export async function authRoutes(ctx) {
|
|
17
|
+
const { pathname } = ctx.url;
|
|
18
|
+
const method = ctx.req.method;
|
|
19
|
+
|
|
20
|
+
// POST /api/auth/register
|
|
21
|
+
if (pathname === '/api/auth/register' && method === 'POST') {
|
|
22
|
+
const { username, email, password } = ctx.body || {};
|
|
23
|
+
|
|
24
|
+
if (!username || !password) {
|
|
25
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
26
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Username and password are required' }));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof username !== 'string' || username.length < 2 || username.length > 50) {
|
|
31
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
32
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Username must be 2-50 characters' }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (password.length < 6) {
|
|
37
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
38
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Password must be at least 6 characters' }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if username exists
|
|
43
|
+
const existing = await ctx.store.list({ type: 'user', search: username });
|
|
44
|
+
const userExists = existing.some(u => u.data.username === username);
|
|
45
|
+
|
|
46
|
+
if (userExists) {
|
|
47
|
+
ctx.res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
48
|
+
ctx.res.end(JSON.stringify({ error: 'CONFLICT', message: 'Username already taken' }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hashedPw = hashPassword(password);
|
|
53
|
+
|
|
54
|
+
const user = await ctx.store.create({
|
|
55
|
+
type: 'user',
|
|
56
|
+
data: { username, email: email || '', password: hashedPw },
|
|
57
|
+
status: 'active'
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Issue JWT immediately after registration
|
|
61
|
+
const secret = getJwtSecret();
|
|
62
|
+
const token = signJWT(
|
|
63
|
+
{ sub: user.id, username: user.data.username, role: 'author' },
|
|
64
|
+
secret,
|
|
65
|
+
{ expiresIn: '7d' }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
69
|
+
ctx.res.end(JSON.stringify({
|
|
70
|
+
user: { id: user.id, username: user.data.username, email: user.data.email },
|
|
71
|
+
token
|
|
72
|
+
}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// POST /api/auth/login
|
|
77
|
+
if (pathname === '/api/auth/login' && method === 'POST') {
|
|
78
|
+
const { username, password } = ctx.body || {};
|
|
79
|
+
|
|
80
|
+
if (!username || !password) {
|
|
81
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
82
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Username and password are required' }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const users = await ctx.store.list({ type: 'user', status: 'active' });
|
|
87
|
+
const user = users.find(u => u.data.username === username);
|
|
88
|
+
|
|
89
|
+
if (!user || !verifyPassword(password, user.data.password)) {
|
|
90
|
+
ctx.res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
91
|
+
ctx.res.end(JSON.stringify({ error: 'UNAUTHORIZED', message: 'Invalid username or password' }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const secret = getJwtSecret();
|
|
96
|
+
const token = signJWT(
|
|
97
|
+
{ sub: user.id, username: user.data.username, role: user.data.role || 'author' },
|
|
98
|
+
secret,
|
|
99
|
+
{ expiresIn: '7d' }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
103
|
+
ctx.res.end(JSON.stringify({
|
|
104
|
+
user: { id: user.id, username: user.data.username, email: user.data.email, role: user.data.role || 'author' },
|
|
105
|
+
token
|
|
106
|
+
}));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// POST /api/auth/apikeys — Generate new API Key (requires auth)
|
|
111
|
+
if (pathname === '/api/auth/apikeys' && method === 'POST') {
|
|
112
|
+
const authResult = await requireAuth(ctx);
|
|
113
|
+
if (!authResult.authenticated) {
|
|
114
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
115
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
ctx.actor = authResult.actor;
|
|
119
|
+
|
|
120
|
+
const { label, scopes } = ctx.body || {};
|
|
121
|
+
const apiKey = generateAPIKey(label || 'Default');
|
|
122
|
+
|
|
123
|
+
// Store the hash with scopes (default: read-only for all types)
|
|
124
|
+
const keyScopes = (Array.isArray(scopes) && scopes.length > 0) ? scopes : ['*:read'];
|
|
125
|
+
|
|
126
|
+
await ctx.store.create({
|
|
127
|
+
type: 'api_key',
|
|
128
|
+
data: {
|
|
129
|
+
prefix: apiKey.prefix,
|
|
130
|
+
hash: apiKey.hash,
|
|
131
|
+
label: apiKey.label,
|
|
132
|
+
ownerId: ctx.actor.id,
|
|
133
|
+
scopes: keyScopes
|
|
134
|
+
},
|
|
135
|
+
status: 'active'
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
139
|
+
ctx.res.end(JSON.stringify({
|
|
140
|
+
key: apiKey.key,
|
|
141
|
+
prefix: apiKey.prefix,
|
|
142
|
+
label: apiKey.label,
|
|
143
|
+
scopes: keyScopes,
|
|
144
|
+
message: 'Save this key — it will not be shown again'
|
|
145
|
+
}));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// GET /api/auth/apikeys — List API Keys (requires auth)
|
|
150
|
+
if (pathname === '/api/auth/apikeys' && method === 'GET') {
|
|
151
|
+
const authResult = await requireAuth(ctx);
|
|
152
|
+
if (!authResult.authenticated) {
|
|
153
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
154
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
ctx.actor = authResult.actor;
|
|
158
|
+
|
|
159
|
+
const keys = await ctx.store.list({ type: 'api_key', status: 'active' });
|
|
160
|
+
const myKeys = keys
|
|
161
|
+
.filter(k => k.data.ownerId === ctx.actor.id)
|
|
162
|
+
.map(k => ({
|
|
163
|
+
prefix: k.data.prefix,
|
|
164
|
+
label: k.data.label,
|
|
165
|
+
scopes: k.data.scopes || ['*:*'],
|
|
166
|
+
createdAt: k.createdAt
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
170
|
+
ctx.res.end(JSON.stringify({ keys: myKeys }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// DELETE /api/auth/apikeys/:prefix — Revoke API Key (requires auth)
|
|
175
|
+
const keyMatch = pathname.match(/^\/api\/auth\/apikeys\/(taichu_[a-f0-9]+)$/);
|
|
176
|
+
if (keyMatch && method === 'DELETE') {
|
|
177
|
+
const authResult = await requireAuth(ctx);
|
|
178
|
+
if (!authResult.authenticated) {
|
|
179
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
180
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
ctx.actor = authResult.actor;
|
|
184
|
+
|
|
185
|
+
const prefix = keyMatch[1];
|
|
186
|
+
const keys = await ctx.store.list({ type: 'api_key', status: 'active' });
|
|
187
|
+
const myKey = keys.find(k => k.data.prefix === prefix && k.data.ownerId === ctx.actor.id);
|
|
188
|
+
|
|
189
|
+
if (!myKey) {
|
|
190
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
191
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'API key not found' }));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await ctx.store.update(myKey.id, { status: 'revoked' });
|
|
196
|
+
|
|
197
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
198
|
+
ctx.res.end(JSON.stringify({ message: `API key ${prefix.substring(0, 11)}... revoked` }));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 404
|
|
203
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
204
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Auth route not found: ${method} ${pathname}` }));
|
|
205
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collaboration & WebSocket Routes
|
|
3
|
+
*
|
|
4
|
+
* POST /api/collab/sessions/:docId — Acquire editing session
|
|
5
|
+
* DELETE /api/collab/sessions/:docId — Release editing session
|
|
6
|
+
* GET /api/collab/sessions — List active sessions
|
|
7
|
+
* GET /api/ws — WebSocket connection info
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
11
|
+
import { getCollab } from '../collab.js';
|
|
12
|
+
import { getWSS } from '../websocket.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('../context.js').Context} ctx
|
|
16
|
+
*/
|
|
17
|
+
export async function collabRoutes(ctx) {
|
|
18
|
+
const { pathname } = ctx.url;
|
|
19
|
+
const method = ctx.req.method;
|
|
20
|
+
|
|
21
|
+
// WebSocket info + stats
|
|
22
|
+
if (pathname === '/api/ws' && method === 'GET') {
|
|
23
|
+
const wss = getWSS();
|
|
24
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
25
|
+
ctx.res.end(JSON.stringify({
|
|
26
|
+
protocol: 'ws',
|
|
27
|
+
endpoint: `ws://localhost:${ctx.config.port || 3120}`,
|
|
28
|
+
stats: wss.getStats(),
|
|
29
|
+
usage: 'Connect via WebSocket, then send: {"type":"subscribe","channel":"article"}'
|
|
30
|
+
}));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// GET /api/collab/sessions — list active sessions
|
|
35
|
+
if (pathname === '/api/collab/sessions' && method === 'GET') {
|
|
36
|
+
const authResult = await requireAuth(ctx);
|
|
37
|
+
if (!authResult.authenticated) {
|
|
38
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
39
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const collab = getCollab();
|
|
43
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
44
|
+
ctx.res.end(JSON.stringify({ sessions: collab.listSessions() }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// POST /api/collab/sessions/:docId — acquire
|
|
49
|
+
const acquireMatch = pathname.match(/^\/api\/collab\/sessions\/([\w-]+)$/);
|
|
50
|
+
if (acquireMatch && method === 'POST') {
|
|
51
|
+
const authResult = await requireAuth(ctx);
|
|
52
|
+
if (!authResult.authenticated) {
|
|
53
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
54
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const docId = acquireMatch[1];
|
|
58
|
+
const collab = getCollab();
|
|
59
|
+
const result = collab.acquire(docId, {
|
|
60
|
+
id: authResult.actor.id,
|
|
61
|
+
type: authResult.actor.type,
|
|
62
|
+
username: authResult.actor.username,
|
|
63
|
+
label: authResult.actor.label
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
ctx.res.writeHead(result.acquired ? 200 : 409, { 'Content-Type': 'application/json' });
|
|
67
|
+
ctx.res.end(JSON.stringify(result));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// DELETE /api/collab/sessions/:docId — release
|
|
72
|
+
if (acquireMatch && method === 'DELETE') {
|
|
73
|
+
const authResult = await requireAuth(ctx);
|
|
74
|
+
if (!authResult.authenticated) {
|
|
75
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
76
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const docId = acquireMatch[1];
|
|
80
|
+
const collab = getCollab();
|
|
81
|
+
const released = collab.release(docId, authResult.actor.id);
|
|
82
|
+
|
|
83
|
+
ctx.res.writeHead(released ? 200 : 404, { 'Content-Type': 'application/json' });
|
|
84
|
+
ctx.res.end(JSON.stringify({ released }));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
89
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Export — 内容导出(JSON / Markdown / CSV)
|
|
3
|
+
*
|
|
4
|
+
* GET /api/export/:type?format=json|md|csv
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
8
|
+
import { getStore } from '../context.js';
|
|
9
|
+
|
|
10
|
+
export async function exportRoutes(ctx) {
|
|
11
|
+
const { pathname } = ctx.url;
|
|
12
|
+
const method = ctx.req.method;
|
|
13
|
+
|
|
14
|
+
const match = pathname.match(/^\/api\/export\/([a-z][a-z0-9_]*)$/);
|
|
15
|
+
if (!match || method !== 'GET') return false;
|
|
16
|
+
|
|
17
|
+
const authResult = await requireAuth(ctx);
|
|
18
|
+
if (!authResult.authenticated) {
|
|
19
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
20
|
+
ctx.res.end(JSON.stringify({ error: authResult.error }));
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const type = match[1];
|
|
25
|
+
const format = ctx.url.searchParams.get('format') || 'json';
|
|
26
|
+
const status = ctx.url.searchParams.get('status') || 'published';
|
|
27
|
+
|
|
28
|
+
const store = getStore();
|
|
29
|
+
const docs = await store.list({ type, status, limit: 10000 });
|
|
30
|
+
|
|
31
|
+
switch (format) {
|
|
32
|
+
case 'json':
|
|
33
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${type}-export.json"` });
|
|
34
|
+
ctx.res.end(JSON.stringify({ exported: new Date().toISOString(), type, total: docs.length, docs }, null, 2));
|
|
35
|
+
break;
|
|
36
|
+
|
|
37
|
+
case 'md':
|
|
38
|
+
case 'markdown': {
|
|
39
|
+
const md = docs.map(d => {
|
|
40
|
+
const title = d.data?.title || 'Untitled';
|
|
41
|
+
const body = typeof d.data?.body === 'string' ? d.data.body : JSON.stringify(d.data?.body || {});
|
|
42
|
+
return `# ${title}\n\n${body}\n\n---\n`;
|
|
43
|
+
}).join('\n');
|
|
44
|
+
ctx.res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="${type}-export.md"` });
|
|
45
|
+
ctx.res.end(md);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
case 'csv': {
|
|
50
|
+
const headers = ['id', 'title', 'status', 'createdAt', 'updatedAt'];
|
|
51
|
+
const rows = [headers.join(',')];
|
|
52
|
+
for (const d of docs) {
|
|
53
|
+
rows.push([
|
|
54
|
+
csvEscape(d.id),
|
|
55
|
+
csvEscape(d.data?.title || ''),
|
|
56
|
+
csvEscape(d.status),
|
|
57
|
+
csvEscape(d.createdAt),
|
|
58
|
+
csvEscape(d.updatedAt)
|
|
59
|
+
].join(','));
|
|
60
|
+
}
|
|
61
|
+
ctx.res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${type}-export.csv"` });
|
|
62
|
+
ctx.res.end('\uFEFF' + rows.join('\n'));
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
default:
|
|
67
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
68
|
+
ctx.res.end(JSON.stringify({ error: 'Invalid format. Use: json, md, csv' }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function csvEscape(s) {
|
|
75
|
+
const str = String(s || '').replace(/"/g, '""');
|
|
76
|
+
return `"${str}"`;
|
|
77
|
+
}
|