@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.
Files changed (93) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/docker-compose.yml +42 -0
  6. package/docs/ROADMAP.md +101 -0
  7. package/docs/api/README.md +102 -0
  8. package/docs/architecture/001-zero-dependency-core.md +61 -0
  9. package/docs/architecture/002-structured-content-model.md +70 -0
  10. package/docs/architecture/003-hook-based-extension.md +82 -0
  11. package/docs/architecture/004-api-first-architecture.md +122 -0
  12. package/docs/architecture/README.md +24 -0
  13. package/docs/logo.svg +40 -0
  14. package/docs/research/ai-era-cms-user-research.md +247 -0
  15. package/docs/zh/README.md +81 -0
  16. package/docs/zh/guides/deploy.md +75 -0
  17. package/docs/zh/guides/mcp.md +84 -0
  18. package/docs/zh/guides/promotion.md +51 -0
  19. package/marketplace.json +78 -0
  20. package/package.json +60 -0
  21. package/packages/core/src/auth.js +158 -0
  22. package/packages/core/src/content-type.js +244 -0
  23. package/packages/core/src/core.test.js +406 -0
  24. package/packages/core/src/errors.js +60 -0
  25. package/packages/core/src/hooks.js +104 -0
  26. package/packages/core/src/index.js +16 -0
  27. package/packages/core/src/server.test.js +149 -0
  28. package/packages/core/src/sm-crypto.js +31 -0
  29. package/packages/core/src/sqlite-store.js +354 -0
  30. package/packages/core/src/store.js +174 -0
  31. package/packages/core/src/tokenizer.js +89 -0
  32. package/packages/core/src/vector-index.js +131 -0
  33. package/packages/llm-providers/src/index.js +181 -0
  34. package/packages/mcp/src/index.js +355 -0
  35. package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
  36. package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
  37. package/packages/server/public/admin/index.html +28 -0
  38. package/packages/server/public/aurora/style.css +1173 -0
  39. package/packages/server/public/favicon.svg +46 -0
  40. package/packages/server/public/theme/index.html +288 -0
  41. package/packages/server/public/theme/style.css +133 -0
  42. package/packages/server/public/theme-minimal/index.html +223 -0
  43. package/packages/server/public/theme-minimal/style.css +109 -0
  44. package/packages/server/public/ws-test.html +106 -0
  45. package/packages/server/src/activitypub.js +228 -0
  46. package/packages/server/src/audit.js +104 -0
  47. package/packages/server/src/auth-provider.js +76 -0
  48. package/packages/server/src/body-parser.js +52 -0
  49. package/packages/server/src/bootstrap.js +272 -0
  50. package/packages/server/src/collab.js +154 -0
  51. package/packages/server/src/config.js +136 -0
  52. package/packages/server/src/context.js +86 -0
  53. package/packages/server/src/email.js +317 -0
  54. package/packages/server/src/index.js +195 -0
  55. package/packages/server/src/logger.js +78 -0
  56. package/packages/server/src/media-store.js +213 -0
  57. package/packages/server/src/middleware/auth.js +203 -0
  58. package/packages/server/src/middleware/cors.js +15 -0
  59. package/packages/server/src/middleware/error-handler.js +49 -0
  60. package/packages/server/src/middleware/rate-limit.js +118 -0
  61. package/packages/server/src/multipart.js +150 -0
  62. package/packages/server/src/notify.js +126 -0
  63. package/packages/server/src/pipeline.js +206 -0
  64. package/packages/server/src/plugin-installer.js +139 -0
  65. package/packages/server/src/plugin-manager.js +165 -0
  66. package/packages/server/src/relationships.js +217 -0
  67. package/packages/server/src/revisions.js +114 -0
  68. package/packages/server/src/router.js +194 -0
  69. package/packages/server/src/routes/activitypub.js +140 -0
  70. package/packages/server/src/routes/api.js +363 -0
  71. package/packages/server/src/routes/audit.js +222 -0
  72. package/packages/server/src/routes/auth.js +205 -0
  73. package/packages/server/src/routes/collab.js +90 -0
  74. package/packages/server/src/routes/export.js +77 -0
  75. package/packages/server/src/routes/graphql.js +344 -0
  76. package/packages/server/src/routes/media.js +169 -0
  77. package/packages/server/src/routes/plugin-marketplace.js +171 -0
  78. package/packages/server/src/routes/relationships.js +133 -0
  79. package/packages/server/src/routes/rss.js +92 -0
  80. package/packages/server/src/routes/sso.js +211 -0
  81. package/packages/server/src/routes/theme.js +119 -0
  82. package/packages/server/src/routes/webhook.js +94 -0
  83. package/packages/server/src/routes/wechat.js +115 -0
  84. package/packages/server/src/routes/workflow.js +157 -0
  85. package/packages/server/src/scheduler.js +96 -0
  86. package/packages/server/src/search.js +100 -0
  87. package/packages/server/src/server.test.js +295 -0
  88. package/packages/server/src/sso-analytics.js +78 -0
  89. package/packages/server/src/static.js +70 -0
  90. package/packages/server/src/theme-engine.js +119 -0
  91. package/packages/server/src/webhook.js +192 -0
  92. package/packages/server/src/websocket.js +308 -0
  93. 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
+ }