@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,195 @@
1
+ /**
2
+ * @taichu/server — Taichu HTTP Server
3
+ *
4
+ * 零外部依赖的 HTTP 服务。
5
+ * 负责:
6
+ * 1. 路由分发
7
+ * 2. JSON 解析
8
+ * 3. CORS 处理
9
+ * 4. 静态文件服务(管理后台 SPA)
10
+ * 5. MCP 端点(Phase 2)
11
+ *
12
+ * 设计原则:
13
+ * - 每个模块可独立测试
14
+ * - 错误统一捕获并序列化为 JSON
15
+ * - 请求上下文通过 context object 传递
16
+ */
17
+
18
+ import { createServer } from 'node:http';
19
+ import { router } from './router.js';
20
+ import { parseBody } from './body-parser.js';
21
+ import { corsMiddleware } from './middleware/cors.js';
22
+ import { errorHandler } from './middleware/error-handler.js';
23
+ import { createContext } from './context.js';
24
+ import { bootstrap } from './bootstrap.js';
25
+ import { initSearch } from './search.js';
26
+ import { logger } from './logger.js';
27
+ import { loadConfig, getConfig, getConfigWarnings, configSummary } from './config.js';
28
+ import { getWSS } from './websocket.js';
29
+ import { getWebhookManager } from './webhook.js';
30
+ import { rateLimit } from './middleware/rate-limit.js';
31
+ import { record as auditRecord } from './audit.js';
32
+ import { snapshotRevision } from './revisions.js';
33
+ import { notify } from './notify.js';
34
+ import { startScheduler, stopScheduler } from './scheduler.js';
35
+
36
+ export async function start(configOverrides = {}) {
37
+ const config = loadConfig();
38
+ const { port, host, storage, dataDir, version } = config;
39
+
40
+ // Pre-init store so the first request doesn't pay cold-start cost
41
+ const ctx = await createContext({ req: null, res: null, url: null, body: null, config: { storage, dataDir } });
42
+ const storeType = ctx.store.getDbPath ? `sqlite (${ctx.store.getDbPath()})` : 'memory';
43
+
44
+ // Init vector search index
45
+ await initSearch(ctx.store, ctx.hooks);
46
+
47
+ // Init WebSocket for real-time updates
48
+ const wss = getWSS();
49
+
50
+ // Init webhook manager
51
+ const webhooks = getWebhookManager(ctx.store);
52
+
53
+ // Init scheduled publishing scheduler
54
+ startScheduler(ctx.hooks);
55
+
56
+ // Register content change broadcasts via hooks
57
+ for (const event of ['afterCreate', 'afterUpdate', 'afterDelete']) {
58
+ ctx.hooks.on(event, async (doc) => {
59
+ const wsEvent = event.replace('after', '').toLowerCase();
60
+ const payload = {
61
+ id: doc.id, type: doc.type, status: doc.status,
62
+ title: doc.data?.title || doc.data?.name || '',
63
+ updatedAt: doc.updatedAt
64
+ };
65
+
66
+ // WebSocket broadcast
67
+ wss.broadcast(doc.type || '*', wsEvent, payload);
68
+
69
+ // Webhook fire (async, don't block)
70
+ webhooks.fire(wsEvent, { ...payload, data: doc.data }).catch(() => {});
71
+
72
+ // Audit log (async, don't block)
73
+ auditRecord({
74
+ actorId: doc.createdBy || doc._meta?.createdBy?.agentId || 'system',
75
+ actorType: doc._meta?.createdBy?.type || 'system',
76
+ action: wsEvent,
77
+ resourceType: doc.type,
78
+ resourceId: doc.id,
79
+ detail: { title: doc.data?.title, status: doc.status }
80
+ }).catch(() => {});
81
+
82
+ // Snapshot revision (async, don't block)
83
+ if (wsEvent !== 'delete') {
84
+ snapshotRevision(doc, { id: doc.createdBy || 'system' }).catch(() => {});
85
+ }
86
+
87
+ // IM notification (async, don't block)
88
+ notify(`content_${wsEvent}d`, { doc, summary: payload.title || '' }).catch(() => {});
89
+ });
90
+ }
91
+
92
+ const server = createServer(async (req, res) => {
93
+ try {
94
+ // 1. CORS
95
+ corsMiddleware(req, res);
96
+ if (req.method === 'OPTIONS') {
97
+ res.writeHead(204);
98
+ res.end();
99
+ return;
100
+ }
101
+
102
+ // 2. Parse URL
103
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
104
+
105
+ // 3. Rate limit (skip for health, static files)
106
+ if (!url.pathname.startsWith('/admin') && !url.pathname.startsWith('/uploads') && url.pathname !== '/health' && url.pathname !== '/ws-test.html') {
107
+ const ctx = { req, res, url };
108
+ if (!rateLimit(ctx)) return; // 429 already written
109
+ }
110
+
111
+ // 3. Parse body (for POST/PUT/PATCH)
112
+ let body = null;
113
+ if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
114
+ body = await parseBody(req);
115
+ }
116
+
117
+ // 4. Build context (reuses pre-initialized store)
118
+ const ctx2 = await createContext({ req, res, url, body, config: { storage, dataDir } });
119
+
120
+ // 5. Route
121
+ await router(ctx2);
122
+
123
+ } catch (err) {
124
+ errorHandler(res, err);
125
+ }
126
+ });
127
+
128
+ // Attach WebSocket to HTTP server
129
+ wss.attach(server);
130
+
131
+ server.listen(port, host, () => {
132
+ const startMsg = [
133
+ '',
134
+ ' ██████╗ ██╗ ██████╗ ███╗ ██╗',
135
+ ' ██╔════╝ ██║██╔═══██╗████╗ ██║',
136
+ ' ██║ ███╗██║██║ ██║██╔██╗ ██║',
137
+ ' ██║ ██║██║██║ ██║██║╚██╗██║',
138
+ ' ╚██████╔╝██║╚██████╔╝██║ ╚████║',
139
+ ' ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝',
140
+ '',
141
+ ` Taichu CMS v${version}`,
142
+ ` AI Agent-Native Content Infrastructure`,
143
+ ` Store: ${storeType}`,
144
+ '',
145
+ ` Local: http://localhost:${port}`,
146
+ ` Network: http://${host}:${port}`,
147
+ '',
148
+ ` API: http://localhost:${port}/api`,
149
+ ` Health: http://localhost:${port}/api/health`,
150
+ ` Live: ws://localhost:${port}`,
151
+ '',
152
+ ` Ready.`,
153
+ ''
154
+ ].join('\n');
155
+ console.log(startMsg);
156
+ });
157
+
158
+ // Graceful shutdown
159
+ const shutdown = async (signal) => {
160
+ logger.info(`${signal} received. Shutting down...`);
161
+ stopScheduler();
162
+ wss.close();
163
+ server.close(() => {
164
+ logger.info('HTTP server closed');
165
+ // Flush store if needed
166
+ if (ctx.store.flush) {
167
+ ctx.store.flush().then(() => process.exit(0)).catch(() => process.exit(0));
168
+ } else {
169
+ process.exit(0);
170
+ }
171
+ });
172
+ // Force exit after 10s
173
+ setTimeout(() => process.exit(1), 10000);
174
+ };
175
+
176
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
177
+ process.on('SIGINT', () => shutdown('SIGINT'));
178
+
179
+ server.on('error', (err) => {
180
+ if (err.code === 'EADDRINUSE') {
181
+ console.error(`Port ${port} is already in use. Try: TAICHU_PORT=${port + 1} npm start`);
182
+ } else {
183
+ console.error('Server error:', err.message);
184
+ }
185
+ process.exit(1);
186
+ });
187
+
188
+ return server;
189
+ }
190
+
191
+ // Run directly if called as main module
192
+ if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) {
193
+ bootstrap();
194
+ await start({});
195
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Logger — 结构化日志系统
3
+ *
4
+ * JSON 格式输出,支持分级和请求追踪。
5
+ *
6
+ * 级别(数值越小越详细):
7
+ * debug: 0 — 开发调试
8
+ * info: 1 — 常规信息
9
+ * warn: 2 — 警告
10
+ * error: 3 — 错误
11
+ *
12
+ * 环境变量:
13
+ * TAICHU_LOG_LEVEL — 最小输出级别(默认 info)
14
+ * TAICHU_LOG_FORMAT — "json" | "pretty"(默认 pretty)
15
+ */
16
+
17
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
18
+ const LEVEL_LABELS = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
19
+
20
+ // ANSI 颜色(pretty 模式)
21
+ const COLORS = {
22
+ debug: '\x1b[90m', // gray
23
+ info: '\x1b[36m', // cyan
24
+ warn: '\x1b[33m', // yellow
25
+ error: '\x1b[31m', // red
26
+ reset: '\x1b[0m',
27
+ dim: '\x1b[2m',
28
+ bold: '\x1b[1m'
29
+ };
30
+
31
+ const minLevel = LEVELS[process.env.TAICHU_LOG_LEVEL || 'info'] ?? 1;
32
+ const format = process.env.TAICHU_LOG_FORMAT || 'pretty';
33
+
34
+ /**
35
+ * @param {string} level
36
+ * @param {string} message
37
+ * @param {object} [context]
38
+ */
39
+ function log(level, message, context = {}) {
40
+ if (LEVELS[level] < minLevel) return;
41
+
42
+ const ts = new Date().toISOString();
43
+
44
+ if (format === 'json') {
45
+ const entry = { ts, level, message, ...context };
46
+ console.log(JSON.stringify(entry));
47
+ return;
48
+ }
49
+
50
+ // Pretty format
51
+ const color = COLORS[level] || '';
52
+ const label = level.toUpperCase().padEnd(5);
53
+ const extra = Object.keys(context).length
54
+ ? COLORS.dim + ' ' + JSON.stringify(context) + COLORS.reset
55
+ : '';
56
+
57
+ console.log(`${COLORS.dim}${ts}${COLORS.reset} ${color}${COLORS.bold}${label}${COLORS.reset} ${message}${extra}`);
58
+ }
59
+
60
+ export const logger = {
61
+ debug: (msg, ctx) => log('debug', msg, ctx),
62
+ info: (msg, ctx) => log('info', msg, ctx),
63
+ warn: (msg, ctx) => log('warn', msg, ctx),
64
+ error: (msg, ctx) => log('error', msg, ctx)
65
+ };
66
+
67
+ /**
68
+ * Create a child logger with a fixed namespace.
69
+ * Usage: const log = logger.child('store'); log.info('connected');
70
+ */
71
+ export function createLogger(namespace) {
72
+ return {
73
+ debug: (msg, ctx) => logger.debug(`[${namespace}] ${msg}`, ctx),
74
+ info: (msg, ctx) => logger.info(`[${namespace}] ${msg}`, ctx),
75
+ warn: (msg, ctx) => logger.warn(`[${namespace}] ${msg}`, ctx),
76
+ error: (msg, ctx) => logger.error(`[${namespace}] ${msg}`, ctx)
77
+ };
78
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Media Store — 文件存储抽象
3
+ *
4
+ * 支持:
5
+ * - 本地文件系统存储(接口设计为未来扩展 S3/R2 等)
6
+ * - 图片自动压缩(quality 可配置)
7
+ * - WebP 自动转换(原格式保留,额外生成 .webp 版本)
8
+ * - 多尺寸缩略图(small/medium/large)
9
+ */
10
+
11
+ import { writeFile, readFile, mkdir, unlink } from 'node:fs/promises';
12
+ import { existsSync } from 'node:fs';
13
+ import { join, extname } from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+ import sharp from 'sharp';
16
+
17
+ const DEFAULT_UPLOAD_DIR = join(process.cwd(), '.taichu', 'uploads');
18
+
19
+ // Thumbnail sizes: name → max dimension
20
+ const THUMB_SIZES = {
21
+ small: 150,
22
+ medium: 300,
23
+ large: 800
24
+ };
25
+
26
+ // Image compression settings
27
+ const COMPRESS_QUALITY = parseInt(process.env.TAICHU_IMAGE_QUALITY) || 80;
28
+ const WEBP_QUALITY = parseInt(process.env.TAICHU_WEBP_QUALITY) || 75;
29
+ const MAX_IMAGE_DIMENSION = parseInt(process.env.TAICHU_MAX_IMAGE_DIMENSION) || 4096;
30
+ const ENABLE_WEBP = process.env.TAICHU_WEBP !== '0'; // default: enabled
31
+ const ENABLE_COMPRESS = process.env.TAICHU_IMAGE_COMPRESS !== '0'; // default: enabled
32
+
33
+ /**
34
+ * @param {object} config
35
+ * @param {string} [config.uploadDir]
36
+ */
37
+ export function createMediaStore(config = {}) {
38
+ const uploadDir = config.uploadDir || process.env.TAICHU_UPLOAD_DIR || DEFAULT_UPLOAD_DIR;
39
+ const thumbDir = join(uploadDir, 'thumbnails');
40
+
41
+ let initialized = false;
42
+
43
+ async function ensureDirs() {
44
+ if (initialized) return;
45
+ if (!existsSync(uploadDir)) await mkdir(uploadDir, { recursive: true });
46
+ for (const size of Object.keys(THUMB_SIZES)) {
47
+ const dir = join(uploadDir, 'thumbnails', size);
48
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
49
+ }
50
+ // WebP output dir
51
+ const webpDir = join(uploadDir, 'webp');
52
+ if (!existsSync(webpDir)) await mkdir(webpDir, { recursive: true });
53
+ initialized = true;
54
+ }
55
+
56
+ /**
57
+ * Save a file with image processing pipeline:
58
+ * 1. Compress original (if image & enabled)
59
+ * 2. Generate WebP variant (if image & enabled)
60
+ * 3. Generate multi-size thumbnails
61
+ *
62
+ * @param {Buffer} buffer
63
+ * @param {string} filename
64
+ * @param {string} mimetype
65
+ * @returns {Promise<object>}
66
+ */
67
+ async function save(buffer, filename, mimetype) {
68
+ await ensureDirs();
69
+
70
+ const ext = extname(filename) || mimetypeToExt(mimetype) || '.bin';
71
+ const hash = createHash('md5').update(buffer).digest('hex').substring(0, 12);
72
+ const safeFilename = `${hash}${ext}`;
73
+ const filePath = join(uploadDir, safeFilename);
74
+
75
+ const result = {
76
+ id: hash,
77
+ url: `/uploads/${safeFilename}`,
78
+ filename: safeFilename,
79
+ originalName: filename,
80
+ mimetype,
81
+ size: buffer.length,
82
+ thumbnails: {},
83
+ webp: null
84
+ };
85
+
86
+ const isProcessableImage = mimetype.startsWith('image/')
87
+ && mimetype !== 'image/svg+xml'
88
+ && mimetype !== 'image/gif';
89
+
90
+ if (isProcessableImage) {
91
+ try {
92
+ const pipeline = sharp(buffer);
93
+ const metadata = await pipeline.metadata();
94
+ result.width = metadata.width;
95
+ result.height = metadata.height;
96
+
97
+ // Step 1: Compress original if enabled
98
+ let finalBuffer = buffer;
99
+ if (ENABLE_COMPRESS && metadata.width && metadata.width > MAX_IMAGE_DIMENSION) {
100
+ finalBuffer = await pipeline
101
+ .resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, { fit: 'inside', withoutEnlargement: true })
102
+ .toBuffer();
103
+ result.size = finalBuffer.length;
104
+ result.compressed = true;
105
+ } else if (ENABLE_COMPRESS && (mimetype === 'image/jpeg' || mimetype === 'image/png')) {
106
+ // Compress without resize
107
+ let compressor = sharp(buffer);
108
+ if (mimetype === 'image/jpeg') {
109
+ compressor = compressor.jpeg({ quality: COMPRESS_QUALITY, mozjpeg: true });
110
+ } else {
111
+ compressor = compressor.png({ quality: COMPRESS_QUALITY, compressionLevel: 8 });
112
+ }
113
+ const compressed = await compressor.toBuffer();
114
+ if (compressed.length < buffer.length) {
115
+ finalBuffer = compressed;
116
+ result.size = finalBuffer.length;
117
+ result.compressed = true;
118
+ }
119
+ }
120
+
121
+ await writeFile(filePath, finalBuffer);
122
+
123
+ // Step 2: Generate WebP variant
124
+ if (ENABLE_WEBP) {
125
+ const webpFilename = `${hash}.webp`;
126
+ const webpPath = join(uploadDir, 'webp', webpFilename);
127
+ await sharp(finalBuffer)
128
+ .webp({ quality: WEBP_QUALITY })
129
+ .toFile(webpPath);
130
+ result.webp = `/uploads/webp/${webpFilename}`;
131
+ }
132
+
133
+ // Step 3: Generate multi-size thumbnails
134
+ for (const [sizeName, maxDim] of Object.entries(THUMB_SIZES)) {
135
+ const thumbFilename = `${sizeName}_${hash}${ENABLE_WEBP ? '.webp' : ext}`;
136
+ const thumbPath = join(uploadDir, 'thumbnails', sizeName, thumbFilename);
137
+
138
+ let thumbPipeline = sharp(finalBuffer).resize(maxDim, maxDim, { fit: 'inside', withoutEnlargement: true });
139
+ if (ENABLE_WEBP) {
140
+ thumbPipeline = thumbPipeline.webp({ quality: WEBP_QUALITY });
141
+ } else if (mimetype === 'image/jpeg') {
142
+ thumbPipeline = thumbPipeline.jpeg({ quality: 75 });
143
+ } else {
144
+ thumbPipeline = thumbPipeline.png({ quality: 75 });
145
+ }
146
+
147
+ await thumbPipeline.toFile(thumbPath);
148
+ result.thumbnails[sizeName] = `/uploads/thumbnails/${sizeName}/${thumbFilename}`;
149
+ }
150
+ } catch (e) {
151
+ // Non-processable image — save as-is
152
+ await writeFile(filePath, buffer);
153
+ }
154
+ } else {
155
+ // Non-image or SVG/GIF — save as-is
156
+ await writeFile(filePath, buffer);
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * Delete a file and all its variants (thumbnails, WebP).
164
+ */
165
+ async function remove(safeFilename) {
166
+ const filePath = join(uploadDir, safeFilename);
167
+ if (existsSync(filePath)) await unlink(filePath);
168
+
169
+ // Remove WebP variant
170
+ const hash = safeFilename.replace(/\.[^.]+$/, '');
171
+ const webpPath = join(uploadDir, 'webp', `${hash}.webp`);
172
+ if (existsSync(webpPath)) await unlink(webpPath);
173
+
174
+ // Remove all thumbnail sizes
175
+ for (const sizeName of Object.keys(THUMB_SIZES)) {
176
+ const dir = join(uploadDir, 'thumbnails', sizeName);
177
+ // Try both original ext and webp
178
+ const ext = extname(safeFilename);
179
+ const thumbOrig = join(dir, `${sizeName}_${safeFilename}`);
180
+ const thumbWebp = join(dir, `${sizeName}_${hash}.webp`);
181
+ if (existsSync(thumbOrig)) await unlink(thumbOrig);
182
+ if (existsSync(thumbWebp)) await unlink(thumbWebp);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Read a file from disk.
188
+ */
189
+ async function get(safeFilename) {
190
+ const filePath = join(uploadDir, safeFilename);
191
+ if (!existsSync(filePath)) return null;
192
+ return readFile(filePath);
193
+ }
194
+
195
+ return { save, remove, get, uploadDir, thumbDir };
196
+ }
197
+
198
+ function mimetypeToExt(mimetype) {
199
+ const map = {
200
+ 'image/jpeg': '.jpg',
201
+ 'image/png': '.png',
202
+ 'image/gif': '.gif',
203
+ 'image/webp': '.webp',
204
+ 'image/svg+xml': '.svg',
205
+ 'video/mp4': '.mp4',
206
+ 'video/webm': '.webm',
207
+ 'audio/mpeg': '.mp3',
208
+ 'audio/wav': '.wav',
209
+ 'application/pdf': '.pdf',
210
+ 'application/zip': '.zip'
211
+ };
212
+ return map[mimetype] || null;
213
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Auth Middleware — 认证中间件
3
+ *
4
+ * 两种认证方式:
5
+ * Bearer <JWT> → 解析 JWT,识别人类用户
6
+ * X-Taichu-Agent-Key → 匹配 API Key,识别 AI Agent
7
+ *
8
+ * 使用:
9
+ * const result = await requireAuth(ctx);
10
+ * if (!result.authenticated) return error;
11
+ * ctx.actor = result.actor; // { id, type: 'human'|'agent', username, role }
12
+ */
13
+
14
+ import { verifyJWT, verifyAPIKey } from '../../../core/src/auth.js';
15
+ import { getStore } from '../context.js';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ /** Cached JWT secret for the process lifetime */
21
+ let _jwtSecret = null;
22
+
23
+ /**
24
+ * Get or auto-generate a JWT secret.
25
+ *
26
+ * Priority:
27
+ * 1. TAICHU_JWT_SECRET environment variable
28
+ * 2. Auto-generated secret persisted to .taichu/data/.jwt_secret
29
+ *
30
+ * The hardcoded default 'taichu-dev-secret' is explicitly rejected.
31
+ *
32
+ * @returns {string}
33
+ */
34
+ export function getJwtSecret() {
35
+ if (_jwtSecret) return _jwtSecret;
36
+
37
+ // 1. Environment variable
38
+ const envSecret = process.env.TAICHU_JWT_SECRET;
39
+ if (envSecret && envSecret !== 'taichu-dev-secret' && envSecret !== 'change-me') {
40
+ _jwtSecret = envSecret;
41
+ return _jwtSecret;
42
+ }
43
+
44
+ // 2. Auto-generate and persist
45
+ const dataDir = process.env.TAICHU_DATA_DIR || join(process.cwd(), '.taichu', 'data');
46
+ const secretFile = join(dataDir, '.jwt_secret');
47
+
48
+ if (existsSync(secretFile)) {
49
+ _jwtSecret = readFileSync(secretFile, 'utf-8').trim();
50
+ if (_jwtSecret) return _jwtSecret;
51
+ }
52
+
53
+ // Generate new secret
54
+ if (!existsSync(dataDir)) {
55
+ mkdirSync(dataDir, { recursive: true });
56
+ }
57
+ _jwtSecret = randomBytes(32).toString('hex');
58
+ writeFileSync(secretFile, _jwtSecret, 'utf-8');
59
+
60
+ console.log(` Auth: Auto-generated JWT secret saved to ${secretFile}`);
61
+ return _jwtSecret;
62
+ }
63
+
64
+ /**
65
+ * Require authentication — returns auth result or 401.
66
+ *
67
+ * @param {Context} ctx
68
+ * @returns {Promise<{ authenticated: boolean, status?: number, error?: string, message?: string, actor?: object }>}
69
+ */
70
+ export async function requireAuth(ctx) {
71
+ const authHeader = ctx.req.headers['authorization'];
72
+ const agentKey = ctx.req.headers['x-taichu-agent-key'];
73
+
74
+ // ── JWT Auth (Human) ──
75
+ if (authHeader && authHeader.startsWith('Bearer ')) {
76
+ const token = authHeader.slice(7);
77
+ const secret = getJwtSecret();
78
+ const result = verifyJWT(token, secret);
79
+
80
+ if (!result.valid) {
81
+ return { authenticated: false, status: 401, error: 'UNAUTHORIZED', message: result.error };
82
+ }
83
+
84
+ return {
85
+ authenticated: true,
86
+ actor: {
87
+ id: result.payload.sub,
88
+ type: 'human',
89
+ username: result.payload.username,
90
+ role: result.payload.role || 'author'
91
+ }
92
+ };
93
+ }
94
+
95
+ // ── API Key Auth (Agent) ──
96
+ if (agentKey) {
97
+ const store = getStore();
98
+ const keys = await store.list({ type: 'api_key', status: 'active' });
99
+
100
+ for (const keyDoc of keys) {
101
+ if (verifyAPIKey(agentKey, keyDoc.data.hash)) {
102
+ const scopes = keyDoc.data.scopes || ['*:*']; // default: full access for legacy keys
103
+ return {
104
+ authenticated: true,
105
+ actor: {
106
+ id: keyDoc.data.ownerId || `agent_${keyDoc.data.prefix}`,
107
+ type: 'agent',
108
+ role: 'agent',
109
+ keyPrefix: keyDoc.data.prefix,
110
+ label: keyDoc.data.label,
111
+ scopes
112
+ }
113
+ };
114
+ }
115
+ }
116
+
117
+ return { authenticated: false, status: 401, error: 'UNAUTHORIZED', message: 'Invalid API key' };
118
+ }
119
+
120
+ // ── No credentials ──
121
+ return { authenticated: false, status: 401, error: 'UNAUTHORIZED', message: 'Authentication required' };
122
+ }
123
+
124
+ /**
125
+ * Check if the actor has the required scope.
126
+ *
127
+ * Scope format: "<type>:<action>" where:
128
+ * - type: content type name (e.g. "article") or "*" for all
129
+ * - action: "read" | "write" | "delete" | "*" for all
130
+ *
131
+ * Examples:
132
+ * checkScope(actor, 'article:read') → true if actor can read articles
133
+ * checkScope(actor, '*:*') → true if actor is admin
134
+ * checkScope(actor, 'media:write') → true if actor can write media
135
+ *
136
+ * Humans (JWT) always have full access (*:*).
137
+ *
138
+ * @param {object} actor
139
+ * @param {string} required — scope string like "article:read"
140
+ * @returns {boolean}
141
+ */
142
+ export function checkScope(actor, required) {
143
+ // Humans always pass
144
+ if (actor.type === 'human') return true;
145
+
146
+ const scopes = actor.scopes || [];
147
+ if (scopes.includes('*:*')) return true;
148
+
149
+ // Parse required: "type:action" or "type:field:action"
150
+ const parts = required.split(':');
151
+ const reqType = parts[0];
152
+ const reqField = parts.length === 3 ? parts[1] : null;
153
+ const reqAction = parts.length === 3 ? parts[2] : parts[1];
154
+
155
+ for (const scope of scopes) {
156
+ const sParts = scope.split(':');
157
+
158
+ if (sParts.length === 3) {
159
+ // Field-level scope: type:field:action
160
+ const typeMatch = sParts[0] === '*' || sParts[0] === reqType;
161
+ const fieldMatch = sParts[1] === '*' || (reqField && sParts[1] === reqField);
162
+ const actionMatch = sParts[2] === '*' || sParts[2] === reqAction;
163
+ if (typeMatch && fieldMatch && (reqField ? true : true) && actionMatch) return true;
164
+ } else {
165
+ // Type-level scope: type:action
166
+ const typeMatch = sParts[0] === '*' || sParts[0] === reqType;
167
+ const actionMatch = sParts[1] === '*' || sParts[1] === reqAction;
168
+ if (typeMatch && actionMatch) return true;
169
+ }
170
+ }
171
+
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Convenience: require auth + scope check in one call.
177
+ * Returns 403 if authenticated but lacking scope.
178
+ */
179
+ export async function requireScopedAuth(ctx, scope) {
180
+ const result = await requireAuth(ctx);
181
+ if (!result.authenticated) return result;
182
+
183
+ if (!checkScope(result.actor, scope)) {
184
+ return {
185
+ authenticated: false,
186
+ status: 403,
187
+ error: 'FORBIDDEN',
188
+ message: `Insufficient permissions: "${scope}" scope required`
189
+ };
190
+ }
191
+
192
+ return result;
193
+ }
194
+
195
+ /**
196
+ * Optional auth — attaches actor if authenticated, but doesn't block.
197
+ */
198
+ export async function optionalAuth(ctx) {
199
+ const result = await requireAuth(ctx);
200
+ if (result.authenticated) {
201
+ ctx.actor = result.actor;
202
+ }
203
+ }