@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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL API Route
|
|
3
|
+
*
|
|
4
|
+
* REST 之外的第二个 API 通道。
|
|
5
|
+
* 提供灵活的客户端自定义查询——前端想要什么字段就取什么字段。
|
|
6
|
+
*
|
|
7
|
+
* POST /api/graphql — GraphQL 端点
|
|
8
|
+
* GET /api/graphql — GraphiQL 交互式 IDE(开发环境)
|
|
9
|
+
*
|
|
10
|
+
* 支持 Bearer JWT 或 X-Taichu-Agent-Key 认证。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildSchema, graphql } from 'graphql';
|
|
14
|
+
import { getStore, getHooks } from '../context.js';
|
|
15
|
+
import { search as vectorSearch } from '../search.js';
|
|
16
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
17
|
+
|
|
18
|
+
// ─── Schema Definition ────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const schemaSDL = `
|
|
21
|
+
type Document {
|
|
22
|
+
id: ID!
|
|
23
|
+
type: String!
|
|
24
|
+
data: JSON
|
|
25
|
+
status: String
|
|
26
|
+
createdBy: String
|
|
27
|
+
createdAt: String
|
|
28
|
+
updatedAt: String
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ContentType {
|
|
32
|
+
name: String!
|
|
33
|
+
label: String!
|
|
34
|
+
description: String
|
|
35
|
+
schemaOrg: String
|
|
36
|
+
fieldCount: Int
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type SearchResult {
|
|
40
|
+
id: ID!
|
|
41
|
+
type: String!
|
|
42
|
+
title: String
|
|
43
|
+
status: String
|
|
44
|
+
score: Float
|
|
45
|
+
updatedAt: String
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
scalar JSON
|
|
49
|
+
|
|
50
|
+
type Query {
|
|
51
|
+
"""获取单个文档"""
|
|
52
|
+
content(type: String!, id: ID!): Document
|
|
53
|
+
|
|
54
|
+
"""列出某类型的文档"""
|
|
55
|
+
contentList(
|
|
56
|
+
type: String!
|
|
57
|
+
status: String
|
|
58
|
+
search: String
|
|
59
|
+
limit: Int
|
|
60
|
+
offset: Int
|
|
61
|
+
): [Document!]!
|
|
62
|
+
|
|
63
|
+
"""语义搜索"""
|
|
64
|
+
search(query: String!, type: String, limit: Int): [SearchResult!]!
|
|
65
|
+
|
|
66
|
+
"""列出所有内容类型"""
|
|
67
|
+
contentTypes: [ContentType!]!
|
|
68
|
+
|
|
69
|
+
"""健康检查"""
|
|
70
|
+
health: String!
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Mutation {
|
|
74
|
+
"""创建文档"""
|
|
75
|
+
createContent(type: String!, data: JSON!, status: String): Document
|
|
76
|
+
|
|
77
|
+
"""更新文档"""
|
|
78
|
+
updateContent(type: String!, id: ID!, data: JSON!): Document
|
|
79
|
+
|
|
80
|
+
"""删除文档"""
|
|
81
|
+
deleteContent(type: String!, id: ID!): Boolean!
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
// ─── Resolvers ────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const resolvers = {
|
|
88
|
+
JSON: {
|
|
89
|
+
serialize: (v) => v,
|
|
90
|
+
parseValue: (v) => v
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
Query: {
|
|
94
|
+
async content(_, { type, id }) {
|
|
95
|
+
const store = getStore();
|
|
96
|
+
return store.get(id);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async contentList(_, args) {
|
|
100
|
+
const store = getStore();
|
|
101
|
+
const docs = await store.list({
|
|
102
|
+
type: args.type,
|
|
103
|
+
status: args.status || undefined,
|
|
104
|
+
search: args.search || undefined,
|
|
105
|
+
limit: args.limit || 20,
|
|
106
|
+
offset: args.offset || 0
|
|
107
|
+
});
|
|
108
|
+
return docs;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async search(_, { query, type, limit = 10 }) {
|
|
112
|
+
const results = vectorSearch(query, limit);
|
|
113
|
+
const store = getStore();
|
|
114
|
+
const docs = [];
|
|
115
|
+
|
|
116
|
+
for (const { docId, score } of results) {
|
|
117
|
+
try {
|
|
118
|
+
const doc = await store.get(docId);
|
|
119
|
+
if (doc && (!type || doc.type === type)) {
|
|
120
|
+
docs.push({
|
|
121
|
+
id: doc.id,
|
|
122
|
+
type: doc.type,
|
|
123
|
+
title: doc.data?.title || doc.data?.name || '(untitled)',
|
|
124
|
+
status: doc.status,
|
|
125
|
+
score: Math.round(score * 100) / 100,
|
|
126
|
+
updatedAt: doc.updatedAt
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} catch { /* skip */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return docs;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
contentTypes() {
|
|
136
|
+
// Import dynamically to avoid circular deps
|
|
137
|
+
return import('./api.js').then(m => {
|
|
138
|
+
const types = m.getContentTypes ? m.getContentTypes() : [];
|
|
139
|
+
return types;
|
|
140
|
+
}).catch(() => []);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
health() {
|
|
144
|
+
return `Taichu CMS v0.2.0 — uptime: ${Math.floor(process.uptime())}s`;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
Mutation: {
|
|
149
|
+
async createContent(_, { type, data, status }) {
|
|
150
|
+
const store = getStore();
|
|
151
|
+
const hooks = getHooks();
|
|
152
|
+
|
|
153
|
+
let payload = { type, data, status: status || 'draft' };
|
|
154
|
+
payload = await hooks.run('beforeCreate', payload, { store });
|
|
155
|
+
const doc = await store.create(payload);
|
|
156
|
+
await hooks.run('afterCreate', doc, { store });
|
|
157
|
+
return doc;
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async updateContent(_, { type, id, data }) {
|
|
161
|
+
const store = getStore();
|
|
162
|
+
const hooks = getHooks();
|
|
163
|
+
|
|
164
|
+
let payload = { id, type, data };
|
|
165
|
+
payload = await hooks.run('beforeUpdate', payload, { store });
|
|
166
|
+
const doc = await store.update(id, payload);
|
|
167
|
+
if (doc) await hooks.run('afterUpdate', doc, { store });
|
|
168
|
+
return doc;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async deleteContent(_, { type, id }) {
|
|
172
|
+
const store = getStore();
|
|
173
|
+
const hooks = getHooks();
|
|
174
|
+
|
|
175
|
+
const basePayload = { id, type };
|
|
176
|
+
const payload = await hooks.run('beforeDelete', basePayload, { store });
|
|
177
|
+
const deleted = await store.delete(id);
|
|
178
|
+
if (deleted) await hooks.run('afterDelete', { id, type }, { store });
|
|
179
|
+
return deleted;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ─── Build Schema ─────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
let schema = null;
|
|
187
|
+
|
|
188
|
+
function getSchema() {
|
|
189
|
+
if (!schema) {
|
|
190
|
+
schema = buildSchema(schemaSDL);
|
|
191
|
+
}
|
|
192
|
+
return schema;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Route Handler ────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {import('../context.js').Context} ctx
|
|
199
|
+
*/
|
|
200
|
+
export async function graphqlRoutes(ctx) {
|
|
201
|
+
const s = getSchema();
|
|
202
|
+
|
|
203
|
+
// GET: GraphiQL playground (simple HTML)
|
|
204
|
+
if (ctx.req.method === 'GET') {
|
|
205
|
+
ctx.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
206
|
+
ctx.res.end(GRAPHIQL_HTML);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// POST: Execute GraphQL query (mutations require auth)
|
|
211
|
+
if (ctx.req.method === 'POST') {
|
|
212
|
+
const { query, variables, operationName } = ctx.body || {};
|
|
213
|
+
|
|
214
|
+
if (!query) {
|
|
215
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
216
|
+
ctx.res.end(JSON.stringify({ errors: [{ message: 'Query is required' }] }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Require auth for mutations
|
|
221
|
+
const isMutation = query.trim().toLowerCase().startsWith('mutation');
|
|
222
|
+
if (isMutation) {
|
|
223
|
+
const authResult = await requireAuth(ctx);
|
|
224
|
+
if (!authResult.authenticated) {
|
|
225
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
226
|
+
ctx.res.end(JSON.stringify({ errors: [{ message: authResult.message }] }));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
ctx.actor = authResult.actor;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Validate query depth (prevent DoS via deeply nested queries)
|
|
233
|
+
const maxDepth = parseInt(process.env.TAICHU_GRAPHQL_MAX_DEPTH) || 5;
|
|
234
|
+
if (estimateQueryDepth(query) > maxDepth) {
|
|
235
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
236
|
+
ctx.res.end(JSON.stringify({ errors: [{ message: `Query depth exceeds maximum of ${maxDepth}` }] }));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const result = await graphql({
|
|
242
|
+
schema: s,
|
|
243
|
+
source: query,
|
|
244
|
+
variableValues: variables,
|
|
245
|
+
operationName,
|
|
246
|
+
contextValue: {}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
250
|
+
ctx.res.end(JSON.stringify(result));
|
|
251
|
+
} catch (err) {
|
|
252
|
+
ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
253
|
+
ctx.res.end(JSON.stringify({ errors: [{ message: err.message }] }));
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Estimate query depth to prevent deeply nested DoS attacks.
|
|
261
|
+
* Simple bracket-based heuristic.
|
|
262
|
+
*/
|
|
263
|
+
function estimateQueryDepth(query) {
|
|
264
|
+
let depth = 0, maxDepth = 0;
|
|
265
|
+
for (const ch of query) {
|
|
266
|
+
if (ch === '{') { depth++; maxDepth = Math.max(maxDepth, depth); }
|
|
267
|
+
if (ch === '}') depth--;
|
|
268
|
+
}
|
|
269
|
+
return maxDepth;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── GraphiQL HTML ────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
const GRAPHIQL_HTML = `<!DOCTYPE html>
|
|
275
|
+
<html>
|
|
276
|
+
<head>
|
|
277
|
+
<title>Taichu GraphiQL</title>
|
|
278
|
+
<style>
|
|
279
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
280
|
+
body { font-family: -apple-system, sans-serif; background: #0F172A; color: #E2E8F0; height:100vh; display:flex; flex-direction:column; }
|
|
281
|
+
header { padding: 12px 24px; background: #1E293B; border-bottom: 1px solid #334155; display:flex; align-items:center; gap:12px; }
|
|
282
|
+
header h1 { font-size: 16px; color: #10B981; }
|
|
283
|
+
header span { font-size: 12px; color: #64748B; }
|
|
284
|
+
main { flex:1; display:flex; flex-direction:column; padding: 16px; gap:12px; }
|
|
285
|
+
#editor { flex:1; background:#1E293B; border:1px solid #334155; border-radius:8px; padding:16px; color:#E2E8F0; font-family:'Cascadia Code',monospace; font-size:13px; resize:none; outline:none; }
|
|
286
|
+
#editor:focus { border-color:#10B981; }
|
|
287
|
+
.bar { display:flex; gap:8px; align-items:center; }
|
|
288
|
+
button { padding: 8px 20px; background:#10B981; color:white; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; }
|
|
289
|
+
button:hover { background:#059669; }
|
|
290
|
+
#result { flex:1; background:#1E293B; border:1px solid #334155; border-radius:8px; padding:16px; overflow:auto; font-family:'Cascadia Code',monospace; font-size:13px; white-space:pre-wrap; }
|
|
291
|
+
.error { color:#EF4444; }
|
|
292
|
+
.split { display:flex; flex:1; gap:12px; }
|
|
293
|
+
.split > * { flex:1; }
|
|
294
|
+
</style>
|
|
295
|
+
</head>
|
|
296
|
+
<body>
|
|
297
|
+
<header><h1>⚡ Taichu GraphiQL</h1><span>GraphQL Explorer</span></header>
|
|
298
|
+
<main>
|
|
299
|
+
<div class="bar">
|
|
300
|
+
<button onclick="run()">▶ 执行</button>
|
|
301
|
+
<span style="font-size:12px;color:#64748B">Ctrl+Enter</span>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="split">
|
|
304
|
+
<textarea id="editor" placeholder="# GraphQL Query
|
|
305
|
+
{
|
|
306
|
+
health
|
|
307
|
+
contentTypes { name label }
|
|
308
|
+
contentList(type:"article", limit:5) { id data status }
|
|
309
|
+
}">{
|
|
310
|
+
health
|
|
311
|
+
contentTypes {
|
|
312
|
+
name
|
|
313
|
+
label
|
|
314
|
+
fieldCount
|
|
315
|
+
}
|
|
316
|
+
}</textarea>
|
|
317
|
+
<div id="result">点击执行查看结果</div>
|
|
318
|
+
</div>
|
|
319
|
+
</main>
|
|
320
|
+
<script>
|
|
321
|
+
const editor=document.getElementById('editor');
|
|
322
|
+
const result=document.getElementById('result');
|
|
323
|
+
|
|
324
|
+
async function run() {
|
|
325
|
+
try {
|
|
326
|
+
const q = editor.value;
|
|
327
|
+
const res = await fetch('/api/graphql', {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: {'Content-Type':'application/json'},
|
|
330
|
+
body: JSON.stringify({query:q})
|
|
331
|
+
});
|
|
332
|
+
const data = await res.json();
|
|
333
|
+
result.innerHTML = JSON.stringify(data, null, 2);
|
|
334
|
+
} catch(e) {
|
|
335
|
+
result.innerHTML = '<span class="error">'+e.message+'</span>';
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
editor.addEventListener('keydown', e => {
|
|
340
|
+
if (e.ctrlKey && e.key === 'Enter') run();
|
|
341
|
+
});
|
|
342
|
+
</script>
|
|
343
|
+
</body>
|
|
344
|
+
</html>`;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Routes — 文件上传和管理 API
|
|
3
|
+
*
|
|
4
|
+
* POST /api/media/upload — 上传文件(multipart)
|
|
5
|
+
* GET /api/media — 列出媒体
|
|
6
|
+
* GET /api/media/:id — 获取媒体元数据
|
|
7
|
+
* DELETE /api/media/:id — 删除媒体
|
|
8
|
+
* GET /uploads/* — 静态文件服务(由 router 转发到 static.js)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseMultipart } from '../multipart.js';
|
|
12
|
+
import { createMediaStore } from '../media-store.js';
|
|
13
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
14
|
+
import { getStore } from '../context.js';
|
|
15
|
+
|
|
16
|
+
// Lazy-init media store singleton
|
|
17
|
+
let _mediaStore = null;
|
|
18
|
+
function getMediaStore() {
|
|
19
|
+
if (!_mediaStore) _mediaStore = createMediaStore();
|
|
20
|
+
return _mediaStore;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('../context.js').Context} ctx
|
|
25
|
+
*/
|
|
26
|
+
export async function mediaRoutes(ctx) {
|
|
27
|
+
const { pathname } = ctx.url;
|
|
28
|
+
const method = ctx.req.method;
|
|
29
|
+
|
|
30
|
+
// POST /api/media/upload
|
|
31
|
+
if (pathname === '/api/media/upload' && method === 'POST') {
|
|
32
|
+
const authResult = await requireAuth(ctx);
|
|
33
|
+
if (!authResult.authenticated) {
|
|
34
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
35
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ctx.actor = authResult.actor;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { files, fields } = await parseMultipart(ctx.req);
|
|
42
|
+
if (!files || files.length === 0) {
|
|
43
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
44
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'No file uploaded' }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mediaStore = getMediaStore();
|
|
49
|
+
const store = getStore();
|
|
50
|
+
const results = [];
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const saved = await mediaStore.save(file.buffer, file.filename, file.mimetype);
|
|
54
|
+
|
|
55
|
+
// Store metadata in Taichu's content system
|
|
56
|
+
const doc = await store.create({
|
|
57
|
+
type: 'media',
|
|
58
|
+
data: {
|
|
59
|
+
filename: saved.filename,
|
|
60
|
+
originalName: saved.originalName,
|
|
61
|
+
mimeType: saved.mimetype,
|
|
62
|
+
size: saved.size,
|
|
63
|
+
url: saved.url,
|
|
64
|
+
width: saved.width || null,
|
|
65
|
+
height: saved.height || null,
|
|
66
|
+
compressed: saved.compressed || false,
|
|
67
|
+
webp: saved.webp || null,
|
|
68
|
+
thumbnails: saved.thumbnails || {},
|
|
69
|
+
altText: fields.alt || '',
|
|
70
|
+
caption: fields.caption || '',
|
|
71
|
+
uploadedBy: ctx.actor.id
|
|
72
|
+
},
|
|
73
|
+
status: 'active'
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
results.push({
|
|
77
|
+
id: doc.id,
|
|
78
|
+
...saved,
|
|
79
|
+
altText: fields.alt || '',
|
|
80
|
+
caption: fields.caption || ''
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
85
|
+
ctx.res.end(JSON.stringify(results.length === 1 ? results[0] : { files: results }));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err.code === 'FILE_TOO_LARGE' || err.code === 'PAYLOAD_TOO_LARGE') {
|
|
88
|
+
ctx.res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
89
|
+
ctx.res.end(JSON.stringify({ error: err.code, message: err.message }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// GET /api/media — list media
|
|
98
|
+
if (pathname === '/api/media' && method === 'GET') {
|
|
99
|
+
const authResult = await requireAuth(ctx);
|
|
100
|
+
if (!authResult.authenticated) {
|
|
101
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
102
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const store = getStore();
|
|
107
|
+
const docs = await store.list({ type: 'media', limit: 50, ...Object.fromEntries(ctx.url.searchParams) });
|
|
108
|
+
|
|
109
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
110
|
+
ctx.res.end(JSON.stringify({ docs, total: docs.length }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// GET /api/media/:id
|
|
115
|
+
const mediaMatch = pathname.match(/^\/api\/media\/([\w-]+)$/);
|
|
116
|
+
if (mediaMatch && method === 'GET') {
|
|
117
|
+
const authResult = await requireAuth(ctx);
|
|
118
|
+
if (!authResult.authenticated) {
|
|
119
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
120
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const store = getStore();
|
|
125
|
+
const doc = await store.get(mediaMatch[1]);
|
|
126
|
+
if (!doc || doc.type !== 'media') {
|
|
127
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
128
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Media not found' }));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
133
|
+
ctx.res.end(JSON.stringify(doc));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// DELETE /api/media/:id
|
|
138
|
+
if (mediaMatch && method === 'DELETE') {
|
|
139
|
+
const authResult = await requireAuth(ctx);
|
|
140
|
+
if (!authResult.authenticated) {
|
|
141
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
142
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const store = getStore();
|
|
147
|
+
const mediaStore = getMediaStore();
|
|
148
|
+
const doc = await store.get(mediaMatch[1]);
|
|
149
|
+
|
|
150
|
+
if (!doc || doc.type !== 'media') {
|
|
151
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
152
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Media not found' }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Delete file from disk
|
|
157
|
+
await mediaStore.remove(doc.data.filename);
|
|
158
|
+
// Delete metadata
|
|
159
|
+
await store.delete(mediaMatch[1]);
|
|
160
|
+
|
|
161
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
162
|
+
ctx.res.end(JSON.stringify({ success: true }));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 404
|
|
167
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
168
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Media route not found: ${method} ${pathname}` }));
|
|
169
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Marketplace Routes
|
|
3
|
+
*
|
|
4
|
+
* GET /api/plugins — list installed plugins
|
|
5
|
+
* GET /api/plugins/marketplace — search/browse marketplace
|
|
6
|
+
* POST /api/plugins/install — install a plugin from marketplace
|
|
7
|
+
* POST /api/plugins/uninstall/:name — uninstall a plugin
|
|
8
|
+
* POST /api/plugins/refresh — refresh marketplace index
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
12
|
+
import { installPlugin, uninstallPlugin, listInstalled, isInstalled } from '../plugin-installer.js';
|
|
13
|
+
import { getPluginManager } from '../plugin-manager.js';
|
|
14
|
+
|
|
15
|
+
/** Default marketplace index URL */
|
|
16
|
+
const DEFAULT_MARKETPLACE_URL = 'https://raw.githubusercontent.com/Caludelaw/Taichu/main/marketplace.json';
|
|
17
|
+
const MARKETPLACE_URL = process.env.TAICHU_MARKETPLACE_URL || DEFAULT_MARKETPLACE_URL;
|
|
18
|
+
|
|
19
|
+
let _marketplaceCache = null;
|
|
20
|
+
let _marketplaceCacheTime = 0;
|
|
21
|
+
const CACHE_TTL = 300000; // 5 min
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fetch marketplace index (with cache).
|
|
25
|
+
*/
|
|
26
|
+
async function fetchMarketplace(opts = {}) {
|
|
27
|
+
const force = opts.force || false;
|
|
28
|
+
if (!force && _marketplaceCache && (Date.now() - _marketplaceCacheTime) < CACHE_TTL) {
|
|
29
|
+
return _marketplaceCache;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(MARKETPLACE_URL);
|
|
34
|
+
if (!res.ok) throw new Error(`Marketplace fetch failed: ${res.status}`);
|
|
35
|
+
_marketplaceCache = await res.json();
|
|
36
|
+
_marketplaceCacheTime = Date.now();
|
|
37
|
+
return _marketplaceCache;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// Return cached if available, otherwise empty
|
|
40
|
+
if (_marketplaceCache) return _marketplaceCache;
|
|
41
|
+
return { version: 1, plugins: [], error: err.message };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {import('../context.js').Context} ctx */
|
|
46
|
+
export async function pluginMarketplaceRoutes(ctx) {
|
|
47
|
+
const { pathname } = ctx.url;
|
|
48
|
+
const method = ctx.req.method;
|
|
49
|
+
|
|
50
|
+
// Auth required for all plugin management
|
|
51
|
+
const authResult = await requireAuth(ctx);
|
|
52
|
+
if (!authResult.authenticated) {
|
|
53
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
54
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// GET /api/plugins — list installed
|
|
59
|
+
if (pathname === '/api/plugins' && method === 'GET') {
|
|
60
|
+
const pm = getPluginManager();
|
|
61
|
+
const installed = listInstalled();
|
|
62
|
+
const loaded = pm.list();
|
|
63
|
+
const result = installed.map(p => ({
|
|
64
|
+
...p,
|
|
65
|
+
loaded: loaded.some(l => l.name === p.name)
|
|
66
|
+
}));
|
|
67
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
68
|
+
ctx.res.end(JSON.stringify({ plugins: result, total: result.length }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// GET /api/plugins/marketplace — browse marketplace
|
|
73
|
+
if (pathname === '/api/plugins/marketplace' && method === 'GET') {
|
|
74
|
+
const search = ctx.url.searchParams.get('search')?.toLowerCase();
|
|
75
|
+
const category = ctx.url.searchParams.get('category');
|
|
76
|
+
|
|
77
|
+
const marketplace = await fetchMarketplace();
|
|
78
|
+
let results = marketplace.plugins || [];
|
|
79
|
+
|
|
80
|
+
if (search) {
|
|
81
|
+
results = results.filter(p =>
|
|
82
|
+
p.name.toLowerCase().includes(search) ||
|
|
83
|
+
p.description.toLowerCase().includes(search) ||
|
|
84
|
+
(p.keywords || []).some(k => k.toLowerCase().includes(search))
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (category) {
|
|
88
|
+
results = results.filter(p => p.category === category);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Add install status
|
|
92
|
+
const enriched = results.map(p => ({
|
|
93
|
+
...p,
|
|
94
|
+
installed: isInstalled(p.name)
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
98
|
+
ctx.res.end(JSON.stringify({
|
|
99
|
+
plugins: enriched,
|
|
100
|
+
total: enriched.length,
|
|
101
|
+
lastUpdated: marketplace.lastUpdated || null,
|
|
102
|
+
source: MARKETPLACE_URL
|
|
103
|
+
}));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// POST /api/plugins/install — install from marketplace
|
|
108
|
+
if (pathname === '/api/plugins/install' && method === 'POST') {
|
|
109
|
+
const { repo, name } = ctx.body || {};
|
|
110
|
+
|
|
111
|
+
if (!repo && !name) {
|
|
112
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
113
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Either "repo" (GitHub repo) or "name" (marketplace plugin name) is required' }));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let installRepo = repo;
|
|
118
|
+
if (!installRepo) {
|
|
119
|
+
// Look up in marketplace
|
|
120
|
+
const marketplace = await fetchMarketplace();
|
|
121
|
+
const plugin = (marketplace.plugins || []).find(p => p.name === name);
|
|
122
|
+
if (!plugin) {
|
|
123
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
124
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Plugin "${name}" not found in marketplace` }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
installRepo = plugin.repository?.replace('https://github.com/', '') || plugin.repo;
|
|
128
|
+
if (!installRepo) {
|
|
129
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
130
|
+
ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: `Plugin "${name}" has no repository URL` }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await installPlugin(installRepo, { version: ctx.body.version });
|
|
136
|
+
if (result.success) {
|
|
137
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
138
|
+
ctx.res.end(JSON.stringify(result));
|
|
139
|
+
} else {
|
|
140
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
141
|
+
ctx.res.end(JSON.stringify(result));
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// POST /api/plugins/uninstall/:name
|
|
147
|
+
const uninstallMatch = pathname.match(/^\/api\/plugins\/uninstall\/(.+)$/);
|
|
148
|
+
if (uninstallMatch && method === 'POST') {
|
|
149
|
+
const name = uninstallMatch[1];
|
|
150
|
+
const result = await uninstallPlugin(name);
|
|
151
|
+
if (result.success) {
|
|
152
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
153
|
+
ctx.res.end(JSON.stringify(result));
|
|
154
|
+
} else {
|
|
155
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
156
|
+
ctx.res.end(JSON.stringify(result));
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// POST /api/plugins/refresh — refresh marketplace cache
|
|
162
|
+
if (pathname === '/api/plugins/refresh' && method === 'POST') {
|
|
163
|
+
const marketplace = await fetchMarketplace({ force: true });
|
|
164
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
165
|
+
ctx.res.end(JSON.stringify({ refreshed: true, pluginCount: (marketplace.plugins || []).length }));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
170
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
171
|
+
}
|