@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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router — Taichu 路由分发
|
|
3
|
+
*
|
|
4
|
+
* 基于 URL 模式匹配的轻量路由。
|
|
5
|
+
* 支持:
|
|
6
|
+
* - 静态路由:/api/health
|
|
7
|
+
* - 参数路由:/api/content/:type/:id
|
|
8
|
+
* - HTTP 方法区分
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { apiRoutes } from './routes/api.js';
|
|
12
|
+
import { authRoutes } from './routes/auth.js';
|
|
13
|
+
import { graphqlRoutes } from './routes/graphql.js';
|
|
14
|
+
import { mediaRoutes } from './routes/media.js';
|
|
15
|
+
import { collabRoutes } from './routes/collab.js';
|
|
16
|
+
import { webhookRoutes } from './routes/webhook.js';
|
|
17
|
+
import { auditRoutes, revisionRoutes } from './routes/audit.js';
|
|
18
|
+
import { relationshipRoutes } from './routes/relationships.js';
|
|
19
|
+
import { pluginMarketplaceRoutes } from './routes/plugin-marketplace.js';
|
|
20
|
+
import { activityPubRoutes } from './routes/activitypub.js';
|
|
21
|
+
import { workflowRoutes } from './routes/workflow.js';
|
|
22
|
+
import { wechatRoutes } from './routes/wechat.js';
|
|
23
|
+
import { ssoRoutes } from './routes/sso.js';
|
|
24
|
+
import { themeRoutes } from './routes/theme.js';
|
|
25
|
+
import { rssSitemapRoutes } from './routes/rss.js';
|
|
26
|
+
import { exportRoutes } from './routes/export.js';
|
|
27
|
+
import { serveStatic } from './static.js';
|
|
28
|
+
import { createMediaStore } from './media-store.js';
|
|
29
|
+
import { renderTheme, serveThemeAsset } from './theme-engine.js';
|
|
30
|
+
import { join, dirname } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {import('./context.js').Context} ctx
|
|
38
|
+
*/
|
|
39
|
+
export async function router(ctx) {
|
|
40
|
+
const { pathname } = ctx.url;
|
|
41
|
+
const method = ctx.req.method;
|
|
42
|
+
|
|
43
|
+
// Auth routes
|
|
44
|
+
if (pathname.startsWith('/api/auth')) {
|
|
45
|
+
return authRoutes(ctx);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ActivityPub & WebFinger (no auth required for federation)
|
|
49
|
+
if (pathname.startsWith('/api/activitypub') || pathname.startsWith('/.well-known/')) {
|
|
50
|
+
return activityPubRoutes(ctx);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// GraphQL API
|
|
54
|
+
if (pathname === '/api/graphql') {
|
|
55
|
+
return graphqlRoutes(ctx);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Collaboration & WebSocket
|
|
59
|
+
if (pathname.startsWith('/api/collab') || pathname === '/api/ws') {
|
|
60
|
+
return collabRoutes(ctx);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Webhooks
|
|
64
|
+
if (pathname.startsWith('/api/webhooks')) {
|
|
65
|
+
return webhookRoutes(ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Audit, pipelines, site settings
|
|
69
|
+
if (pathname.startsWith('/api/audit') || pathname.startsWith('/api/pipelines') || pathname === '/api/site-settings') {
|
|
70
|
+
return auditRoutes(ctx);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Workflow routes (review/approve/reject)
|
|
74
|
+
if (pathname.startsWith('/api/workflow')) {
|
|
75
|
+
return workflowRoutes(ctx);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// SSO routes
|
|
79
|
+
if (pathname.startsWith('/api/sso')) {
|
|
80
|
+
return ssoRoutes(ctx);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Theme management routes
|
|
84
|
+
if (pathname.startsWith('/api/theme')) {
|
|
85
|
+
return themeRoutes(ctx);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// WeChat integration routes
|
|
89
|
+
if (pathname.startsWith('/api/wechat')) {
|
|
90
|
+
return wechatRoutes(ctx);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Media routes (upload/list/delete)
|
|
94
|
+
if (pathname.startsWith('/api/media')) {
|
|
95
|
+
return mediaRoutes(ctx);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Revision routes (must precede content routes)
|
|
99
|
+
const revMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/([\w-]+)\/(revisions.*)$/);
|
|
100
|
+
if (revMatch) {
|
|
101
|
+
return revisionRoutes(ctx, revMatch[1], revMatch[2]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Relationship routes (must precede content routes)
|
|
105
|
+
const relMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/([\w-]+)\/(relationships|graph)/);
|
|
106
|
+
if (relMatch) {
|
|
107
|
+
return relationshipRoutes(ctx);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Plugin marketplace routes
|
|
111
|
+
if (pathname.startsWith('/api/plugins')) {
|
|
112
|
+
return pluginMarketplaceRoutes(ctx);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Content API routes
|
|
116
|
+
if (pathname.startsWith('/api')) {
|
|
117
|
+
return apiRoutes(ctx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Admin SPA static files
|
|
121
|
+
if (pathname.startsWith('/admin')) {
|
|
122
|
+
const served = await serveStatic(ctx, PUBLIC_DIR, pathname);
|
|
123
|
+
if (served) return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Uploaded media files
|
|
127
|
+
if (pathname.startsWith('/uploads/')) {
|
|
128
|
+
const mediaStore = createMediaStore();
|
|
129
|
+
const relativePath = pathname.slice('/uploads/'.length);
|
|
130
|
+
const served = await serveStatic(ctx, mediaStore.uploadDir, relativePath);
|
|
131
|
+
if (served) return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Theme static assets
|
|
135
|
+
if (pathname.startsWith('/theme/')) {
|
|
136
|
+
const assetPath = pathname.replace('/theme/', '');
|
|
137
|
+
const served = await serveThemeAsset(ctx, assetPath);
|
|
138
|
+
if (served) return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Public static files (ws-test.html, etc.)
|
|
142
|
+
{
|
|
143
|
+
const served = await serveStatic(ctx, PUBLIC_DIR, pathname);
|
|
144
|
+
if (served) return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Health check
|
|
148
|
+
if (pathname === '/health') {
|
|
149
|
+
const mem = process.memoryUsage();
|
|
150
|
+
const { getWSS } = await import('./websocket.js');
|
|
151
|
+
const { getConfig } = await import('./config.js');
|
|
152
|
+
const cfg = getConfig();
|
|
153
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
154
|
+
ctx.res.end(JSON.stringify({
|
|
155
|
+
status: 'ok',
|
|
156
|
+
name: 'taichu',
|
|
157
|
+
version: cfg.version,
|
|
158
|
+
uptime: Math.floor(process.uptime()),
|
|
159
|
+
node: process.version,
|
|
160
|
+
env: cfg.nodeEnv,
|
|
161
|
+
store: cfg.storage,
|
|
162
|
+
memory: {
|
|
163
|
+
rss: Math.round(mem.rss / 1024 / 1024) + 'MB',
|
|
164
|
+
heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + 'MB'
|
|
165
|
+
},
|
|
166
|
+
ws: getWSS().getStats(),
|
|
167
|
+
timestamp: new Date().toISOString()
|
|
168
|
+
}));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Content Export
|
|
173
|
+
if (pathname.startsWith('/api/export')) {
|
|
174
|
+
const handled = await exportRoutes(ctx);
|
|
175
|
+
if (handled) return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// RSS & Sitemap
|
|
179
|
+
if (pathname === '/rss.xml' || pathname === '/sitemap.xml') {
|
|
180
|
+
return rssSitemapRoutes(ctx);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Frontend Theme — catch-all for non-API, non-admin paths
|
|
184
|
+
if (!pathname.startsWith('/api') && !pathname.startsWith('/admin') && !pathname.startsWith('/uploads')) {
|
|
185
|
+
return renderTheme(ctx);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 404
|
|
189
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
190
|
+
ctx.res.end(JSON.stringify({
|
|
191
|
+
error: 'NOT_FOUND',
|
|
192
|
+
message: `Route not found: ${method} ${pathname}`
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityPub Routes
|
|
3
|
+
*
|
|
4
|
+
* GET /.well-known/webfinger?resource=acct:taichu@host — WebFinger discovery
|
|
5
|
+
* GET /api/activitypub/actor — Actor JSON-LD
|
|
6
|
+
* GET /api/activitypub/outbox — Activity outbox
|
|
7
|
+
* POST /api/activitypub/inbox — Receive activities
|
|
8
|
+
* GET /api/activitypub/followers — Followers collection
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
actorObject,
|
|
13
|
+
webfingerResponse,
|
|
14
|
+
createContentActivity,
|
|
15
|
+
processInboxActivity
|
|
16
|
+
} from '../activitypub.js';
|
|
17
|
+
import { getStore } from '../context.js';
|
|
18
|
+
import { createLogger } from '../logger.js';
|
|
19
|
+
|
|
20
|
+
const log = createLogger('ap-routes');
|
|
21
|
+
|
|
22
|
+
const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
|
|
23
|
+
|
|
24
|
+
/** @param {import('../context.js').Context} ctx */
|
|
25
|
+
export async function activityPubRoutes(ctx) {
|
|
26
|
+
const { pathname } = ctx.url;
|
|
27
|
+
const method = ctx.req.method;
|
|
28
|
+
|
|
29
|
+
// WebFinger
|
|
30
|
+
if (pathname === '/.well-known/webfinger' && method === 'GET') {
|
|
31
|
+
const resource = ctx.url.searchParams.get('resource');
|
|
32
|
+
if (!resource) {
|
|
33
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
34
|
+
ctx.res.end(JSON.stringify({ error: 'Missing "resource" parameter' }));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const result = webfingerResponse(resource);
|
|
38
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/jrd+json' });
|
|
39
|
+
ctx.res.end(JSON.stringify(result));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// NodeInfo (for fediverse discovery)
|
|
44
|
+
if (pathname === '/.well-known/nodeinfo' && method === 'GET') {
|
|
45
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
46
|
+
ctx.res.end(JSON.stringify({
|
|
47
|
+
links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', href: `${getBaseUrl(ctx)}/api/activitypub/nodeinfo` }]
|
|
48
|
+
}));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Actor
|
|
53
|
+
if (pathname === '/api/activitypub/actor' && method === 'GET') {
|
|
54
|
+
const actor = actorObject();
|
|
55
|
+
ctx.res.writeHead(200, { 'Content-Type': AP_CONTENT_TYPE });
|
|
56
|
+
ctx.res.end(JSON.stringify(actor));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Outbox
|
|
61
|
+
if (pathname === '/api/activitypub/outbox' && method === 'GET') {
|
|
62
|
+
const store = getStore();
|
|
63
|
+
const activities = [];
|
|
64
|
+
try {
|
|
65
|
+
// Get recently published content as activities
|
|
66
|
+
const docs = await store.list({ type: 'article', status: 'published', limit: 20, orderBy: 'updated_at', order: 'desc' });
|
|
67
|
+
for (const doc of docs) {
|
|
68
|
+
activities.push(createContentActivity(doc));
|
|
69
|
+
}
|
|
70
|
+
} catch (_) {}
|
|
71
|
+
|
|
72
|
+
const outbox = {
|
|
73
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
74
|
+
id: `${getBaseUrl(ctx)}/api/activitypub/outbox`,
|
|
75
|
+
type: 'OrderedCollection',
|
|
76
|
+
totalItems: activities.length,
|
|
77
|
+
orderedItems: activities
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
ctx.res.writeHead(200, { 'Content-Type': AP_CONTENT_TYPE });
|
|
81
|
+
ctx.res.end(JSON.stringify(outbox));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Followers
|
|
86
|
+
if (pathname === '/api/activitypub/followers' && method === 'GET') {
|
|
87
|
+
ctx.res.writeHead(200, { 'Content-Type': AP_CONTENT_TYPE });
|
|
88
|
+
ctx.res.end(JSON.stringify({
|
|
89
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
90
|
+
id: `${getBaseUrl(ctx)}/api/activitypub/followers`,
|
|
91
|
+
type: 'OrderedCollection',
|
|
92
|
+
totalItems: 0,
|
|
93
|
+
orderedItems: []
|
|
94
|
+
}));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Inbox (POST only)
|
|
99
|
+
if (pathname === '/api/activitypub/inbox' && method === 'POST') {
|
|
100
|
+
try {
|
|
101
|
+
const result = await processInboxActivity(ctx.body, ctx.req.headers);
|
|
102
|
+
if (result.accepted) {
|
|
103
|
+
ctx.res.writeHead(result.response ? 200 : 202, { 'Content-Type': AP_CONTENT_TYPE });
|
|
104
|
+
ctx.res.end(JSON.stringify(result.response || { accepted: true }));
|
|
105
|
+
} else {
|
|
106
|
+
ctx.res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
107
|
+
ctx.res.end(JSON.stringify({ error: result.reason || 'Rejected' }));
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
log.error(`Inbox error: ${err.message}`);
|
|
111
|
+
ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
112
|
+
ctx.res.end(JSON.stringify({ error: 'Internal error' }));
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// NodeInfo endpoint
|
|
118
|
+
if (pathname === '/api/activitypub/nodeinfo' && method === 'GET') {
|
|
119
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
120
|
+
ctx.res.end(JSON.stringify({
|
|
121
|
+
version: '2.0',
|
|
122
|
+
software: { name: 'taichu', version: '0.5.0' },
|
|
123
|
+
protocols: ['activitypub'],
|
|
124
|
+
services: { inbound: [], outbound: [] },
|
|
125
|
+
openRegistrations: false,
|
|
126
|
+
usage: { users: { total: 1 } },
|
|
127
|
+
metadata: { nodeName: 'Taichu CMS' }
|
|
128
|
+
}));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
133
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getBaseUrl(ctx) {
|
|
137
|
+
const proto = ctx.req.headers['x-forwarded-proto'] || 'http';
|
|
138
|
+
const host = ctx.req.headers['x-forwarded-host'] || ctx.req.headers.host || 'localhost';
|
|
139
|
+
return `${proto}://${host}`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Routes
|
|
3
|
+
*
|
|
4
|
+
* 所有 /api/* 的路由处理。
|
|
5
|
+
*
|
|
6
|
+
* 端点设计:
|
|
7
|
+
* GET /api/content/:type — 列出某类型的所有文档
|
|
8
|
+
* GET /api/content/:type/:id — 获取单个文档
|
|
9
|
+
* POST /api/content/:type — 创建文档
|
|
10
|
+
* PUT /api/content/:type/:id — 更新文档
|
|
11
|
+
* DELETE /api/content/:type/:id — 删除文档
|
|
12
|
+
* GET /api/content-types — 列出所有已注册的内容类型
|
|
13
|
+
* GET /api/content-types/:name — 获取内容类型 Schema
|
|
14
|
+
* GET /api/health — 健康检查
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { NotFoundError, ValidationError } from '../../../core/src/errors.js';
|
|
18
|
+
import { search as vectorSearch } from '../search.js';
|
|
19
|
+
import { requireAuth, requireScopedAuth, optionalAuth } from '../middleware/auth.js';
|
|
20
|
+
|
|
21
|
+
// Built-in content type registry
|
|
22
|
+
// Plugins/extensions can register additional types via hooks
|
|
23
|
+
const _contentTypes = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register a content type for API exposure.
|
|
27
|
+
*/
|
|
28
|
+
export function registerContentType(ct) {
|
|
29
|
+
_contentTypes.set(ct.name, ct);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get all registered content types (for GraphQL resolver).
|
|
34
|
+
*/
|
|
35
|
+
export function getContentTypes() {
|
|
36
|
+
return Array.from(_contentTypes.values()).map(ct => ({
|
|
37
|
+
name: ct.name,
|
|
38
|
+
label: ct.label,
|
|
39
|
+
description: ct.description,
|
|
40
|
+
schemaOrg: ct.schemaOrg || null,
|
|
41
|
+
fieldCount: Object.keys(ct.fields).length
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {import('../context.js').Context} ctx
|
|
47
|
+
*/
|
|
48
|
+
export async function apiRoutes(ctx) {
|
|
49
|
+
const { pathname } = ctx.url;
|
|
50
|
+
const method = ctx.req.method;
|
|
51
|
+
|
|
52
|
+
// /api/health
|
|
53
|
+
if (pathname === '/api/health') {
|
|
54
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
55
|
+
ctx.res.end(JSON.stringify({
|
|
56
|
+
status: 'ok',
|
|
57
|
+
name: 'taichu',
|
|
58
|
+
version: '0.1.0',
|
|
59
|
+
uptime: process.uptime()
|
|
60
|
+
}));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// /api/search?q=xxx&type=article
|
|
65
|
+
if (pathname === '/api/search' && method === 'GET') {
|
|
66
|
+
const q = ctx.url.searchParams.get('q') || '';
|
|
67
|
+
const type = ctx.url.searchParams.get('type') || null;
|
|
68
|
+
|
|
69
|
+
if (!q || q.length < 2) {
|
|
70
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
71
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Query must be at least 2 characters' }));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const results = vectorSearch(q, 20);
|
|
76
|
+
const docs = [];
|
|
77
|
+
for (const { docId, score } of results) {
|
|
78
|
+
try {
|
|
79
|
+
const doc = await ctx.store.get(docId);
|
|
80
|
+
if (doc && (!type || doc.type === type)) {
|
|
81
|
+
docs.push({ ...doc, _score: Math.round(score * 100) / 100 });
|
|
82
|
+
}
|
|
83
|
+
} catch (e) { /* skip */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
87
|
+
ctx.res.end(JSON.stringify({ query: q, docs, total: docs.length }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// /api/content-types
|
|
92
|
+
if (pathname === '/api/content-types' && method === 'GET') {
|
|
93
|
+
const types = Array.from(_contentTypes.values()).map(ct => ({
|
|
94
|
+
name: ct.name,
|
|
95
|
+
label: ct.label,
|
|
96
|
+
description: ct.description,
|
|
97
|
+
schemaOrg: ct.schemaOrg,
|
|
98
|
+
fields: ct.fields,
|
|
99
|
+
fieldCount: Object.keys(ct.fields).length
|
|
100
|
+
}));
|
|
101
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
102
|
+
ctx.res.end(JSON.stringify({ types }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// /api/content-types/:name
|
|
107
|
+
const ctMatch = pathname.match(/^\/api\/content-types\/([a-z][a-z0-9_]*)$/);
|
|
108
|
+
if (ctMatch && method === 'GET') {
|
|
109
|
+
const ct = _contentTypes.get(ctMatch[1]);
|
|
110
|
+
if (!ct) {
|
|
111
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
112
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Content type "${ctMatch[1]}" not found` }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
116
|
+
ctx.res.end(JSON.stringify(ct.toJSONSchema()));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// /api/content/:type
|
|
121
|
+
const listMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)$/);
|
|
122
|
+
if (listMatch && method === 'GET') {
|
|
123
|
+
// Auth required by default; set TAICHU_PUBLIC_READ=1 to allow anonymous GET
|
|
124
|
+
if (!process.env.TAICHU_PUBLIC_READ) {
|
|
125
|
+
const authResult = await requireAuth(ctx);
|
|
126
|
+
if (!authResult.authenticated) {
|
|
127
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
128
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
ctx.actor = authResult.actor;
|
|
132
|
+
} else {
|
|
133
|
+
await optionalAuth(ctx);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const type = listMatch[1];
|
|
137
|
+
const queryOpts = Object.fromEntries(ctx.url.searchParams);
|
|
138
|
+
// Multi-tenant: enforce tenant filter unless admin explicitly overrides
|
|
139
|
+
if (ctx.multiTenant && !queryOpts.tenantId) {
|
|
140
|
+
queryOpts.tenantId = ctx.tenantId;
|
|
141
|
+
}
|
|
142
|
+
const docs = await ctx.store.list({ type, ...queryOpts });
|
|
143
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
144
|
+
ctx.res.end(JSON.stringify({ docs, total: docs.length }));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (listMatch && method === 'POST') {
|
|
149
|
+
// Require scoped auth for content creation
|
|
150
|
+
// Exception: comments can be submitted publicly
|
|
151
|
+
const type = listMatch[1];
|
|
152
|
+
if (type !== 'comment') {
|
|
153
|
+
const authResult = await requireScopedAuth(ctx, `${type}:write`);
|
|
154
|
+
if (!authResult.authenticated) {
|
|
155
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
156
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
ctx.actor = authResult.actor;
|
|
160
|
+
} else {
|
|
161
|
+
await optionalAuth(ctx);
|
|
162
|
+
}
|
|
163
|
+
const ct = _contentTypes.get(type);
|
|
164
|
+
if (!ct) {
|
|
165
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
166
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: `Unknown content type: "${type}"` }));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Validate
|
|
171
|
+
const validation = ct.validate(ctx.body?.data || {});
|
|
172
|
+
if (!validation.valid) {
|
|
173
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
174
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', errors: validation.errors }));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Scheduled publishing: validate publishedAt when status='scheduled'
|
|
179
|
+
const requestedStatus = ctx.body.status;
|
|
180
|
+
if (requestedStatus === 'scheduled') {
|
|
181
|
+
const pubAt = ctx.body.publishedAt || ctx.body.data?.publishedAt;
|
|
182
|
+
if (!pubAt) {
|
|
183
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
184
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt is required when status is "scheduled"' }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (new Date(pubAt) <= new Date()) {
|
|
188
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
189
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt must be in the future for scheduled content' }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Run beforeCreate hooks
|
|
195
|
+
let payload = { type, data: ctx.body.data, status: ctx.body.status, publishedAt: ctx.body.publishedAt || null, tenantId: ctx.tenantId };
|
|
196
|
+
payload = await ctx.hooks.run('beforeCreate', payload, ctx);
|
|
197
|
+
|
|
198
|
+
const doc = await ctx.store.create(payload);
|
|
199
|
+
|
|
200
|
+
// Run afterCreate hooks
|
|
201
|
+
await ctx.hooks.run('afterCreate', doc, ctx);
|
|
202
|
+
|
|
203
|
+
ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
204
|
+
ctx.res.end(JSON.stringify(doc));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// /api/content/:type/batch — bulk operations
|
|
209
|
+
const batchMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/batch$/);
|
|
210
|
+
if (batchMatch && method === 'POST') {
|
|
211
|
+
const authResult = await requireScopedAuth(ctx, `${batchMatch[1]}:write`);
|
|
212
|
+
if (!authResult.authenticated) {
|
|
213
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
214
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
ctx.actor = authResult.actor;
|
|
218
|
+
|
|
219
|
+
const { action, ids } = ctx.body || {};
|
|
220
|
+
if (!action || !Array.isArray(ids) || !ids.length) {
|
|
221
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
222
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'action and ids[] are required' }));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const validActions = ['delete', 'publish', 'archive'];
|
|
227
|
+
if (!validActions.includes(action)) {
|
|
228
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
229
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: `action must be one of: ${validActions.join(', ')}` }));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const results = { success: 0, failed: 0, errors: [] };
|
|
234
|
+
for (const id of ids) {
|
|
235
|
+
try {
|
|
236
|
+
const doc = await ctx.store.get(id);
|
|
237
|
+
if (!doc || doc.type !== batchMatch[1]) {
|
|
238
|
+
results.failed++;
|
|
239
|
+
results.errors.push({ id, error: 'Not found or wrong type' });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (action === 'delete') {
|
|
244
|
+
await ctx.store.delete(id);
|
|
245
|
+
await ctx.hooks.run('afterDelete', { id, type: batchMatch[1] }, ctx);
|
|
246
|
+
} else {
|
|
247
|
+
await ctx.store.update(id, { status: action === 'publish' ? 'published' : 'archived' });
|
|
248
|
+
}
|
|
249
|
+
results.success++;
|
|
250
|
+
} catch (e) {
|
|
251
|
+
results.failed++;
|
|
252
|
+
results.errors.push({ id, error: e.message });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
257
|
+
ctx.res.end(JSON.stringify(results));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// /api/content/:type/:id
|
|
262
|
+
const itemMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/([\w-]+)$/);
|
|
263
|
+
if (itemMatch) {
|
|
264
|
+
const [, type, id] = itemMatch;
|
|
265
|
+
|
|
266
|
+
if (method === 'GET') {
|
|
267
|
+
if (!process.env.TAICHU_PUBLIC_READ) {
|
|
268
|
+
const authResult = await requireAuth(ctx);
|
|
269
|
+
if (!authResult.authenticated) {
|
|
270
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
271
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
ctx.actor = authResult.actor;
|
|
275
|
+
} else {
|
|
276
|
+
await optionalAuth(ctx);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const doc = await ctx.store.get(id);
|
|
280
|
+
if (!doc) {
|
|
281
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
282
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Document "${id}" not found` }));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
286
|
+
ctx.res.end(JSON.stringify(doc));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (method === 'PUT') {
|
|
291
|
+
const authResult = await requireScopedAuth(ctx, `${type}:write`);
|
|
292
|
+
if (!authResult.authenticated) {
|
|
293
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
294
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
ctx.actor = authResult.actor;
|
|
298
|
+
|
|
299
|
+
// Scheduled publishing: validate publishedAt when status='scheduled'
|
|
300
|
+
const requestedStatus = ctx.body.status;
|
|
301
|
+
if (requestedStatus === 'scheduled') {
|
|
302
|
+
const pubAt = ctx.body.publishedAt || ctx.body.data?.publishedAt;
|
|
303
|
+
if (!pubAt) {
|
|
304
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
305
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt is required when status is "scheduled"' }));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (new Date(pubAt) <= new Date()) {
|
|
309
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
310
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt must be in the future for scheduled content' }));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let payload = { id, type, data: ctx.body.data, status: ctx.body.status, publishedAt: ctx.body.publishedAt || undefined };
|
|
316
|
+
payload = await ctx.hooks.run('beforeUpdate', payload, ctx);
|
|
317
|
+
|
|
318
|
+
const doc = await ctx.store.update(id, payload);
|
|
319
|
+
if (!doc) {
|
|
320
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
321
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Document "${id}" not found` }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await ctx.hooks.run('afterUpdate', doc, ctx);
|
|
326
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
327
|
+
ctx.res.end(JSON.stringify(doc));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (method === 'DELETE') {
|
|
332
|
+
const authResult = await requireScopedAuth(ctx, `${type}:delete`);
|
|
333
|
+
if (!authResult.authenticated) {
|
|
334
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
335
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
ctx.actor = authResult.actor;
|
|
339
|
+
|
|
340
|
+
const basePayload = { id, type };
|
|
341
|
+
const payload = await ctx.hooks.run('beforeDelete', basePayload, ctx);
|
|
342
|
+
|
|
343
|
+
const deleted = await ctx.store.delete(id);
|
|
344
|
+
if (!deleted) {
|
|
345
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
346
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Document "${id}" not found` }));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await ctx.hooks.run('afterDelete', { id, type }, ctx);
|
|
351
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
352
|
+
ctx.res.end(JSON.stringify({ success: true }));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 404 for API
|
|
358
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
359
|
+
ctx.res.end(JSON.stringify({
|
|
360
|
+
error: 'NOT_FOUND',
|
|
361
|
+
message: `API route not found: ${method} ${pathname}`
|
|
362
|
+
}));
|
|
363
|
+
}
|