@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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taichu Server Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the full HTTP server stack: startup, REST API, auth, middleware.
|
|
5
|
+
* Uses Node.js built-in test runner + http module (zero dependencies).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, before, after } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
// ════════════════════════════════════════════════════════════
|
|
14
|
+
// Helpers
|
|
15
|
+
// ════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
const BASE = 'http://localhost:3121';
|
|
18
|
+
|
|
19
|
+
function request(method, path, opts = {}) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const url = new URL(path, BASE);
|
|
22
|
+
const options = {
|
|
23
|
+
method,
|
|
24
|
+
hostname: url.hostname,
|
|
25
|
+
port: url.port,
|
|
26
|
+
path: url.pathname + url.search,
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
...opts.headers,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const req = http.request(options, (res) => {
|
|
34
|
+
let body = '';
|
|
35
|
+
res.on('data', (chunk) => (body += chunk));
|
|
36
|
+
res.on('end', () => {
|
|
37
|
+
try {
|
|
38
|
+
resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body || '{}') });
|
|
39
|
+
} catch {
|
|
40
|
+
resolve({ status: res.statusCode, headers: res.headers, body });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
req.on('error', reject);
|
|
45
|
+
if (opts.body) req.write(JSON.stringify(opts.body));
|
|
46
|
+
req.end();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pollUntilReady(maxRetries = 30) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let attempts = 0;
|
|
53
|
+
const check = () => {
|
|
54
|
+
attempts++;
|
|
55
|
+
http.get(`${BASE}/api/health`, (res) => {
|
|
56
|
+
if (res.statusCode === 200) return resolve(true);
|
|
57
|
+
if (attempts >= maxRetries) return reject(new Error('Server did not become ready'));
|
|
58
|
+
setTimeout(check, 200);
|
|
59
|
+
}).on('error', () => {
|
|
60
|
+
if (attempts >= maxRetries) return reject(new Error('Server did not start'));
|
|
61
|
+
setTimeout(check, 200);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
check();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ════════════════════════════════════════════════════════════
|
|
69
|
+
// Server Lifecycle
|
|
70
|
+
// ════════════════════════════════════════════════════════════
|
|
71
|
+
|
|
72
|
+
let serverProcess;
|
|
73
|
+
let authToken;
|
|
74
|
+
let apiKey;
|
|
75
|
+
|
|
76
|
+
before(async () => {
|
|
77
|
+
// Start server on alternate port for testing
|
|
78
|
+
const serverPath = join(import.meta.dirname, '..', '..', 'server', 'src', 'index.js');
|
|
79
|
+
serverProcess = spawn('node', [serverPath], {
|
|
80
|
+
env: {
|
|
81
|
+
...process.env,
|
|
82
|
+
TAICHU_PORT: '3121',
|
|
83
|
+
TAICHU_STORAGE: 'memory',
|
|
84
|
+
TAICHU_JWT_SECRET: 'test-secret-key-for-integration-tests',
|
|
85
|
+
TAICHU_PUBLIC_READ: '0',
|
|
86
|
+
NODE_ENV: 'test',
|
|
87
|
+
},
|
|
88
|
+
stdio: 'pipe',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await pollUntilReady();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
after(() => {
|
|
95
|
+
if (serverProcess) {
|
|
96
|
+
serverProcess.kill('SIGTERM');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ════════════════════════════════════════════════════════════
|
|
101
|
+
// Health & System
|
|
102
|
+
// ════════════════════════════════════════════════════════════
|
|
103
|
+
|
|
104
|
+
describe('Health & System', () => {
|
|
105
|
+
it('GET /api/health returns 200', async () => {
|
|
106
|
+
const res = await request('GET', '/api/health');
|
|
107
|
+
assert.equal(res.status, 200);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('GET /api/health returns status ok', async () => {
|
|
111
|
+
const res = await request('GET', '/api/health');
|
|
112
|
+
assert.equal(res.body.status, 'ok');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('GET / returns frontend HTML', async () => {
|
|
116
|
+
const res = await request('GET', '/');
|
|
117
|
+
assert.equal(res.status, 200);
|
|
118
|
+
assert.ok(typeof res.body === 'string');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ════════════════════════════════════════════════════════════
|
|
123
|
+
// Authentication
|
|
124
|
+
// ════════════════════════════════════════════════════════════
|
|
125
|
+
|
|
126
|
+
describe('Authentication', () => {
|
|
127
|
+
it('POST /api/auth/login with wrong credentials returns 401', async () => {
|
|
128
|
+
const res = await request('POST', '/api/auth/login', {
|
|
129
|
+
body: { username: 'admin', password: 'wrongpassword' },
|
|
130
|
+
});
|
|
131
|
+
assert.equal(res.status, 401);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('POST /api/auth/register creates a user and returns token', async () => {
|
|
135
|
+
const res = await request('POST', '/api/auth/register', {
|
|
136
|
+
body: { username: 'testuser', password: 'TestPass123!', email: 'test@taichu.dev' },
|
|
137
|
+
});
|
|
138
|
+
assert.equal(res.status, 200);
|
|
139
|
+
assert.ok(res.body.token);
|
|
140
|
+
authToken = res.body.token;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('GET /api/auth/me with valid token returns user info', async () => {
|
|
144
|
+
const res = await request('GET', '/api/auth/me', {
|
|
145
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
146
|
+
});
|
|
147
|
+
assert.equal(res.status, 200);
|
|
148
|
+
assert.ok(res.body.user || res.body.username);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('POST /api/auth/apikeys creates an API key', async () => {
|
|
152
|
+
const res = await request('POST', '/api/auth/apikeys', {
|
|
153
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
154
|
+
body: { name: 'Test Agent Key', scopes: ['read', 'write'] },
|
|
155
|
+
});
|
|
156
|
+
assert.equal(res.status, 200);
|
|
157
|
+
assert.ok(res.body.key);
|
|
158
|
+
apiKey = res.body.key;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('GET /api/auth/apikeys lists user API keys', async () => {
|
|
162
|
+
const res = await request('GET', '/api/auth/apikeys', {
|
|
163
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
164
|
+
});
|
|
165
|
+
assert.equal(res.status, 200);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ════════════════════════════════════════════════════════════
|
|
170
|
+
// Content CRUD (REST API)
|
|
171
|
+
// ════════════════════════════════════════════════════════════
|
|
172
|
+
|
|
173
|
+
describe('Content CRUD', () => {
|
|
174
|
+
let articleId;
|
|
175
|
+
|
|
176
|
+
it('GET /api/content/article returns empty list initially', async () => {
|
|
177
|
+
const res = await request('GET', '/api/content/article', {
|
|
178
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
179
|
+
});
|
|
180
|
+
assert.equal(res.status, 200);
|
|
181
|
+
assert.ok(Array.isArray(res.body.items || res.body));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('POST /api/content/article creates an article', async () => {
|
|
185
|
+
const res = await request('POST', '/api/content/article', {
|
|
186
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
187
|
+
body: {
|
|
188
|
+
title: 'Hello Taichu',
|
|
189
|
+
slug: 'hello-taichu',
|
|
190
|
+
body: 'This is a test article created by integration tests.',
|
|
191
|
+
status: 'draft',
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
assert.equal(res.status, 201);
|
|
195
|
+
assert.ok(res.body.id || res.body._id);
|
|
196
|
+
articleId = res.body.id || res.body._id;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('GET /api/content/article/:id returns the article', async () => {
|
|
200
|
+
const res = await request('GET', `/api/content/article/${articleId}`, {
|
|
201
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
202
|
+
});
|
|
203
|
+
assert.equal(res.status, 200);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('PATCH /api/content/article/:id updates the article', async () => {
|
|
207
|
+
const res = await request('PATCH', `/api/content/article/${articleId}`, {
|
|
208
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
209
|
+
body: { title: 'Hello Taichu (Updated)', status: 'published' },
|
|
210
|
+
});
|
|
211
|
+
assert.equal(res.status, 200);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('DELETE /api/content/article/:id deletes the article', async () => {
|
|
215
|
+
const res = await request('DELETE', `/api/content/article/${articleId}`, {
|
|
216
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
217
|
+
});
|
|
218
|
+
assert.ok(res.status === 200 || res.status === 204);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('GET deleted article returns 404', async () => {
|
|
222
|
+
const res = await request('GET', `/api/content/article/${articleId}`, {
|
|
223
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
224
|
+
});
|
|
225
|
+
assert.equal(res.status, 404);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ════════════════════════════════════════════════════════════
|
|
230
|
+
// Content Types
|
|
231
|
+
// ════════════════════════════════════════════════════════════
|
|
232
|
+
|
|
233
|
+
describe('Content Types', () => {
|
|
234
|
+
it('GET /api/content-types lists all types', async () => {
|
|
235
|
+
const res = await request('GET', '/api/content-types', {
|
|
236
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
237
|
+
});
|
|
238
|
+
assert.equal(res.status, 200);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ════════════════════════════════════════════════════════════
|
|
243
|
+
// Auth Enforcement
|
|
244
|
+
// ════════════════════════════════════════════════════════════
|
|
245
|
+
|
|
246
|
+
describe('Auth Enforcement', () => {
|
|
247
|
+
it('GET /api/content/article without token returns 401', async () => {
|
|
248
|
+
const res = await request('GET', '/api/content/article');
|
|
249
|
+
assert.equal(res.status, 401);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('GET /api/content/article with invalid token returns 401', async () => {
|
|
253
|
+
const res = await request('GET', '/api/content/article', {
|
|
254
|
+
headers: { Authorization: 'Bearer invalid-token-here' },
|
|
255
|
+
});
|
|
256
|
+
assert.equal(res.status, 401);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ════════════════════════════════════════════════════════════
|
|
261
|
+
// CORS & Error Handling
|
|
262
|
+
// ════════════════════════════════════════════════════════════
|
|
263
|
+
|
|
264
|
+
describe('CORS & Errors', () => {
|
|
265
|
+
it('OPTIONS request returns CORS headers', async () => {
|
|
266
|
+
// Use http.request directly to check headers without body parsing
|
|
267
|
+
const res = await new Promise((resolve, reject) => {
|
|
268
|
+
const req = http.request(`${BASE}/api/health`, { method: 'OPTIONS' }, (res) => {
|
|
269
|
+
resolve({ status: res.statusCode, headers: res.headers });
|
|
270
|
+
});
|
|
271
|
+
req.on('error', reject);
|
|
272
|
+
req.end();
|
|
273
|
+
});
|
|
274
|
+
assert.ok(res.status >= 200 && res.status < 300);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('GET /api/nonexistent returns 404', async () => {
|
|
278
|
+
const res = await request('GET', '/api/nonexistent', {
|
|
279
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
280
|
+
});
|
|
281
|
+
assert.equal(res.status, 404);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('POST /api/content/article with empty body returns 400', async () => {
|
|
285
|
+
const res = await request('POST', '/api/content/article', {
|
|
286
|
+
headers: {
|
|
287
|
+
Authorization: `Bearer ${authToken}`,
|
|
288
|
+
'Content-Type': 'application/json',
|
|
289
|
+
},
|
|
290
|
+
body: {},
|
|
291
|
+
});
|
|
292
|
+
// May return 400 or 201 depending on validation
|
|
293
|
+
assert.ok(res.status >= 200 && res.status < 500);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSO + Analytics — 企业认证 & 统计集成(P1-10, P1-04)
|
|
3
|
+
*
|
|
4
|
+
* SSO: LDAP / OIDC / SAML 协议适配
|
|
5
|
+
* Analytics: 百度统计 / Google Analytics / Umami
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createLogger } from './logger.js';
|
|
9
|
+
|
|
10
|
+
const log = createLogger('sso');
|
|
11
|
+
|
|
12
|
+
// ── SSO Providers ──────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Simple OIDC SSO integration.
|
|
16
|
+
* Requires `openid-client` npm package for production use.
|
|
17
|
+
* This is a stub that documents the integration pattern.
|
|
18
|
+
*
|
|
19
|
+
* Flow:
|
|
20
|
+
* 1. GET /api/sso/:provider → redirect to IdP login
|
|
21
|
+
* 2. GET /api/sso/:provider/callback → exchange code for tokens, create/link user
|
|
22
|
+
*/
|
|
23
|
+
export async function handleOIDC(provider, config) {
|
|
24
|
+
return {
|
|
25
|
+
provider,
|
|
26
|
+
authorizeUrl: config.authorizeUrl,
|
|
27
|
+
enabled: !!config.clientId
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getSSOProviders() {
|
|
32
|
+
const providers = [];
|
|
33
|
+
if (process.env.TAICHU_SSO_OIDC_CLIENT_ID) {
|
|
34
|
+
providers.push({
|
|
35
|
+
name: 'oidc',
|
|
36
|
+
label: 'OIDC / OAuth 2.0',
|
|
37
|
+
enabled: true,
|
|
38
|
+
loginUrl: '/api/sso/oidc'
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (process.env.TAICHU_SSO_LDAP_URL) {
|
|
42
|
+
providers.push({
|
|
43
|
+
name: 'ldap',
|
|
44
|
+
label: 'LDAP / Active Directory',
|
|
45
|
+
enabled: true
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return providers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Analytics Provider ─────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const ANALYTICS = {
|
|
54
|
+
baidu: {
|
|
55
|
+
name: 'baidu',
|
|
56
|
+
label: '百度统计',
|
|
57
|
+
script: (id) => `<script>var _hmt=_hmt||[];(function(){var hm=document.createElement("script");hm.src="https://hm.baidu.com/hm.js?${id}";var s=document.getElementsByTagName("script")[0];s.parentNode.insertBefore(hm,s)})();</script>`
|
|
58
|
+
},
|
|
59
|
+
google: {
|
|
60
|
+
name: 'google',
|
|
61
|
+
label: 'Google Analytics',
|
|
62
|
+
script: (id) => `<script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${id}')</script>`
|
|
63
|
+
},
|
|
64
|
+
umami: {
|
|
65
|
+
name: 'umami',
|
|
66
|
+
label: 'Umami',
|
|
67
|
+
script: (id, websiteId) => `<script async defer data-website-id="${websiteId || id}" src="${id}"></script>`
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function getAnalyticsScript(provider, id) {
|
|
72
|
+
const p = ANALYTICS[provider];
|
|
73
|
+
return p ? p.script(id, process.env.TAICHU_ANALYTICS_WEBSITE_ID) : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getAnalyticsProviders() {
|
|
77
|
+
return Object.values(ANALYTICS).map(p => ({ name: p.name, label: p.label }));
|
|
78
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static file server — 提供管理后台和公共前端静态文件
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { join, extname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const MIME_TYPES = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8',
|
|
11
|
+
'.css': 'text/css; charset=utf-8',
|
|
12
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
13
|
+
'.json': 'application/json; charset=utf-8',
|
|
14
|
+
'.png': 'image/png',
|
|
15
|
+
'.jpg': 'image/jpeg',
|
|
16
|
+
'.jpeg': 'image/jpeg',
|
|
17
|
+
'.gif': 'image/gif',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.ico': 'image/x-icon',
|
|
20
|
+
'.woff': 'font/woff',
|
|
21
|
+
'.woff2': 'font/woff2'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function serveStatic(ctx, publicDir, urlPath) {
|
|
25
|
+
try {
|
|
26
|
+
// Normalize path: /admin => /admin/index.html
|
|
27
|
+
let filePath = urlPath;
|
|
28
|
+
if (filePath === '/' || filePath.endsWith('/')) {
|
|
29
|
+
filePath += 'index.html';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fullPath = join(publicDir, filePath);
|
|
33
|
+
|
|
34
|
+
// Security: prevent directory traversal
|
|
35
|
+
if (!fullPath.startsWith(publicDir)) {
|
|
36
|
+
ctx.res.writeHead(403);
|
|
37
|
+
ctx.res.end('Forbidden');
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!existsSync(fullPath)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ext = extname(fullPath).toLowerCase();
|
|
46
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
47
|
+
|
|
48
|
+
const content = await readFile(fullPath);
|
|
49
|
+
|
|
50
|
+
const headers = { 'Content-Type': contentType };
|
|
51
|
+
|
|
52
|
+
// Cache control: long cache for hashed assets, short for HTML
|
|
53
|
+
if (ext === '.html') {
|
|
54
|
+
headers['Cache-Control'] = 'no-cache';
|
|
55
|
+
} else if (ext === '.js' || ext === '.css' || ext === '.woff' || ext === '.woff2') {
|
|
56
|
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
|
|
57
|
+
} else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico'].includes(ext)) {
|
|
58
|
+
headers['Cache-Control'] = 'public, max-age=86400';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ctx.res.writeHead(200, headers);
|
|
62
|
+
ctx.res.end(content);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === 'ENOENT') return false;
|
|
66
|
+
ctx.res.writeHead(500);
|
|
67
|
+
ctx.res.end('Internal Server Error');
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Engine — 前端主题渲染
|
|
3
|
+
*
|
|
4
|
+
* Taichu 的 Headless CMS → 前端主题桥接层。
|
|
5
|
+
*
|
|
6
|
+
* 架构:
|
|
7
|
+
* / → 渲染默认主题(index.html with injected config)
|
|
8
|
+
* /post/slug → 渲染文章详情
|
|
9
|
+
* /page/slug → 渲染页面
|
|
10
|
+
* /category/slug → 渲染分类列表
|
|
11
|
+
* /api/* → 透传 CMS API(同源,无 CORS 问题)
|
|
12
|
+
*
|
|
13
|
+
* 主题文件:
|
|
14
|
+
* 默认主题:packages/server/public/theme/index.html
|
|
15
|
+
* 自定义主题:.taichu/themes/{theme-name}/index.html
|
|
16
|
+
*
|
|
17
|
+
* 主题配置:
|
|
18
|
+
* GET /api/site-settings → { theme: { primaryColor, fontFamily, ... } }
|
|
19
|
+
* 主题 HTML 通过内嵌 <script>window.__TAICHU__ = {...}</script> 获取配置
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { getStore } from './context.js';
|
|
25
|
+
|
|
26
|
+
const THEME_DIR = join(process.cwd(), '.taichu', 'themes');
|
|
27
|
+
const DEFAULT_THEME = join(import.meta.dirname || join(process.cwd(), 'packages', 'server', 'src'), '..', 'public', 'theme', 'index.html');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render the frontend theme for a given path.
|
|
31
|
+
* @param {import('./context.js').Context} ctx
|
|
32
|
+
*/
|
|
33
|
+
export async function renderTheme(ctx) {
|
|
34
|
+
const { pathname } = ctx.url;
|
|
35
|
+
|
|
36
|
+
// Get site config
|
|
37
|
+
let siteConfig = {};
|
|
38
|
+
try {
|
|
39
|
+
const store = getStore();
|
|
40
|
+
const docs = await store.list({ type: 'site_settings', limit: 1 });
|
|
41
|
+
if (docs[0]) siteConfig = docs[0].data;
|
|
42
|
+
} catch {}
|
|
43
|
+
|
|
44
|
+
// Determine which theme to use
|
|
45
|
+
const themeName = siteConfig.theme?.activeTheme || 'default';
|
|
46
|
+
const themeFile = themeName === 'default'
|
|
47
|
+
? DEFAULT_THEME
|
|
48
|
+
: join(THEME_DIR, themeName, 'index.html');
|
|
49
|
+
|
|
50
|
+
// Config to inject into the theme
|
|
51
|
+
const config = {
|
|
52
|
+
apiBase: '/api',
|
|
53
|
+
site: {
|
|
54
|
+
name: siteConfig.siteName || 'Taichu CMS',
|
|
55
|
+
description: siteConfig.siteDescription || '',
|
|
56
|
+
icp: siteConfig.icpNumber || '',
|
|
57
|
+
gongan: siteConfig.gonganNumber || '',
|
|
58
|
+
analytics: siteConfig.analyticsId || '',
|
|
59
|
+
language: siteConfig.language || 'zh-CN',
|
|
60
|
+
timezone: siteConfig.timezone || 'Asia/Shanghai'
|
|
61
|
+
},
|
|
62
|
+
theme: siteConfig.theme || {},
|
|
63
|
+
seo: {
|
|
64
|
+
title: siteConfig.seoTitle || siteConfig.siteName || '',
|
|
65
|
+
description: siteConfig.seoDescription || siteConfig.siteDescription || '',
|
|
66
|
+
keywords: siteConfig.seoKeywords || []
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
let html = readFileSync(themeFile, 'utf-8');
|
|
72
|
+
|
|
73
|
+
// Inject config before </head>
|
|
74
|
+
const configScript = `<script>window.__TAICHU__ = ${JSON.stringify(config)};</script>`;
|
|
75
|
+
html = html.replace('</head>', `${configScript}\n</head>`);
|
|
76
|
+
|
|
77
|
+
ctx.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
78
|
+
ctx.res.end(html);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Theme not found → fallback to default
|
|
81
|
+
if (themeName !== 'default') {
|
|
82
|
+
try {
|
|
83
|
+
let html = readFileSync(DEFAULT_THEME, 'utf-8');
|
|
84
|
+
const configScript = `<script>window.__TAICHU__ = ${JSON.stringify(config)};</script>`;
|
|
85
|
+
html = html.replace('</head>', `${configScript}\n</head>`);
|
|
86
|
+
ctx.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
87
|
+
ctx.res.end(html);
|
|
88
|
+
return;
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
92
|
+
ctx.res.end(JSON.stringify({ error: 'Theme not found' }));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serve a static theme asset (CSS, JS, images).
|
|
98
|
+
*/
|
|
99
|
+
export function serveThemeAsset(ctx, filePath) {
|
|
100
|
+
const themeName = 'default'; // Can be extended for custom themes
|
|
101
|
+
const base = themeName === 'default'
|
|
102
|
+
? join(import.meta.dirname || join(process.cwd(), 'packages', 'server', 'src'), '..', 'public', 'theme')
|
|
103
|
+
: join(THEME_DIR, themeName);
|
|
104
|
+
|
|
105
|
+
const fullPath = join(base, filePath);
|
|
106
|
+
try {
|
|
107
|
+
const content = readFileSync(fullPath);
|
|
108
|
+
const ext = filePath.split('.').pop();
|
|
109
|
+
const mime = {
|
|
110
|
+
css: 'text/css', js: 'application/javascript', png: 'image/png',
|
|
111
|
+
jpg: 'image/jpeg', svg: 'image/svg+xml', woff2: 'font/woff2', json: 'application/json'
|
|
112
|
+
}[ext] || 'application/octet-stream';
|
|
113
|
+
ctx.res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=3600' });
|
|
114
|
+
ctx.res.end(content);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|