@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,133 @@
1
+ /**
2
+ * Relationship Routes — 内容关系图谱 API
3
+ *
4
+ * GET /api/content/:type/:id/relationships — list all relationships
5
+ * POST /api/content/:type/:id/relationships — create relationship
6
+ * GET /api/content/:type/:id/relationships/backlinks — incoming references
7
+ * DELETE /api/content/:type/:id/relationships/:targetId — remove relationship
8
+ * GET /api/content/:type/:id/graph?depth=2 — traverse subgraph
9
+ */
10
+
11
+ import { requireAuth } from '../middleware/auth.js';
12
+ import {
13
+ getRelationships,
14
+ getBacklinks,
15
+ getAllRelationships,
16
+ addRelationship,
17
+ removeRelationship,
18
+ traverseGraph
19
+ } from './relationships.js';
20
+ import { getStore } from '../context.js';
21
+
22
+ /** @param {import('./context.js').Context} ctx */
23
+ export async function relationshipRoutes(ctx) {
24
+ const { pathname } = ctx.url;
25
+ const method = ctx.req.method;
26
+
27
+ // Auth required
28
+ const authResult = await requireAuth(ctx);
29
+ if (!authResult.authenticated) {
30
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
31
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
32
+ return;
33
+ }
34
+ ctx.actor = authResult.actor;
35
+
36
+ const store = getStore();
37
+ const match = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/([\w-]+)\/(relationships(?:\/backlinks)?|graph)$/);
38
+ if (!match) {
39
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
40
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
41
+ return;
42
+ }
43
+
44
+ const [, type, id, action] = match;
45
+ const doc = await store.get(id);
46
+ if (!doc || doc.type !== type) {
47
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
48
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Document not found' }));
49
+ return;
50
+ }
51
+
52
+ // GET /relationships — list all (outgoing + incoming)
53
+ if (action === 'relationships' && method === 'GET') {
54
+ const result = await getAllRelationships(store, id);
55
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
56
+ ctx.res.end(JSON.stringify(result));
57
+ return;
58
+ }
59
+
60
+ // GET /relationships/backlinks
61
+ if (action === 'relationships/backlinks' && method === 'GET') {
62
+ const result = await getBacklinks(store, id);
63
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
64
+ ctx.res.end(JSON.stringify(result));
65
+ return;
66
+ }
67
+
68
+ // POST /relationships — create
69
+ if (action === 'relationships' && method === 'POST') {
70
+ const { targetId, type, meta } = ctx.body || {};
71
+ if (!targetId || !type) {
72
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
73
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'targetId and type are required' }));
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const result = await addRelationship(store, id, targetId, type, meta);
79
+ if (result.alreadyExists) {
80
+ ctx.res.writeHead(409, { 'Content-Type': 'application/json' });
81
+ ctx.res.end(JSON.stringify({ error: 'ALREADY_EXISTS', message: 'Relationship already exists' }));
82
+ return;
83
+ }
84
+ ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
85
+ ctx.res.end(JSON.stringify(result));
86
+ } catch (err) {
87
+ const status = err.message.includes('not found') ? 404 : 400;
88
+ ctx.res.writeHead(status, { 'Content-Type': 'application/json' });
89
+ ctx.res.end(JSON.stringify({ error: 'RELATIONSHIP_ERROR', message: err.message }));
90
+ }
91
+ return;
92
+ }
93
+
94
+ // DELETE /relationships/:targetId
95
+ const delMatch = pathname.match(/\/relationships\/([\w-]+)$/);
96
+ if (delMatch && method === 'DELETE') {
97
+ const targetId = delMatch[1];
98
+ const relType = ctx.url.searchParams.get('type') || undefined;
99
+ try {
100
+ const result = await removeRelationship(store, id, targetId, relType);
101
+ if (result.notFound) {
102
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
103
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Relationship not found' }));
104
+ return;
105
+ }
106
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
107
+ ctx.res.end(JSON.stringify(result));
108
+ } catch (err) {
109
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
110
+ ctx.res.end(JSON.stringify({ error: 'RELATIONSHIP_ERROR', message: err.message }));
111
+ }
112
+ return;
113
+ }
114
+
115
+ // GET /graph?depth=2&types=related_to,parent_of
116
+ if (action === 'graph' && method === 'GET') {
117
+ const depth = parseInt(ctx.url.searchParams.get('depth')) || 2;
118
+ const types = ctx.url.searchParams.get('types')?.split(',').filter(Boolean) || null;
119
+
120
+ try {
121
+ const result = await traverseGraph(store, id, { depth, types });
122
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
123
+ ctx.res.end(JSON.stringify({ startId: id, depth, types, ...result }));
124
+ } catch (err) {
125
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
126
+ ctx.res.end(JSON.stringify({ error: 'GRAPH_ERROR', message: err.message }));
127
+ }
128
+ return;
129
+ }
130
+
131
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
132
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
133
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * RSS + Sitemap Route Handler
3
+ *
4
+ * GET /rss.xml — RSS 2.0 feed of published articles
5
+ * GET /sitemap.xml — XML sitemap for search engines
6
+ */
7
+
8
+ import { getStore } from '../context.js';
9
+
10
+ export async function rssSitemapRoutes(ctx) {
11
+ const { pathname } = ctx.url;
12
+ const store = getStore();
13
+
14
+ if (pathname === '/rss.xml') {
15
+ try {
16
+ const docs = await store.list({ type: 'article', status: 'published', limit: 20 });
17
+ const settingsDocs = await store.list({ type: 'site_settings', limit: 1 });
18
+ const site = settingsDocs[0]?.data || {};
19
+
20
+ const items = (docs || []).map(d => {
21
+ const title = d.data?.title || 'Untitled';
22
+ const desc = excerpt(d.data?.body) || title;
23
+ const link = `${ctx.url.protocol}//${ctx.url.host}/post/${d.data?.slug || d.id}`;
24
+ const date = new Date(d.updatedAt).toUTCString();
25
+ return `<item><title>${esc(title)}</title><link>${esc(link)}</link><description>${esc(desc)}</description><pubDate>${date}</pubDate><guid>${esc(link)}</guid></item>`;
26
+ }).join('\n');
27
+
28
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
29
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
30
+ <channel>
31
+ <title>${esc(site.siteName || 'Taichu CMS')}</title>
32
+ <link>${ctx.url.protocol}//${ctx.url.host}</link>
33
+ <description>${esc(site.siteDescription || '')}</description>
34
+ <language>${site.language || 'zh-CN'}</language>
35
+ <atom:link href="${ctx.url.protocol}//${ctx.url.host}/rss.xml" rel="self" type="application/rss+xml"/>
36
+ ${items}
37
+ </channel>
38
+ </rss>`;
39
+
40
+ ctx.res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8' });
41
+ ctx.res.end(xml);
42
+ return;
43
+ } catch (err) {
44
+ ctx.res.writeHead(500, { 'Content-Type': 'text/plain' });
45
+ ctx.res.end('RSS Error: ' + err.message);
46
+ return;
47
+ }
48
+ }
49
+
50
+ if (pathname === '/sitemap.xml') {
51
+ try {
52
+ const articles = await store.list({ type: 'article', status: 'published', limit: 1000 });
53
+ const pages = await store.list({ type: 'page', status: 'published', limit: 100 });
54
+
55
+ const host = `${ctx.url.protocol}//${ctx.url.host}`;
56
+ let urls = `<url><loc>${esc(host)}</loc><changefreq>daily</changefreq><priority>1.0</priority></url>\n`;
57
+
58
+ for (const a of (articles || [])) {
59
+ const slug = a.data?.slug || a.id;
60
+ urls += `<url><loc>${esc(host)}/post/${esc(slug)}</loc><lastmod>${new Date(a.updatedAt).toISOString()}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>\n`;
61
+ }
62
+ for (const p of (pages || [])) {
63
+ const slug = p.data?.slug || p.id;
64
+ urls += `<url><loc>${esc(host)}/page/${esc(slug)}</loc><changefreq>monthly</changefreq><priority>0.6</priority></url>\n`;
65
+ }
66
+
67
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
68
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
69
+ ${urls}</urlset>`;
70
+
71
+ ctx.res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8' });
72
+ ctx.res.end(xml);
73
+ return;
74
+ } catch (err) {
75
+ ctx.res.writeHead(500, { 'Content-Type': 'text/plain' });
76
+ ctx.res.end('Sitemap Error: ' + err.message);
77
+ return;
78
+ }
79
+ }
80
+
81
+ ctx.res.writeHead(404, { 'Content-Type': 'text/plain' });
82
+ ctx.res.end('Not Found');
83
+ }
84
+
85
+ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
86
+ function excerpt(body) {
87
+ if (!body) return '';
88
+ if (typeof body === 'string') return body.substring(0, 300);
89
+ if (body.text) return body.text.substring(0, 300);
90
+ if (body.content) return body.content.map(n => n.text||'').join(' ').substring(0, 300);
91
+ return '';
92
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * SSO Routes — 企业单点登录 (OIDC + LDAP)
3
+ *
4
+ * GET /api/sso/providers — 列出可用 SSO Provider
5
+ * GET /api/sso/oidc — 发起 OIDC 登录(重定向到 IdP)
6
+ * GET /api/sso/oidc/callback — OIDC 回调处理(code → token → JWT)
7
+ */
8
+
9
+ import { getSSOProviders } from '../sso-analytics.js';
10
+ import { createHash, createVerify } from 'node:crypto';
11
+ import { createLogger } from '../logger.js';
12
+ import { getStore } from '../context.js';
13
+
14
+ const log = createLogger('sso');
15
+
16
+ export async function ssoRoutes(ctx) {
17
+ const { pathname } = ctx.url;
18
+ const method = ctx.req.method;
19
+
20
+ // GET /api/sso/providers — list available SSO providers
21
+ if (pathname === '/api/sso/providers' && method === 'GET') {
22
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
23
+ ctx.res.end(JSON.stringify({ providers: getSSOProviders() }));
24
+ return;
25
+ }
26
+
27
+ // GET /api/sso/oidc — redirect to IdP
28
+ if (pathname === '/api/sso/oidc' && method === 'GET') {
29
+ const issuer = process.env.TAICHU_SSO_OIDC_ISSUER;
30
+ const clientId = process.env.TAICHU_SSO_OIDC_CLIENT_ID;
31
+ if (!issuer || !clientId) {
32
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
33
+ ctx.res.end(JSON.stringify({ error: 'SSO not configured. Set TAICHU_SSO_OIDC_ISSUER and TAICHU_SSO_OIDC_CLIENT_ID' }));
34
+ return;
35
+ }
36
+
37
+ const redirectUri = `http://${ctx.req.headers.host}/api/sso/oidc/callback`;
38
+ const state = Buffer.from(JSON.stringify({ ts: Date.now() })).toString('base64url');
39
+ const nonce = createHash('sha256').update(String(Date.now())).digest('hex').substring(0, 16);
40
+
41
+ const authUrl = `${issuer}/authorize?` + new URLSearchParams({
42
+ client_id: clientId,
43
+ redirect_uri: redirectUri,
44
+ response_type: 'code',
45
+ scope: 'openid profile email',
46
+ state,
47
+ nonce
48
+ }).toString();
49
+
50
+ log.info(`OIDC redirect to ${issuer}`);
51
+ ctx.res.writeHead(307, { Location: authUrl });
52
+ ctx.res.end();
53
+ return;
54
+ }
55
+
56
+ // GET /api/sso/oidc/callback — handle IdP callback
57
+ if (pathname === '/api/sso/oidc/callback' && method === 'GET') {
58
+ const code = ctx.url.searchParams.get('code');
59
+ const state = ctx.url.searchParams.get('state');
60
+ const error = ctx.url.searchParams.get('error');
61
+
62
+ if (error) {
63
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
64
+ ctx.res.end(JSON.stringify({ error: 'SSO authorization failed', detail: error }));
65
+ return;
66
+ }
67
+ if (!code) {
68
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
69
+ ctx.res.end(JSON.stringify({ error: 'Missing authorization code' }));
70
+ return;
71
+ }
72
+
73
+ const issuer = process.env.TAICHU_SSO_OIDC_ISSUER;
74
+ const clientId = process.env.TAICHU_SSO_OIDC_CLIENT_ID;
75
+ const clientSecret = process.env.TAICHU_SSO_OIDC_CLIENT_SECRET;
76
+ const redirectUri = `http://${ctx.req.headers.host}/api/sso/oidc/callback`;
77
+
78
+ if (!issuer || !clientId || !clientSecret) {
79
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
80
+ ctx.res.end(JSON.stringify({ error: 'OIDC not fully configured (missing CLIENT_SECRET)' }));
81
+ return;
82
+ }
83
+
84
+ try {
85
+ // Exchange code for tokens
86
+ const tokenRes = await fetch(`${issuer}/token`, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
89
+ body: new URLSearchParams({
90
+ grant_type: 'authorization_code',
91
+ code,
92
+ client_id: clientId,
93
+ client_secret: clientSecret,
94
+ redirect_uri: redirectUri
95
+ }).toString()
96
+ });
97
+
98
+ if (!tokenRes.ok) {
99
+ const errBody = await tokenRes.text();
100
+ log.error(`Token exchange failed: ${errBody}`);
101
+ ctx.res.writeHead(401, { 'Content-Type': 'application/json' });
102
+ ctx.res.end(JSON.stringify({ error: 'Token exchange failed' }));
103
+ return;
104
+ }
105
+
106
+ const tokens = await tokenRes.json();
107
+ const idToken = tokens.id_token;
108
+
109
+ if (!idToken) {
110
+ ctx.res.writeHead(401, { 'Content-Type': 'application/json' });
111
+ ctx.res.end(JSON.stringify({ error: 'No id_token received' }));
112
+ return;
113
+ }
114
+
115
+ // Decode payload without verifying signature (basic validation)
116
+ const payload = decodeJwtPayload(idToken);
117
+ if (!payload) {
118
+ ctx.res.writeHead(401, { 'Content-Type': 'application/json' });
119
+ ctx.res.end(JSON.stringify({ error: 'Invalid id_token' }));
120
+ return;
121
+ }
122
+
123
+ const { sub, email, name, preferred_username } = payload;
124
+ const username = preferred_username || email?.split('@')[0] || sub;
125
+ const displayName = name || username;
126
+
127
+ log.info(`OIDC login: ${email || sub} (${displayName})`);
128
+
129
+ // Find or create user
130
+ const store = getStore();
131
+ let user = null;
132
+ if (store) {
133
+ const users = await store.list({ type: 'user', limit: 100 });
134
+ user = users.find(u => u.data?.ssoSub === sub || u.data?.email === email);
135
+ if (!user) {
136
+ user = await store.create({
137
+ type: 'user',
138
+ data: {
139
+ username,
140
+ email: email || '',
141
+ displayName,
142
+ ssoSub: sub,
143
+ ssoProvider: 'oidc',
144
+ role: 'editor'
145
+ },
146
+ status: 'active'
147
+ });
148
+ log.info(`Created SSO user: ${username}`);
149
+ }
150
+ }
151
+
152
+ // Generate JWT session token
153
+ const jwt = signJwt({
154
+ sub: user?.id || sub,
155
+ username,
156
+ role: user?.data?.role || 'editor',
157
+ iat: Math.floor(Date.now() / 1000),
158
+ exp: Math.floor(Date.now() / 1000) + 86400
159
+ });
160
+
161
+ // Return HTML page that stores token and redirects to admin
162
+ const adminUrl = '/admin/';
163
+ const html = `<!DOCTYPE html>
164
+ <html><head><meta charset="utf-8"><title>登录中...</title></head>
165
+ <body style="font-family:sans-serif;text-align:center;padding-top:80px;">
166
+ <p>✅ 登录成功,正在跳转...</p>
167
+ <script>
168
+ localStorage.setItem('taichu_token', '${jwt}');
169
+ localStorage.setItem('taichu_user', '${JSON.stringify({ id: user?.id, username, role: 'editor' }).replace(/'/g, "\\'")}');
170
+ location.href = '${adminUrl}';
171
+ </script>
172
+ </body></html>`;
173
+
174
+ ctx.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
175
+ ctx.res.end(html);
176
+ } catch (err) {
177
+ log.error(`OIDC callback error: ${err.message}`);
178
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
179
+ ctx.res.end(JSON.stringify({ error: 'Internal error during SSO callback' }));
180
+ }
181
+ return;
182
+ }
183
+
184
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
185
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
186
+ }
187
+
188
+ /**
189
+ * Decode JWT payload (no signature verification — for basic claims extraction).
190
+ */
191
+ function decodeJwtPayload(token) {
192
+ try {
193
+ const parts = token.split('.');
194
+ if (parts.length !== 3) return null;
195
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
196
+ } catch { return null; }
197
+ }
198
+
199
+ /**
200
+ * Sign a simple JWT with HS256.
201
+ */
202
+ function signJwt(payload) {
203
+ const secret = process.env.TAICHU_JWT_SECRET || 'taichu-dev-secret-change-me';
204
+ const header = { alg: 'HS256', typ: 'JWT' };
205
+ const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
206
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
207
+ const signature = createHash('sha256')
208
+ .update(headerB64 + '.' + payloadB64 + secret)
209
+ .digest('base64url');
210
+ return `${headerB64}.${payloadB64}.${signature}`;
211
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Theme Management API
3
+ *
4
+ * GET /api/theme — List available themes
5
+ * POST /api/theme/upload — Upload custom theme (.zip)
6
+ * DELETE /api/theme/:name — Delete custom theme
7
+ * POST /api/theme/activate/:name — Activate a theme
8
+ */
9
+
10
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync, rmdirSync, readdirSync, createWriteStream } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { pipeline } from 'node:stream';
13
+ import { requireAuth } from '../middleware/auth.js';
14
+ import { createLogger } from '../logger.js';
15
+
16
+ const log = createLogger('theme');
17
+ const THEME_DIR = join(process.cwd(), '.taichu', 'themes');
18
+ const PUBLIC_THEME_DIR = join(import.meta.dirname, '..', 'public');
19
+
20
+ // Built-in themes
21
+ const BUILT_IN_THEMES = [
22
+ { name: 'default', label: '默认博客主题', description: 'Taichu 内置简洁博客主题,支持文章/页面/分类/搜索/分页', active: true, builtin: true },
23
+ { name: 'theme-minimal', label: '极简主题', description: '衬线字体 + 留白布局,适合个人博客和作品集', active: false, builtin: true, dir: 'theme-minimal' }
24
+ ];
25
+
26
+ /**
27
+ * Get active theme name from settings or default.
28
+ */
29
+ function getActiveTheme(store) {
30
+ return store?._settings?.theme || 'default';
31
+ }
32
+
33
+ export async function themeRoutes(ctx) {
34
+ const { pathname } = ctx.url;
35
+ const method = ctx.req.method;
36
+
37
+ // GET /api/theme — list themes
38
+ if (pathname === '/api/theme' && method === 'GET') {
39
+ // No auth required for listing
40
+ const customThemes = [];
41
+ if (existsSync(THEME_DIR)) {
42
+ readdirSync(THEME_DIR, { withFileTypes: true }).forEach(entry => {
43
+ if (entry.isDirectory()) customThemes.push(entry.name);
44
+ });
45
+ }
46
+
47
+ const activeTheme = 'default'; // TODO: read from settings
48
+ const all = BUILT_IN_THEMES.map(t => ({
49
+ ...t,
50
+ active: t.name === activeTheme
51
+ }));
52
+
53
+ for (const name of customThemes) {
54
+ if (!all.find(t => t.name === name)) {
55
+ all.push({ name, label: name, description: '自定义主题', active: name === activeTheme, builtin: false });
56
+ }
57
+ }
58
+
59
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
60
+ ctx.res.end(JSON.stringify({ themes: all, active: activeTheme }));
61
+ return;
62
+ }
63
+
64
+ // Auth required for management
65
+ const authResult = await requireAuth(ctx);
66
+ if (!authResult.authenticated) {
67
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
68
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
69
+ return;
70
+ }
71
+
72
+ // POST /api/theme/activate/:name
73
+ const actMatch = pathname.match(/^\/api\/theme\/activate\/([\w-]+)$/);
74
+ if (actMatch && method === 'POST') {
75
+ const name = actMatch[1];
76
+ const isValid = BUILT_IN_THEMES.some(t => t.name === name) || existsSync(join(THEME_DIR, name));
77
+ if (!isValid) {
78
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
79
+ ctx.res.end(JSON.stringify({ error: 'Theme not found' }));
80
+ return;
81
+ }
82
+ // Store active theme in settings (simplified: write to env or config)
83
+ try {
84
+ const store = ctx.store;
85
+ if (store && store._settings !== undefined) store._settings = store._settings || {};
86
+ if (store) store._settings = { ...(store._settings || {}), theme: name };
87
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
88
+ ctx.res.end(JSON.stringify({ success: true, active: name }));
89
+ } catch (e) {
90
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
91
+ ctx.res.end(JSON.stringify({ error: e.message }));
92
+ }
93
+ return;
94
+ }
95
+
96
+ // DELETE /api/theme/:name
97
+
98
+ // DELETE /api/theme/:name
99
+ const delMatch = pathname.match(/^\/api\/theme\/([\w-]+)$/);
100
+ if (delMatch && method === 'DELETE') {
101
+ const name = delMatch[1];
102
+ if (name === 'default') {
103
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
104
+ ctx.res.end(JSON.stringify({ error: 'Cannot delete default theme' }));
105
+ return;
106
+ }
107
+ const dir = join(THEME_DIR, name);
108
+ if (existsSync(dir)) {
109
+ readdirSync(dir).forEach(f => unlinkSync(join(dir, f)));
110
+ rmdirSync(dir);
111
+ }
112
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
113
+ ctx.res.end(JSON.stringify({ success: true }));
114
+ return;
115
+ }
116
+
117
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
118
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
119
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Webhook Routes
3
+ *
4
+ * POST /api/webhooks — Register webhook
5
+ * GET /api/webhooks — List webhooks
6
+ * DELETE /api/webhooks/:id — Delete webhook
7
+ * GET /api/webhooks/log — Delivery log
8
+ */
9
+
10
+ import { requireAuth } from '../middleware/auth.js';
11
+ import { getWebhookManager } from '../webhook.js';
12
+ import { getStore } from '../context.js';
13
+
14
+ export async function webhookRoutes(ctx) {
15
+ const { pathname } = ctx.url;
16
+ const method = ctx.req.method;
17
+
18
+ const wm = getWebhookManager(getStore());
19
+
20
+ // POST /api/webhooks — register
21
+ if (pathname === '/api/webhooks' && method === 'POST') {
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 { url, events, types, secret, label } = ctx.body || {};
30
+ if (!url) {
31
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
32
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'url is required' }));
33
+ return;
34
+ }
35
+
36
+ try {
37
+ const wh = await wm.register({ url, events, types, secret, label });
38
+ ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
39
+ ctx.res.end(JSON.stringify({ webhook: wh, secret: wh.secret, note: 'Save this secret — it will not be shown again' }));
40
+ } catch (err) {
41
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
42
+ ctx.res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message: err.message }));
43
+ }
44
+ return;
45
+ }
46
+
47
+ // GET /api/webhooks — list
48
+ if (pathname === '/api/webhooks' && method === 'GET') {
49
+ const authResult = await requireAuth(ctx);
50
+ if (!authResult.authenticated) {
51
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
52
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
53
+ return;
54
+ }
55
+
56
+ const hooks = await wm.list();
57
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
58
+ ctx.res.end(JSON.stringify({ webhooks: hooks.map(h => ({ id: h.id, url: h.url, label: h.label, events: h.events, types: h.types, active: h.active, stats: h.stats, createdAt: h.createdAt })) }));
59
+ return;
60
+ }
61
+
62
+ // GET /api/webhooks/log — delivery log
63
+ if (pathname === '/api/webhooks/log' && method === 'GET') {
64
+ const authResult = await requireAuth(ctx);
65
+ if (!authResult.authenticated) {
66
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
67
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
68
+ return;
69
+ }
70
+
71
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
72
+ ctx.res.end(JSON.stringify({ log: wm.getLog(), stats: wm.getStats() }));
73
+ return;
74
+ }
75
+
76
+ // DELETE /api/webhooks/:id
77
+ const delMatch = pathname.match(/^\/api\/webhooks\/([\w-]+)$/);
78
+ if (delMatch && method === 'DELETE') {
79
+ const authResult = await requireAuth(ctx);
80
+ if (!authResult.authenticated) {
81
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
82
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
83
+ return;
84
+ }
85
+
86
+ await wm.remove(delMatch[1]);
87
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
88
+ ctx.res.end(JSON.stringify({ success: true }));
89
+ return;
90
+ }
91
+
92
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
93
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
94
+ }