@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,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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
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
|
+
}
|