@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,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
|
+
}
|