@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,223 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title data-taichu="siteName">Taichu CMS</title>
7
+ <meta name="description" content="">
8
+ <link rel="stylesheet" href="/theme/style.css">
9
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
10
+ <link rel="alternate" type="application/rss+xml" href="/rss.xml" title="RSS Feed">
11
+ </head>
12
+ <body>
13
+ <header class="site-header">
14
+ <div class="header-inner">
15
+ <a href="/" class="site-logo">Taichu</a>
16
+ <nav class="site-nav" id="nav"></nav>
17
+ </div>
18
+ </header>
19
+
20
+ <main class="site-main" id="main"></main>
21
+
22
+ <footer class="site-footer">
23
+ <div class="footer-inner">
24
+ <p>Powered by <a href="https://github.com/Caludelaw/Taichu" target="_blank">Taichu CMS</a></p>
25
+ <p class="icp" id="icp"></p>
26
+ </div>
27
+ </footer>
28
+
29
+ <script>
30
+ (function() {
31
+ var G = window.__TAICHU__ || {};
32
+ document.title = G.site?.name || 'Taichu CMS';
33
+ document.documentElement.lang = G.site?.language || 'zh-CN';
34
+ document.querySelectorAll('[data-taichu=siteName]').forEach(function(el){ el.textContent = G.site?.name || 'Taichu' });
35
+
36
+ var icpEl = document.getElementById('icp');
37
+ if (icpEl && G.site?.icp) {
38
+ icpEl.innerHTML = '<a href="https://beian.miit.gov.cn" target="_blank">'+G.site.icp+'</a>' + (G.site.gongan ? ' | '+G.site.gongan : '');
39
+ }
40
+
41
+ var theme = G.theme || {};
42
+ var rs = document.documentElement.style;
43
+ if (theme.primaryColor) rs.setProperty('--accent', theme.primaryColor);
44
+ if (theme.fontSize) rs.setProperty('--font-size', theme.fontSize);
45
+ if (theme.customCSS) { var s = document.createElement('style'); s.textContent = theme.customCSS; document.head.appendChild(s); }
46
+
47
+ function api(path) { return fetch('/api'+path).then(function(r){ return r.json() }); }
48
+ function getPage() { var m = location.search.match(/page=(\d+)/); return m ? parseInt(m[1]) : 1; }
49
+ var PAGE_SIZE = 10;
50
+
51
+ function paginationHTML(page, total) {
52
+ var pages = Math.ceil(total / PAGE_SIZE);
53
+ if (pages <= 1) return '';
54
+ var html = '<nav class="pagination">';
55
+ if (page > 1) html += '<a href="?page='+(page-1)+'" class="btn-page">&larr; 前页</a>';
56
+ else html += '<span class="btn-page disabled">&larr; 前页</span>';
57
+ html += '<span class="page-info">'+page+' / '+pages+'</span>';
58
+ if (page < pages) html += '<a href="?page='+(page+1)+'" class="btn-page">后页 &rarr;</a>';
59
+ else html += '<span class="btn-page disabled">后页 &rarr;</span>';
60
+ html += '</nav>';
61
+ return html;
62
+ }
63
+
64
+ function route() {
65
+ var p = location.pathname;
66
+ if (p === '/' || p === '') return renderHome();
67
+ if (p.startsWith('/post/')) return renderPost(p.split('/post/')[1]);
68
+ if (p.startsWith('/page/')) return renderPage(p.split('/page/')[1]);
69
+ if (p === '/search') return renderSearch();
70
+ render404();
71
+ }
72
+
73
+ function buildNav(navItems, categories, pages) {
74
+ var nav = document.getElementById('nav');
75
+ var html = '<a href="/">首页</a>';
76
+ (navItems||[]).forEach(function(item){
77
+ html += '<a href="'+escAttr(item.data?.url||'#')+'"'+(item.data?.target==='_blank'?' target="_blank"':'')+'>'+escHtml(item.data?.title||'')+'</a>';
78
+ });
79
+ if (!navItems || !navItems.length) {
80
+ (categories||[]).forEach(function(c){
81
+ html += '<a href="/category/'+(c.data?.slug||c.data?.name||c.id)+'">'+escHtml(c.data?.name||c.data?.title||c.id)+'</a>';
82
+ });
83
+ (pages||[]).forEach(function(p){
84
+ html += '<a href="/page/'+(p.data?.slug||p.id)+'">'+escHtml(p.data?.title||p.id)+'</a>';
85
+ });
86
+ }
87
+ html += '<a href="/search">🔍</a>';
88
+ nav.innerHTML = html;
89
+ }
90
+
91
+ function renderHome() {
92
+ var main = document.getElementById('main');
93
+ var page = getPage();
94
+ var offset = (page - 1) * PAGE_SIZE;
95
+ main.innerHTML = '<div class="loading">加载中...</div>';
96
+
97
+ Promise.all([
98
+ api('/content/article?status=published&limit='+PAGE_SIZE+'&offset='+offset),
99
+ api('/content/article?status=published&limit=1'),
100
+ api('/content/navigation?limit=20'),
101
+ api('/content/category?limit=20')
102
+ ]).then(function(res){
103
+ var articles = res[0].docs || [];
104
+ var total = res[1].total || 0;
105
+ var navItems = (res[2].docs||[]).sort(function(a,b){ return (a.data?.order||0)-(b.data?.order||0) });
106
+ var categories = res[3].docs || [];
107
+ buildNav(navItems, categories, []);
108
+
109
+ if (!articles.length && page === 1) {
110
+ main.innerHTML = '<div class="hero"><h1>你好,我是 Taichu</h1><p>一个轻量、快速的 AI Agent-Native 内容管理系统。</p></div><div class="empty-blog"><p>还没有文章。去 <a href="/admin/">管理后台</a> 开始创作。</p></div>';
111
+ return;
112
+ }
113
+ var html = '<div class="post-list">';
114
+ articles.forEach(function(a){
115
+ var ex = excerpt(a.data?.body);
116
+ html += '<article class="post-card"><div class="post-meta"><time>'+fmtDate(a.updatedAt)+'</time></div><h2><a href="/post/'+(a.data?.slug||a.id)+'">'+escHtml(a.data?.title||'Untitled')+'</a></h2><p>'+ex+'</p></article>';
117
+ });
118
+ html += '</div>';
119
+ html += paginationHTML(page, total);
120
+ main.innerHTML = html;
121
+ }).catch(function(e){ main.innerHTML = '<p class="error">加载失败</p>'; });
122
+ }
123
+
124
+ function renderPost(slug) {
125
+ var main = document.getElementById('main');
126
+ main.innerHTML = '<div class="loading">加载中...</div>';
127
+ api('/content/article?limit=100').then(function(data){
128
+ var docs = data.docs || [];
129
+ var doc = docs.find(function(d){ return d.data?.slug === slug });
130
+ if (!doc) { render404(); return; }
131
+ main.innerHTML = '<article class="post-single"><h1>'+escHtml(doc.data?.title||'')+'</h1><time>'+fmtDate(doc.updatedAt)+'</time><div class="post-body">'+renderBody(doc.data?.body)+'</div></article>';
132
+ document.title = (doc.data?.title||'') + ' — ' + (G.site?.name||'Taichu');
133
+ }).catch(function(){ main.innerHTML = '<p class="error">加载失败</p>'; });
134
+ }
135
+
136
+ function renderPage(slug) {
137
+ var main = document.getElementById('main');
138
+ main.innerHTML = '<div class="loading">加载中...</div>';
139
+ api('/content/page?limit=100').then(function(data){
140
+ var docs = data.docs || [];
141
+ var doc = docs.find(function(d){ return d.data?.slug === slug });
142
+ if (!doc) { render404(); return; }
143
+ main.innerHTML = '<article class="post-single"><h1>'+escHtml(doc.data?.title||'')+'</h1><div class="post-body">'+renderBody(doc.data?.body)+'</div></article>';
144
+ document.title = (doc.data?.title||'') + ' — ' + (G.site?.name||'Taichu');
145
+ }).catch(function(){ main.innerHTML = '<p class="error">加载失败</p>'; });
146
+ }
147
+
148
+ function renderSearch() {
149
+ var main = document.getElementById('main');
150
+ var q = (new URL(location.href)).searchParams.get('q') || '';
151
+ main.innerHTML = '<h1 class="page-title">搜索</h1><form onsubmit="event.preventDefault();location.href=\'/search?q=\'+encodeURIComponent(this.q.value)" class="search-form"><input name="q" value="'+escAttr(q)+'" class="search-input" placeholder="输入关键词..." autofocus><button class="btn-primary">搜索</button></form><div id="search-results"></div>';
152
+ if (!q) return;
153
+ document.getElementById('search-results').innerHTML = '<div class="loading">搜索中...</div>';
154
+ api('/search?q='+encodeURIComponent(q)).then(function(data){
155
+ var docs = data.docs || [];
156
+ var results = document.getElementById('search-results');
157
+ if (!docs.length) { results.innerHTML = '<p class="empty">未找到</p>'; return; }
158
+ var html = '<div class="post-list">';
159
+ docs.forEach(function(d){
160
+ html += '<article class="post-card"><h2><a href="/post/'+(d.data?.slug||d.id)+'">'+escHtml(d.data?.title||'')+'</a></h2><p>'+excerpt(d.data?.body)+'</p></article>';
161
+ });
162
+ html += '</div>';
163
+ results.innerHTML = html;
164
+ });
165
+ }
166
+
167
+ function render404() {
168
+ document.getElementById('main').innerHTML = '<div class="empty-page"><h1>404</h1><p>页面未找到</p><a href="/">返回首页</a></div>';
169
+ }
170
+
171
+ function renderBody(body) {
172
+ if (!body) return '';
173
+ if (typeof body === 'string') return '<p>'+escHtml(body)+'</p>';
174
+ if (body.type === 'doc' && Array.isArray(body.content)) return body.content.map(renderNode).join('');
175
+ if (body.text) return '<p>'+escHtml(String(body.text||body))+'</p>';
176
+ return '';
177
+ }
178
+
179
+ function renderNode(node) {
180
+ if (!node) return '';
181
+ var text = '';
182
+ if (node.content) text = node.content.map(renderNode).join('');
183
+ else if (node.text) text = escHtml(String(node.text));
184
+ if (node.marks) {
185
+ node.marks.forEach(function(m){
186
+ if (m.type === 'bold') text = '<strong>'+text+'</strong>';
187
+ if (m.type === 'italic') text = '<em>'+text+'</em>';
188
+ if (m.type === 'code') text = '<code>'+text+'</code>';
189
+ if (m.type === 'link') text = '<a href="'+escAttr(m.attrs?.href||'#')+'">'+text+'</a>';
190
+ if (m.type === 'strike') text = '<s>'+text+'</s>';
191
+ });
192
+ }
193
+ switch (node.type) {
194
+ case 'paragraph': return '<p>'+text+'</p>';
195
+ case 'heading': return '<h'+(node.attrs?.level||2)+'>'+text+'</h'+(node.attrs?.level||2)+'>';
196
+ case 'bulletList': return '<ul>'+text+'</ul>';
197
+ case 'orderedList': return '<ol>'+text+'</ol>';
198
+ case 'listItem': return '<li>'+text+'</li>';
199
+ case 'blockquote': return '<blockquote>'+text+'</blockquote>';
200
+ case 'codeBlock': return '<pre><code>'+text+'</code></pre>';
201
+ case 'horizontalRule': return '<hr>';
202
+ case 'image': return '<img src="'+escAttr(node.attrs?.src||'')+'" alt="'+escAttr(node.attrs?.alt||'')+'" loading="lazy">';
203
+ default: return text;
204
+ }
205
+ }
206
+
207
+ function excerpt(body) {
208
+ if (!body) return '';
209
+ if (typeof body === 'string') return body.replace(/<[^>]+>/g,'').substring(0,200);
210
+ if (body.text) return escHtml(String(body.text)).substring(0,200);
211
+ if (body.content) return body.content.map(function(n){ return n.text||'' }).join(' ').substring(0,200);
212
+ return '';
213
+ }
214
+
215
+ function fmtDate(d) { if (!d) return ''; return new Date(d).toLocaleDateString('zh-CN',{year:'numeric',month:'long',day:'numeric'}); }
216
+ function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
217
+ function escAttr(s) { return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
218
+
219
+ route();
220
+ })();
221
+ </script>
222
+ </body>
223
+ </html>
@@ -0,0 +1,109 @@
1
+ /* Taichu Minimal — Clean Portfolio Theme */
2
+ :root {
3
+ --accent: #1E1B4B;
4
+ --bg: #FAFAFA;
5
+ --text: #1A1A2E;
6
+ --text-secondary: #666;
7
+ --border: #EBEBEB;
8
+ --font: 'Georgia', 'Noto Serif SC', serif;
9
+ --font-size: 17px;
10
+ --max-width: 720px;
11
+ }
12
+
13
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
+ body {
15
+ font-family: var(--font); font-size: var(--font-size);
16
+ color: var(--text); background: var(--bg); line-height: 1.8;
17
+ }
18
+
19
+ /* Header */
20
+ .site-header { background: var(--bg); padding: 24px 0; }
21
+ .header-inner {
22
+ max-width: var(--max-width); margin: 0 auto; padding: 0 24px;
23
+ display: flex; align-items: baseline; justify-content: space-between;
24
+ }
25
+ .site-logo {
26
+ font-size: 24px; font-weight: 400; color: var(--text);
27
+ text-decoration: none; letter-spacing: -1px;
28
+ }
29
+ .site-nav { display: flex; gap: 24px; }
30
+ .site-nav a { color: var(--text-secondary); text-decoration: none; font-size: 14px; font-family: -apple-system, sans-serif; }
31
+ .site-nav a:hover { color: var(--accent); }
32
+
33
+ /* Main */
34
+ .site-main { max-width: var(--max-width); margin: 0 auto; padding: 48px 24px; min-height: 60vh; }
35
+
36
+ /* Hero (empty home) */
37
+ .hero { text-align: center; padding: 80px 0 40px; }
38
+ .hero h1 { font-size: 36px; font-weight: 400; letter-spacing: -1px; margin-bottom: 12px; }
39
+ .hero p { font-size: 16px; color: var(--text-secondary); font-family: -apple-system, sans-serif; }
40
+
41
+ /* Post List */
42
+ .post-list { display: flex; flex-direction: column; gap: 48px; }
43
+ .post-card { border-bottom: 1px solid var(--border); padding-bottom: 32px; }
44
+ .post-card:last-child { border-bottom: none; }
45
+ .post-meta { margin-bottom: 8px; }
46
+ .post-meta time { font-size: 12px; color: var(--text-secondary); font-family: -apple-system, sans-serif; text-transform: uppercase; letter-spacing: 1px; }
47
+ .post-card h2 { font-size: 24px; line-height: 1.3; font-weight: 400; letter-spacing: -0.5px; }
48
+ .post-card h2 a { color: var(--text); text-decoration: none; }
49
+ .post-card h2 a:hover { color: var(--accent); }
50
+ .post-card p { margin-top: 8px; color: var(--text-secondary); font-size: 15px; }
51
+
52
+ /* Post Single */
53
+ .post-single h1 { font-size: 32px; line-height: 1.2; font-weight: 400; letter-spacing: -1px; margin-bottom: 8px; }
54
+ .post-single time { font-size: 12px; color: var(--text-secondary); font-family: -apple-system, sans-serif; margin-bottom: 40px; display: block; }
55
+ .post-body p { margin-bottom: 24px; font-size: 17px; }
56
+ .post-body h2, .post-body h3 { margin: 40px 0 16px; font-weight: 400; }
57
+ .post-body h2 { font-size: 24px; }
58
+ .post-body h3 { font-size: 20px; }
59
+ .post-body ul, .post-body ol { margin: 12px 0 24px 24px; }
60
+ .post-body li { margin-bottom: 6px; }
61
+ .post-body blockquote {
62
+ border-left: 2px solid var(--accent); padding: 8px 20px; margin: 24px 0;
63
+ color: var(--text-secondary); font-style: italic;
64
+ }
65
+ .post-body pre { background: #1A1A2E; color: #E0E0E0; padding: 20px; font-size: 14px; margin: 24px 0; overflow-x: auto; }
66
+ .post-body code { font-size: 14px; }
67
+ .post-body pre code { background: none; }
68
+ .post-body img { max-width: 100%; margin: 24px 0; }
69
+ .post-body hr { border: none; border-top: 1px solid var(--border); margin: 40px 0; }
70
+ .post-body a { color: var(--accent); border-bottom: 1px solid var(--accent); text-decoration: none; }
71
+
72
+ /* Footer */
73
+ .site-footer { border-top: 1px solid var(--border); padding: 40px 0; margin-top: 80px; }
74
+ .footer-inner { max-width: var(--max-width); margin: 0 auto; text-align: center; padding: 0 24px; }
75
+ .footer-inner p { font-size: 13px; color: var(--text-secondary); font-family: -apple-system, sans-serif; }
76
+ .footer-inner a { color: var(--accent); text-decoration: none; }
77
+ .icp { margin-top: 4px; font-size: 12px; }
78
+
79
+ /* Pagination */
80
+ .pagination { display: flex; justify-content: center; align-items: center; gap: 20px; margin-top: 48px; }
81
+ .btn-page { padding: 8px 20px; border: 1px solid var(--border); font-size: 13px; font-family: -apple-system, sans-serif; color: var(--text-secondary); text-decoration: none; }
82
+ .btn-page:hover { border-color: var(--accent); color: var(--accent); }
83
+ .btn-page.disabled { opacity: 0.25; }
84
+ .page-info { font-size: 13px; color: var(--text-secondary); font-family: -apple-system, sans-serif; }
85
+
86
+ /* States */
87
+ .loading { text-align: center; padding: 80px 0; color: var(--text-secondary); }
88
+ .error { color: #C0392B; padding: 40px; text-align: center; }
89
+ .empty-page { text-align: center; padding: 80px 0; }
90
+ .empty-page h1 { font-size: 72px; font-weight: 300; color: #CCC; }
91
+ .empty-blog { text-align: center; padding: 40px 0; font-family: -apple-system, sans-serif; }
92
+ .empty-blog a { color: var(--accent); }
93
+ .page-title { font-size: 28px; font-weight: 400; margin-bottom: 32px; }
94
+
95
+ .search-form { display: flex; gap: 10px; margin-bottom: 32px; }
96
+ .search-input {
97
+ flex: 1; padding: 10px 16px; border: 1px solid var(--border); background: white;
98
+ font-size: 15px; font-family: inherit;
99
+ }
100
+ .btn-primary {
101
+ padding: 10px 24px; background: var(--accent); color: white; border: none;
102
+ font-size: 14px; font-family: -apple-system, sans-serif; cursor: pointer;
103
+ }
104
+
105
+ @media (max-width: 768px) {
106
+ .site-main { padding: 32px 16px; }
107
+ .post-single h1 { font-size: 24px; }
108
+ .post-card h2 { font-size: 20px; }
109
+ }
@@ -0,0 +1,106 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Taichu WebSocket Test</title>
6
+ <style>
7
+ * { margin:0; padding:0; box-sizing:border-box; }
8
+ body { font-family: monospace; background: #0F172A; color: #E2E8F0; padding: 20px; }
9
+ h1 { font-size: 18px; color: #10B981; margin-bottom: 16px; }
10
+ .panel { background: #1E293B; border: 1px solid #334155; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
11
+ .panel h2 { font-size: 14px; color: #94A3B8; margin-bottom: 8px; }
12
+ .status { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; }
13
+ .connected { background: #065F46; color: #10B981; }
14
+ .disconnected { background: #7F1D1D; color: #FCA5A5; }
15
+ .log { max-height: 300px; overflow-y: auto; font-size: 12px; line-height: 1.6; }
16
+ .log .create { color: #10B981; }
17
+ .log .update { color: #F59E0B; }
18
+ .log .delete { color: #EF4444; }
19
+ .log .info { color: #94A3B8; }
20
+ button { padding: 6px 16px; border: 1px solid #334155; background: #1E293B; color: #E2E8F0; border-radius: 4px; cursor: pointer; font-size: 12px; margin-right: 8px; margin-bottom: 8px; }
21
+ button:hover { border-color: #10B981; color: #10B981; }
22
+ input { padding: 6px 10px; background: #0F172A; border: 1px solid #334155; color: #E2E8F0; border-radius: 4px; font-size: 12px; width: 200px; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <h1>⚡ Taichu WebSocket Live Test</h1>
27
+
28
+ <div class="panel">
29
+ <h2>Connection</h2>
30
+ <div><span id="ws-status" class="status disconnected">disconnected</span></div>
31
+ <p style="margin-top:8px;font-size:12px;color:#94A3B8;" id="ws-url"></p>
32
+ </div>
33
+
34
+ <div class="panel">
35
+ <h2>Subscribe</h2>
36
+ <input id="channel" value="article" placeholder="channel name" />
37
+ <button onclick="subscribe()">Subscribe</button>
38
+ <button onclick="unsubscribe()">Unsubscribe</button>
39
+ <button onclick="connect()">Connect</button>
40
+ </div>
41
+
42
+ <div class="panel">
43
+ <h2>Events</h2>
44
+ <div class="log" id="log">
45
+ <div class="info">Waiting for WebSocket events...</div>
46
+ </div>
47
+ </div>
48
+
49
+ <script>
50
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
51
+ const wsUrl = `${protocol}//${location.host}`;
52
+ document.getElementById('ws-url').textContent = wsUrl;
53
+
54
+ let ws = null;
55
+
56
+ function connect() {
57
+ if (ws) ws.close();
58
+ ws = new WebSocket(wsUrl);
59
+
60
+ ws.onopen = () => {
61
+ document.getElementById('ws-status').className = 'status connected';
62
+ document.getElementById('ws-status').textContent = 'connected';
63
+ log('info', 'WebSocket connected');
64
+ };
65
+
66
+ ws.onmessage = (e) => {
67
+ const msg = JSON.parse(e.data);
68
+ if (msg.type === 'subscribed') {
69
+ log('info', `Subscribed to "${msg.channel}"`);
70
+ } else if (msg.type === 'content_change') {
71
+ log(msg.event, `[${msg.channel}] ${msg.event}: ${msg.doc.title || msg.doc.id}`);
72
+ }
73
+ };
74
+
75
+ ws.onclose = () => {
76
+ document.getElementById('ws-status').className = 'status disconnected';
77
+ document.getElementById('ws-status').textContent = 'disconnected';
78
+ log('info', 'WebSocket disconnected');
79
+ };
80
+
81
+ ws.onerror = () => log('info', 'WebSocket error');
82
+ }
83
+
84
+ function subscribe() {
85
+ const ch = document.getElementById('channel').value || 'article';
86
+ if (ws) ws.send(JSON.stringify({ type: 'subscribe', channel: ch }));
87
+ }
88
+
89
+ function unsubscribe() {
90
+ const ch = document.getElementById('channel').value || 'article';
91
+ if (ws) ws.send(JSON.stringify({ type: 'unsubscribe', channel: ch }));
92
+ }
93
+
94
+ function log(ev, msg) {
95
+ const el = document.getElementById('log');
96
+ const div = document.createElement('div');
97
+ div.className = ev;
98
+ div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
99
+ el.prepend(div);
100
+ if (el.children.length > 100) el.removeChild(el.lastChild);
101
+ }
102
+
103
+ connect();
104
+ </script>
105
+ </body>
106
+ </html>
@@ -0,0 +1,228 @@
1
+ /**
2
+ * ActivityPub — 联邦协议基础
3
+ *
4
+ * 实现 ActivityPub Server-to-Server 协议的最小功能子集:
5
+ * - Actor 端点(JSON-LD)
6
+ * - WebFinger 发现
7
+ * - Outbox(已发布活动列表)
8
+ * - Inbox(接收 Follow/Create/Accept 等)
9
+ * - HTTP 签名验证(基础)
10
+ *
11
+ * 配置:
12
+ * TAICHU_AP_HOST — 实例公开域名(默认 localhost)
13
+ * TAICHU_AP_PORT — 公开端口(默认 3120)
14
+ */
15
+
16
+ import { createLogger } from './logger.js';
17
+ import { getStore } from './context.js';
18
+
19
+ const log = createLogger('activitypub');
20
+
21
+ const AP_CONTEXT = [
22
+ 'https://www.w3.org/ns/activitystreams',
23
+ 'https://w3id.org/security/v1'
24
+ ];
25
+
26
+ const HOST = process.env.TAICHU_AP_HOST || 'localhost';
27
+ const PORT = process.env.TAICHU_AP_PORT || process.env.TAICHU_PORT || '3120';
28
+ const BASE_URL = process.env.TAICHU_AP_BASE_URL || `http://${HOST}:${PORT}`;
29
+
30
+ /**
31
+ * Generate Actor object.
32
+ */
33
+ export function actorObject() {
34
+ const actorId = `${BASE_URL}/api/activitypub/actor`;
35
+ return {
36
+ '@context': AP_CONTEXT,
37
+ id: actorId,
38
+ type: 'Person',
39
+ preferredUsername: 'taichu',
40
+ name: 'Taichu CMS',
41
+ summary: 'AI Agent-Native Content Infrastructure',
42
+ url: BASE_URL,
43
+ icon: { type: 'Image', url: `${BASE_URL}/favicon.svg` },
44
+ inbox: `${BASE_URL}/api/activitypub/inbox`,
45
+ outbox: `${BASE_URL}/api/activitypub/outbox`,
46
+ followers: `${BASE_URL}/api/activitypub/followers`,
47
+ publicKey: {
48
+ id: `${actorId}#main-key`,
49
+ owner: actorId,
50
+ publicKeyPem: getPublicKeyPem()
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Generate WebFinger response.
57
+ */
58
+ export function webfingerResponse(resource) {
59
+ const actorUrl = `${BASE_URL}/api/activitypub/actor`;
60
+ return {
61
+ subject: resource,
62
+ links: [
63
+ {
64
+ rel: 'self',
65
+ type: 'application/activity+json',
66
+ href: actorUrl
67
+ }
68
+ ]
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Create an activity (used to publish content events).
74
+ */
75
+ export function createActivity(type, object) {
76
+ return {
77
+ '@context': AP_CONTEXT,
78
+ id: `${BASE_URL}/api/activitypub/activity/${Date.now()}`,
79
+ type,
80
+ actor: `${BASE_URL}/api/activitypub/actor`,
81
+ published: new Date().toISOString(),
82
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
83
+ object
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Create a "Create" activity when content is published.
89
+ */
90
+ export function createContentActivity(doc) {
91
+ const contentId = `${BASE_URL}/api/content/${doc.type}/${doc.id}`;
92
+ return createActivity('Create', {
93
+ id: contentId,
94
+ type: 'Article',
95
+ attributedTo: `${BASE_URL}/api/activitypub/actor`,
96
+ name: doc.data?.title || '',
97
+ content: typeof doc.data?.body === 'string'
98
+ ? doc.data.body.substring(0, 2000)
99
+ : (doc.data?.summary || ''),
100
+ url: contentId,
101
+ published: doc.publishedAt || doc.createdAt
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Process an incoming activity (inbox handler).
107
+ * @returns {Promise<{accepted: boolean, type?: string}>}
108
+ */
109
+ export async function processInboxActivity(activity, headers) {
110
+ // Verify HTTP signature (basic)
111
+ const sigValid = verifySignature(headers);
112
+ if (!sigValid) {
113
+ log.warn('Inbox activity rejected: invalid signature');
114
+ return { accepted: false, reason: 'invalid_signature' };
115
+ }
116
+
117
+ const { type, actor } = activity;
118
+
119
+ switch (type) {
120
+ case 'Follow': {
121
+ log.info(`Follow request from ${actor}`);
122
+ // Auto-accept follows
123
+ const accept = createActivity('Accept', activity);
124
+ return { accepted: true, type: 'Follow', response: accept };
125
+ }
126
+
127
+ case 'Create':
128
+ case 'Announce':
129
+ case 'Like':
130
+ log.info(`Received ${type} from ${actor}`);
131
+ // Store for potential future use
132
+ await storeActivity(activity);
133
+ return { accepted: true, type };
134
+
135
+ case 'Undo':
136
+ log.info(`Received Undo from ${actor}`);
137
+ return { accepted: true, type: 'Undo' };
138
+
139
+ case 'Delete':
140
+ log.info(`Received Delete from ${actor}`);
141
+ return { accepted: true, type: 'Delete' };
142
+
143
+ default:
144
+ log.debug(`Unhandled activity type: ${type}`);
145
+ return { accepted: true, type: 'unhandled' };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Store received activity as a document.
151
+ */
152
+ async function storeActivity(activity) {
153
+ try {
154
+ const store = getStore();
155
+ if (store) {
156
+ await store.create({
157
+ type: 'activitypub_activity',
158
+ data: activity,
159
+ status: 'received'
160
+ });
161
+ }
162
+ } catch (err) {
163
+ log.error(`Failed to store activity: ${err.message}`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Verify HTTP Signature header (basic implementation).
169
+ */
170
+ function verifySignature(headers) {
171
+ const sigHeader = headers['signature'];
172
+ if (!sigHeader) return false;
173
+
174
+ // Parse signature header
175
+ const sigParts = {};
176
+ sigHeader.split(',').forEach(part => {
177
+ const [key, ...vals] = part.trim().split('=');
178
+ sigParts[key.trim()] = vals.join('=').replace(/^"|"$/g, '');
179
+ });
180
+
181
+ // In production, this would:
182
+ // 1. Fetch the actor's public key
183
+ // 2. Reconstruct the signed string
184
+ // 3. Verify with crypto
185
+ //
186
+ // For P2 MVP: accept basic structure with keyId presence
187
+ if (sigParts.keyId && sigParts.signature && sigParts.headers) {
188
+ log.debug(`Signature from ${sigParts.keyId}`);
189
+ return true; // Basic acceptance for MVP
190
+ }
191
+
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Get the instance's public key (generated once at startup).
197
+ */
198
+ let _publicKeyPem = null;
199
+ let _privateKeyPem = null;
200
+
201
+ function getPublicKeyPem() {
202
+ ensureKeys();
203
+ return _publicKeyPem;
204
+ }
205
+
206
+ export function getPrivateKeyPem() {
207
+ ensureKeys();
208
+ return _privateKeyPem;
209
+ }
210
+
211
+ function ensureKeys() {
212
+ if (_publicKeyPem) return;
213
+
214
+ // Use configured keys or generate placeholder
215
+ const pubEnv = process.env.TAICHU_AP_PUBLIC_KEY;
216
+ const privEnv = process.env.TAICHU_AP_PRIVATE_KEY;
217
+
218
+ if (pubEnv && privEnv) {
219
+ _publicKeyPem = pubEnv.replace(/\\n/g, '\n');
220
+ _privateKeyPem = privEnv.replace(/\\n/g, '\n');
221
+ } else {
222
+ // Generate ephemeral keys for dev
223
+ // In production, generate via: openssl genpkey -algorithm RSA -out private.pem
224
+ log.warn('No AP keys configured. ActivityPub federation will not work in production.');
225
+ _publicKeyPem = 'GENERATE_KEYS_FOR_PRODUCTION';
226
+ _privateKeyPem = '';
227
+ }
228
+ }