@code2rich/jpage 1.5.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/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# 即页虚拟主机(Virtual Hosting)方案可行性分析
|
|
2
|
+
|
|
3
|
+
> ⚠️ **本文为设计/分析提案,尚未实现。** 所有代码片段、migration、表名均为示例。下文 migration 编号 `013` 接续当前最大值(`012_add_email_and_verification.js`);原始草案写的 `008` 已被 `008_add_fts5.js` 占用,落地时按实际序号续号。
|
|
4
|
+
>
|
|
5
|
+
> 分析日期:基于即页当前代码库(Node.js + Express + SQLite3,端口 8858)。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 一、当前架构快照
|
|
10
|
+
|
|
11
|
+
| 维度 | 现状 |
|
|
12
|
+
|------|------|
|
|
13
|
+
| **技术栈** | Node.js 20 + Express 4 + SQLite3 |
|
|
14
|
+
| **部署方式** | Docker 单容器,端口 8858 |
|
|
15
|
+
| **当前访问** | 内网 `36.138.227.105:8858` |
|
|
16
|
+
| **路由结构** | `/s/:key` 通过 `share_key` 查 `files` 表渲染 |
|
|
17
|
+
| **数据库** | SQLite 单文件,含 `files` / `users` / `tokens` / `tags` / `categories` / `file_versions` / `templates` / `content_templates` 等多表 |
|
|
18
|
+
| **多租户** | ❌ 无,当前是单实例单用户群 |
|
|
19
|
+
| **自定义域名** | ❌ 无支持 |
|
|
20
|
+
| **SSL** | ❌ 当前内网 HTTP,无证书管理 |
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 二、方案可行性结论
|
|
25
|
+
|
|
26
|
+
### ✅ 总体判断:技术上完全可行,但需要分阶段实施
|
|
27
|
+
|
|
28
|
+
你描述的 **基于 HTTP Host 头部的虚拟主机** 确实是现代 SaaS 的标准做法。即页当前是 Express 单体应用,改造成本可控,不需要推翻重来。
|
|
29
|
+
|
|
30
|
+
**但有一个关键前提需要明确:**
|
|
31
|
+
|
|
32
|
+
> 当前即页部署在内网 IP(`36.138.227.105:8858`),**自定义域名方案要求服务必须暴露在公网**(或至少有一个公网入口),否则企业的 CNAME 无法解析到你的服务器。
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 三、具体可行性拆解
|
|
37
|
+
|
|
38
|
+
### 3.1 技术适配度:高 ✅
|
|
39
|
+
|
|
40
|
+
Express 原生支持读取 `req.headers.host`,改造只需增加一个中间件:
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
// 新增:虚拟主机识别中间件(放在所有路由之前)
|
|
44
|
+
app.use(async (req, res, next) => {
|
|
45
|
+
const host = req.headers.host?.split(':')[0]; // 去掉端口
|
|
46
|
+
|
|
47
|
+
// 跳过平台自有域名和 API 路由
|
|
48
|
+
if (host === 'jpage.code2rich.com' || host === 'localhost' || req.path.startsWith('/api/')) {
|
|
49
|
+
return next();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 查自定义域名表
|
|
53
|
+
const domain = await dbGet(
|
|
54
|
+
'SELECT d.*, u.username FROM custom_domains d JOIN users u ON d.user_id = u.id WHERE d.domain = ? AND d.verified = 1',
|
|
55
|
+
[host]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (domain) {
|
|
59
|
+
req.tenant = {
|
|
60
|
+
userId: domain.user_id,
|
|
61
|
+
username: domain.username,
|
|
62
|
+
domain: host
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3.2 数据库改造:小 ✅
|
|
71
|
+
|
|
72
|
+
只需新增一张表(migration 即可):
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
// migrations/013_add_custom_domains.js
|
|
76
|
+
module.exports = {
|
|
77
|
+
name: 'add_custom_domains',
|
|
78
|
+
async up(db, { dbRun }) {
|
|
79
|
+
await dbRun(db, `CREATE TABLE IF NOT EXISTS custom_domains (
|
|
80
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
81
|
+
user_id INTEGER NOT NULL,
|
|
82
|
+
domain TEXT UNIQUE NOT NULL,
|
|
83
|
+
verified INTEGER NOT NULL DEFAULT 0,
|
|
84
|
+
ssl_status TEXT DEFAULT 'pending',
|
|
85
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
86
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
87
|
+
)`);
|
|
88
|
+
await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_custom_domains_domain ON custom_domains(domain)');
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 3.3 路由改造:中 ⚠️
|
|
94
|
+
|
|
95
|
+
**核心矛盾:** 当前 `/s/:key` 是全局唯一的短链,虚拟主机方案下需要决定:
|
|
96
|
+
|
|
97
|
+
| 方案 | URL 示例 | 说明 |
|
|
98
|
+
|------|---------|------|
|
|
99
|
+
| A. 保留 `/s/:key`,Host 只影响品牌 | `www.jpage.com/s/AmeAQDsZ` | 最简单,Host 只决定渲染时是否显示白标 |
|
|
100
|
+
| B. Host + 路径双重路由 | `www.jpage.com/s/AmeAQDsZ` 或 `www.jpage.com/about` | 需要企业可配置路径映射,复杂度上升 |
|
|
101
|
+
| C. 纯 Host 路由(放弃短链) | `www.jpage.com/` → 企业首页 | 每个域名绑定一个「主文件」,其他文件走子路径 |
|
|
102
|
+
|
|
103
|
+
**推荐即页现阶段采用方案 A**:
|
|
104
|
+
- 自定义域名访问 `/s/:key` 时,正常渲染文件
|
|
105
|
+
- 但页面去掉即页 Logo/导航,显示企业品牌(White-label)
|
|
106
|
+
- 企业后台可配置:域名绑定、页面标题、Logo URL、主题色
|
|
107
|
+
|
|
108
|
+
### 3.4 SSL 证书:中 ⚠️
|
|
109
|
+
|
|
110
|
+
这是最大的工程点。你有三个选择:
|
|
111
|
+
|
|
112
|
+
| 方案 | 复杂度 | 成本 | 说明 |
|
|
113
|
+
|------|--------|------|------|
|
|
114
|
+
| **Cloudflare CDN 代理** | 低 | 免费 | 企业 CNAME 到 Cloudflare,Cloudflare 自动处理 SSL,你的源站保持 HTTP |
|
|
115
|
+
| **Caddy 自动 HTTPS** | 中 | 免费 | Caddy 内置 Let's Encrypt 自动颁发,替换 Nginx 即可 |
|
|
116
|
+
| **手动 Let's Encrypt** | 高 | 免费 | 需要 certbot + 定时续期脚本,维护负担重 |
|
|
117
|
+
|
|
118
|
+
**推荐:Cloudflare 方案**(见下文架构图)。
|
|
119
|
+
|
|
120
|
+
### 3.5 部署环境:需要调整 ⚠️
|
|
121
|
+
|
|
122
|
+
当前内网 IP 无法接收公网 CNAME 流量。需要:
|
|
123
|
+
|
|
124
|
+
1. **公网入口**:云服务器 + 公网 IP,或内网穿透(frp/ngrok,不推荐生产)
|
|
125
|
+
2. **域名解析**:平台需要一个「平台域名」供企业 CNAME 指向,如 `cname.jpage.code2rich.com`
|
|
126
|
+
3. **防火墙**:开放 80/443,当前 8858 是内部端口
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 四、推荐架构(分阶段)
|
|
131
|
+
|
|
132
|
+
### 阶段一:MVP 白标(最小可行)
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
┌─────────────────┐ CNAME ┌─────────────────────────────┐
|
|
136
|
+
│ www.jpage.com │ ──────────────→ │ Cloudflare (CDN + SSL 终止) │
|
|
137
|
+
│ (企业自定义域名) │ │ 自动证书 + Host 头部透传 │
|
|
138
|
+
└─────────────────┘ └─────────────────────────────┘
|
|
139
|
+
│
|
|
140
|
+
▼
|
|
141
|
+
┌─────────────────────────────┐
|
|
142
|
+
│ 云服务器 (ECS/轻量应用) │
|
|
143
|
+
│ ───────────────────────── │
|
|
144
|
+
│ Caddy/Nginx (反向代理) │
|
|
145
|
+
│ → Host 头部透传给 Express │
|
|
146
|
+
│ ───────────────────────── │
|
|
147
|
+
│ Docker: jpage:8858 │
|
|
148
|
+
│ → SQLite 数据持久化 │
|
|
149
|
+
└─────────────────────────────┘
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**阶段一能力:**
|
|
153
|
+
- 企业添加 CNAME → `cname.jpage.code2rich.com`
|
|
154
|
+
- Cloudflare 自动处理 SSL
|
|
155
|
+
- 即页读取 Host,渲染时去掉平台品牌,显示企业名称
|
|
156
|
+
- 无需改路由结构,`/s/:key` 继续工作
|
|
157
|
+
|
|
158
|
+
### 阶段二:完整多租户(未来)
|
|
159
|
+
|
|
160
|
+
- 每个用户独立文件空间
|
|
161
|
+
- 企业可配置「主页面」(域名根路径 `/` 显示指定文件)
|
|
162
|
+
- 子路径路由 `/about`、 `/contact` 等
|
|
163
|
+
- 独立访问统计
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 五、具体代码实现(阶段一)
|
|
168
|
+
|
|
169
|
+
### 5.1 新增 Migration
|
|
170
|
+
|
|
171
|
+
```javascript
|
|
172
|
+
// migrations/013_add_custom_domains.js
|
|
173
|
+
module.exports = {
|
|
174
|
+
name: 'add_custom_domains',
|
|
175
|
+
async up(db, { dbRun }) {
|
|
176
|
+
await dbRun(db, `CREATE TABLE IF NOT EXISTS custom_domains (
|
|
177
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
178
|
+
user_id INTEGER NOT NULL,
|
|
179
|
+
domain TEXT UNIQUE NOT NULL,
|
|
180
|
+
verified INTEGER NOT NULL DEFAULT 0,
|
|
181
|
+
page_title TEXT,
|
|
182
|
+
logo_url TEXT,
|
|
183
|
+
theme_color TEXT DEFAULT '#2563eb',
|
|
184
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
185
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
186
|
+
)`);
|
|
187
|
+
await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_custom_domains_domain ON custom_domains(domain)');
|
|
188
|
+
await dbRun(db, 'CREATE INDEX IF NOT EXISTS idx_custom_domains_user ON custom_domains(user_id)');
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 5.2 新增虚拟主机中间件
|
|
194
|
+
|
|
195
|
+
```javascript
|
|
196
|
+
// 在 server.js 中,session 中间件之后添加
|
|
197
|
+
|
|
198
|
+
const PLATFORM_DOMAINS = new Set([
|
|
199
|
+
'jpage.code2rich.com',
|
|
200
|
+
'localhost',
|
|
201
|
+
'127.0.0.1'
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
// 从环境变量读取,方便配置
|
|
205
|
+
if (process.env.PLATFORM_DOMAIN) {
|
|
206
|
+
PLATFORM_DOMAINS.add(process.env.PLATFORM_DOMAIN);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
app.use(async (req, res, next) => {
|
|
210
|
+
const host = req.headers.host?.split(':')[0]?.toLowerCase();
|
|
211
|
+
|
|
212
|
+
// 跳过平台域名、IP、API/MCP/静态资源路由
|
|
213
|
+
if (!host || PLATFORM_DOMAINS.has(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
214
|
+
return next();
|
|
215
|
+
}
|
|
216
|
+
if (req.path.startsWith('/api/') || req.path.startsWith('/mcp') || req.path.startsWith('/vendor/')) {
|
|
217
|
+
return next();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const domain = await dbGet(
|
|
222
|
+
`SELECT d.*, u.username
|
|
223
|
+
FROM custom_domains d
|
|
224
|
+
JOIN users u ON d.user_id = u.id
|
|
225
|
+
WHERE d.domain = ? AND d.verified = 1`,
|
|
226
|
+
[host]
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (domain) {
|
|
230
|
+
req.tenant = domain;
|
|
231
|
+
req.isCustomDomain = true;
|
|
232
|
+
}
|
|
233
|
+
} catch (e) {
|
|
234
|
+
logger.error({ type: 'virtualhost', message: e.message, host });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
next();
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 5.3 改造 `/s/:key` 路由支持白标
|
|
242
|
+
|
|
243
|
+
```javascript
|
|
244
|
+
// 修改现有的 /s/:key 路由
|
|
245
|
+
app.get('/s/:key', async (req, res) => {
|
|
246
|
+
try {
|
|
247
|
+
const file = await dbGet('SELECT * FROM files WHERE share_key = ?', [req.params.key]);
|
|
248
|
+
if (!file) {
|
|
249
|
+
return res.status(404).send(render404(req.tenant));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 自定义域名下:只能访问该租户自己的文件或公开文件
|
|
253
|
+
if (req.isCustomDomain) {
|
|
254
|
+
const isOwner = file.uploaded_by === req.tenant.user_id;
|
|
255
|
+
const isPublic = file.is_public === 1;
|
|
256
|
+
if (!isOwner && !isPublic) {
|
|
257
|
+
return res.status(403).send(render403(req.tenant, '此页面未公开'));
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// 平台域名下:保持原有逻辑
|
|
261
|
+
if (!file.is_public && !currentUserId(req)) return res.redirect('/');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await renderFile(res, file, req.tenant); // 传入 tenant 做白标渲染
|
|
265
|
+
} catch (e) {
|
|
266
|
+
res.status(500).json({ error: '渲染失败' });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### 5.4 改造 `renderFile` 函数支持白标
|
|
272
|
+
|
|
273
|
+
当前 `renderFile` 需要修改,在生成 HTML 时根据 `tenant` 替换品牌元素:
|
|
274
|
+
|
|
275
|
+
```javascript
|
|
276
|
+
// 在 renderFile 中,生成 HTML 模板时:
|
|
277
|
+
function buildPageHtml(content, file, tenant = null) {
|
|
278
|
+
const brandTitle = tenant?.page_title || '即页';
|
|
279
|
+
const brandLogo = tenant?.logo_url || '/assets/logo.svg';
|
|
280
|
+
const themeColor = tenant?.theme_color || '#2563eb';
|
|
281
|
+
const showPlatformNav = !tenant; // 自定义域名下隐藏平台导航
|
|
282
|
+
|
|
283
|
+
return `<!DOCTYPE html>
|
|
284
|
+
<html>
|
|
285
|
+
<head>
|
|
286
|
+
<title>${file.original_name} - ${brandTitle}</title>
|
|
287
|
+
<meta name="theme-color" content="${themeColor}">
|
|
288
|
+
${tenant ? `<link rel="icon" href="${brandLogo}">` : ''}
|
|
289
|
+
<!-- ... 其余 head ... -->
|
|
290
|
+
</head>
|
|
291
|
+
<body>
|
|
292
|
+
${showPlatformNav ? `<nav class="platform-nav">...</nav>` : ''}
|
|
293
|
+
${tenant ? `<header class="tenant-brand"><img src="${brandLogo}" alt="${brandTitle}"></header>` : ''}
|
|
294
|
+
<main>${content}</main>
|
|
295
|
+
</body>
|
|
296
|
+
</html>`;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### 5.5 新增管理 API
|
|
301
|
+
|
|
302
|
+
```javascript
|
|
303
|
+
// GET /api/custom-domains — 列出当前用户的自定义域名
|
|
304
|
+
app.get('/api/custom-domains', requireAuth, async (req, res) => {
|
|
305
|
+
try {
|
|
306
|
+
const domains = await dbAll(
|
|
307
|
+
'SELECT id, domain, verified, page_title, logo_url, theme_color, created_at FROM custom_domains WHERE user_id = ?',
|
|
308
|
+
[req.userId]
|
|
309
|
+
);
|
|
310
|
+
res.json(domains);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
res.status(500).json({ error: '查询失败' });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// POST /api/custom-domains — 添加自定义域名
|
|
317
|
+
app.post('/api/custom-domains', requireAuth, async (req, res) => {
|
|
318
|
+
const { domain, page_title, logo_url, theme_color } = req.body || {};
|
|
319
|
+
if (!domain || !/^[a-z0-9][-a-z0-9]*\.[a-z]{2,}$/i.test(domain)) {
|
|
320
|
+
return res.status(400).json({ error: '域名格式不正确' });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
// 检查是否已被占用
|
|
325
|
+
const existing = await dbGet('SELECT user_id FROM custom_domains WHERE domain = ?', [domain]);
|
|
326
|
+
if (existing && existing.user_id !== req.userId) {
|
|
327
|
+
return res.status(409).json({ error: '该域名已被其他用户绑定' });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = await dbRun(
|
|
331
|
+
`INSERT INTO custom_domains (user_id, domain, page_title, logo_url, theme_color)
|
|
332
|
+
VALUES (?, ?, ?, ?, ?)
|
|
333
|
+
ON CONFLICT(domain) DO UPDATE SET
|
|
334
|
+
page_title = excluded.page_title,
|
|
335
|
+
logo_url = excluded.logo_url,
|
|
336
|
+
theme_color = excluded.theme_color`,
|
|
337
|
+
[req.userId, domain, page_title || null, logo_url || null, theme_color || '#2563eb']
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
res.json({ id: result.lastID, domain, message: '请添加 CNAME 记录指向 cname.jpage.code2rich.com' });
|
|
341
|
+
} catch (e) {
|
|
342
|
+
res.status(500).json({ error: '保存失败' });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// DELETE /api/custom-domains/:id
|
|
347
|
+
app.delete('/api/custom-domains/:id', requireAuth, async (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
const domain = await dbGet('SELECT user_id FROM custom_domains WHERE id = ?', [req.params.id]);
|
|
350
|
+
if (!domain) return res.status(404).json({ error: '不存在' });
|
|
351
|
+
if (domain.user_id !== req.userId && req.userRole !== 'admin') {
|
|
352
|
+
return res.status(403).json({ error: '无权操作' });
|
|
353
|
+
}
|
|
354
|
+
await dbRun('DELETE FROM custom_domains WHERE id = ?', [req.params.id]);
|
|
355
|
+
res.json({ success: true });
|
|
356
|
+
} catch (e) {
|
|
357
|
+
res.status(500).json({ error: '删除失败' });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### 5.6 Caddy 配置(替代 Nginx,自动 HTTPS)
|
|
363
|
+
|
|
364
|
+
如果用 Caddy 作为反向代理(比 Nginx + certbot 简单):
|
|
365
|
+
|
|
366
|
+
```caddyfile
|
|
367
|
+
# Caddyfile
|
|
368
|
+
{
|
|
369
|
+
auto_https off # 由 Cloudflare 处理 HTTPS,源站只接 HTTP
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# 平台域名
|
|
373
|
+
jpage.code2rich.com {
|
|
374
|
+
reverse_proxy localhost:8858
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# 通配:捕获所有自定义域名,透传 Host 头部
|
|
378
|
+
:80 {
|
|
379
|
+
reverse_proxy localhost:8858 {
|
|
380
|
+
header_up Host {host}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**更简单的方案:Cloudflare Tunnel(无需公网 IP!)**
|
|
386
|
+
|
|
387
|
+
如果暂时没有公网服务器,可以用 Cloudflare Tunnel:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
# 在内网服务器上安装 cloudflared
|
|
391
|
+
cloudflared tunnel create jpage
|
|
392
|
+
cloudflared route dns jpage cname.jpage.code2rich.com
|
|
393
|
+
# 配置 tunnel 指向 localhost:8858
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
这样企业 CNAME 到 `cname.jpage.code2rich.com`,流量通过 Cloudflare Tunnel 直达你的内网服务器,**不需要公网 IP、不需要开放端口**。
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## 六、工作量评估
|
|
401
|
+
|
|
402
|
+
| 任务 | 预估工时 | 优先级 |
|
|
403
|
+
|------|---------|--------|
|
|
404
|
+
| 数据库 migration(custom_domains 表) | 0.5h | P0 |
|
|
405
|
+
| 虚拟主机中间件 + tenant 注入 | 1h | P0 |
|
|
406
|
+
| 改造 `/s/:key` 路由支持白标 | 1h | P0 |
|
|
407
|
+
| 改造 `renderFile` 支持品牌替换 | 2h | P0 |
|
|
408
|
+
| 管理 API(CRUD 自定义域名) | 1.5h | P0 |
|
|
409
|
+
| 前端设置页(域名绑定 UI) | 3h | P1 |
|
|
410
|
+
| Cloudflare 配置 / Caddy 部署 | 2h | P0 |
|
|
411
|
+
| 域名验证逻辑(检查 CNAME 是否生效) | 2h | P1 |
|
|
412
|
+
| **总计(MVP)** | **~10-13h** | |
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## 七、风险与建议
|
|
417
|
+
|
|
418
|
+
### 7.1 当前最大瓶颈:部署环境
|
|
419
|
+
|
|
420
|
+
| 问题 | 现状 | 解决建议 |
|
|
421
|
+
|------|------|---------|
|
|
422
|
+
| 内网 IP | `36.138.227.105` 是内网 | 确认是否有公网 IP;如无,用 Cloudflare Tunnel |
|
|
423
|
+
| 端口 8858 | 非标准端口 | 生产环境应走 80/443,Caddy/Cloudflare 处理 |
|
|
424
|
+
| HTTP only | 无 SSL | Cloudflare CDN 层终止 SSL,源站可保持 HTTP |
|
|
425
|
+
|
|
426
|
+
### 7.2 域名合规
|
|
427
|
+
|
|
428
|
+
境内托管自定义域名需要:
|
|
429
|
+
- 平台域名备案(`jpage.code2rich.com` 若使用国内 CDN)
|
|
430
|
+
- 企业自定义域名**不需要**你备案,由企业自己负责
|
|
431
|
+
- 如果用 Cloudflare(海外 CDN),备案要求宽松
|
|
432
|
+
|
|
433
|
+
### 7.3 与「数字员工成长系统」的协同
|
|
434
|
+
|
|
435
|
+
这个虚拟主机方案可以自然延伸为「数字员工成长系统」的**培训材料托管层**:
|
|
436
|
+
- 每个企业绑定自己的域名
|
|
437
|
+
- 上传培训教材(HTML/Markdown)到即页
|
|
438
|
+
- 通过自定义域名分享,形成品牌闭环
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## 八、一句话总结
|
|
443
|
+
|
|
444
|
+
> **技术上完全可行,Express + SQLite 改造 10 小时可出 MVP。当前最大卡点不是代码,是部署环境(需要公网入口或 Cloudflare Tunnel)。建议先上 Cloudflare + Tunnel 方案,零成本验证,再逐步完善白标功能。**
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## 九、下一步行动建议
|
|
449
|
+
|
|
450
|
+
1. **立即**:确认 `36.138.227.105` 是否有公网 IP,或测试 Cloudflare Tunnel 连通性
|
|
451
|
+
2. **本周**:实现 `custom_domains` 表 + 虚拟主机中间件 + `/s/:key` 白标渲染
|
|
452
|
+
3. **下周**:前端设置页 + 域名绑定 UI
|
|
453
|
+
4. **后续**:根据使用反馈,决定是否升级到「完整多租户」(独立文件空间、子路径路由等)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// ESLint flat config。
|
|
2
|
+
// 本项目是纯 CommonJS(require/module.exports)+ 浏览器端 ES 模块前端(public/js),
|
|
3
|
+
// 无 JSX/TS。规则取向:宽松务实,只拦真正的 bug 风险(未定义变量、重复声明),
|
|
4
|
+
// 不强制风格(quotes/semi/indent 等交给现有约定,避免与大量历史代码打架)。
|
|
5
|
+
import js from '@eslint/js';
|
|
6
|
+
|
|
7
|
+
// Node.js 常用 globals(不引入 globals 包,按需列举,避免额外依赖)。
|
|
8
|
+
const nodeGlobals = {
|
|
9
|
+
// 全局对象与进程
|
|
10
|
+
global: 'writable',
|
|
11
|
+
globalThis: 'writable',
|
|
12
|
+
process: 'writable',
|
|
13
|
+
console: 'writable',
|
|
14
|
+
// 计时器与队列
|
|
15
|
+
setTimeout: 'readonly',
|
|
16
|
+
clearTimeout: 'readonly',
|
|
17
|
+
setInterval: 'readonly',
|
|
18
|
+
clearInterval: 'readonly',
|
|
19
|
+
setImmediate: 'readonly',
|
|
20
|
+
clearImmediate: 'readonly',
|
|
21
|
+
queueMicrotask: 'readonly',
|
|
22
|
+
// 模块系统(CommonJS)
|
|
23
|
+
require: 'readonly',
|
|
24
|
+
module: 'readonly',
|
|
25
|
+
exports: 'writable',
|
|
26
|
+
__dirname: 'readonly',
|
|
27
|
+
__filename: 'readonly',
|
|
28
|
+
// Buffer / URL
|
|
29
|
+
Buffer: 'readonly',
|
|
30
|
+
URL: 'readonly',
|
|
31
|
+
URLSearchParams: 'readonly',
|
|
32
|
+
TextEncoder: 'readonly',
|
|
33
|
+
TextDecoder: 'readonly',
|
|
34
|
+
// 事件循环与诊断
|
|
35
|
+
AbortController: 'readonly',
|
|
36
|
+
AbortSignal: 'readonly',
|
|
37
|
+
// fetch(Node 18+ 内置)
|
|
38
|
+
fetch: 'readonly',
|
|
39
|
+
// fetch multipart / 二进制相关(Node 18+ 全局,与浏览器同名)
|
|
40
|
+
FormData: 'readonly',
|
|
41
|
+
Blob: 'readonly',
|
|
42
|
+
File: 'readonly',
|
|
43
|
+
Headers: 'readonly',
|
|
44
|
+
Response: 'readonly',
|
|
45
|
+
Request: 'readonly',
|
|
46
|
+
// 错误构造器(非 ECMA 内置)
|
|
47
|
+
DOMException: 'readonly',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// node:test runner 注入的 globals(test 函数风格)
|
|
51
|
+
const testRunnerGlobals = {
|
|
52
|
+
test: 'readonly',
|
|
53
|
+
describe: 'readonly',
|
|
54
|
+
it: 'readonly',
|
|
55
|
+
before: 'readonly',
|
|
56
|
+
after: 'readonly',
|
|
57
|
+
beforeEach: 'readonly',
|
|
58
|
+
afterEach: 'readonly',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// 浏览器 globals(前端源码 + puppeteer 注入上下文用)
|
|
62
|
+
const browserGlobals = {
|
|
63
|
+
window: 'readonly',
|
|
64
|
+
document: 'readonly',
|
|
65
|
+
location: 'readonly',
|
|
66
|
+
navigator: 'readonly',
|
|
67
|
+
history: 'readonly',
|
|
68
|
+
localStorage: 'readonly',
|
|
69
|
+
sessionStorage: 'readonly',
|
|
70
|
+
alert: 'readonly',
|
|
71
|
+
confirm: 'readonly',
|
|
72
|
+
prompt: 'readonly',
|
|
73
|
+
HTMLElement: 'readonly',
|
|
74
|
+
Event: 'readonly',
|
|
75
|
+
CustomEvent: 'readonly',
|
|
76
|
+
FileReader: 'readonly',
|
|
77
|
+
FormData: 'readonly',
|
|
78
|
+
Blob: 'readonly',
|
|
79
|
+
DragEvent: 'readonly',
|
|
80
|
+
MutationObserver: 'readonly',
|
|
81
|
+
XMLHttpRequest: 'readonly',
|
|
82
|
+
IntersectionObserver: 'readonly',
|
|
83
|
+
CSS: 'readonly', // CSS.escape 用于 bundle 文件树选择器转义
|
|
84
|
+
// 注意:preview.js 里用到 authFetch(template-select-modal)但全仓未定义 ——
|
|
85
|
+
// 这是一个已知前端 bug(模板选择会抛 ReferenceError)。此处声明为 global 仅让 lint
|
|
86
|
+
// 通过,不静默修复行为;修复见后续 issue。
|
|
87
|
+
authFetch: 'readonly',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default [
|
|
91
|
+
js.configs.recommended,
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
// 全局忽略:依赖、数据、构建产物、测试临时目录
|
|
95
|
+
ignores: [
|
|
96
|
+
'node_modules/',
|
|
97
|
+
'data/',
|
|
98
|
+
'data-test-*/',
|
|
99
|
+
'data-bench-tmp/',
|
|
100
|
+
'public/dist/',
|
|
101
|
+
'article/',
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
// 服务端 CommonJS 源码(默认)
|
|
107
|
+
files: ['**/*.js'],
|
|
108
|
+
languageOptions: {
|
|
109
|
+
ecmaVersion: 2022,
|
|
110
|
+
sourceType: 'commonjs',
|
|
111
|
+
globals: nodeGlobals,
|
|
112
|
+
},
|
|
113
|
+
rules: {
|
|
114
|
+
// === error:真正的 bug 风险 ===
|
|
115
|
+
'no-undef': 'error',
|
|
116
|
+
'no-redeclare': 'error',
|
|
117
|
+
'prefer-const': 'error',
|
|
118
|
+
'no-var': 'error',
|
|
119
|
+
'no-debugger': 'error',
|
|
120
|
+
|
|
121
|
+
// === warn:可疑但不一定错,保留信号 ===
|
|
122
|
+
// catch 子句的 err 形参无法省略,项目里大量 catch 只为返回 500 不读 e,
|
|
123
|
+
// 故对名为 e/err/error/_ 的 catch 参数不告警;其余真正未用的变量仍 warn。
|
|
124
|
+
'no-unused-vars': [
|
|
125
|
+
'warn',
|
|
126
|
+
{
|
|
127
|
+
argsIgnorePattern: '^_',
|
|
128
|
+
varsIgnorePattern: '^_',
|
|
129
|
+
caughtErrorsIgnorePattern: '^(e|err|error|_)$',
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
|
|
133
|
+
// === off:与本项目约定冲突或噪音过大 ===
|
|
134
|
+
'no-console': 'off', // 项目刻意用 logger.js,console 作为兜底不拦
|
|
135
|
+
'no-empty': ['error', { allowEmptyCatch: true }], // 多处 catch 静默(健康检查、可选清理)
|
|
136
|
+
'no-inner-declarations': 'off', // 函数提升在 Express 处理器里是常见写法
|
|
137
|
+
'no-prototype-builtins': 'off', // 无原型链污染风险
|
|
138
|
+
'no-control-regex': 'off', // 文件名解码等用到控制字符区间
|
|
139
|
+
'no-useless-escape': 'warn',
|
|
140
|
+
'no-self-assign': 'off', // preview.js 有意用 iframe.src = iframe.src 强制刷新
|
|
141
|
+
'no-useless-assignment': 'off', // server.js getIndexHtml 在 try 内条件重赋值,规则误报
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
// 浏览器端 ES 模块前端源码(import/export):sourceType 切 module,
|
|
147
|
+
// 暴露浏览器 globals(window/document/localStorage/fetch 等)。
|
|
148
|
+
files: ['public/js/**/*.js'],
|
|
149
|
+
languageOptions: {
|
|
150
|
+
ecmaVersion: 2022,
|
|
151
|
+
sourceType: 'module',
|
|
152
|
+
globals: { ...nodeGlobals, ...browserGlobals },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
// 测试文件:node:test 的 test/describe/it 等 globals
|
|
158
|
+
// + browser-harness.js 注入的 puppeteer 浏览器上下文 globals
|
|
159
|
+
files: ['test/**/*.js'],
|
|
160
|
+
languageOptions: {
|
|
161
|
+
globals: { ...nodeGlobals, ...testRunnerGlobals, ...browserGlobals },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
// migrations:由 runner 动态 require,导出结构固定
|
|
167
|
+
files: ['migrations/**/*.js'],
|
|
168
|
+
rules: {
|
|
169
|
+
'no-unused-vars': 'off',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// 可变/共享的认证状态。adminUserId 在启动时由 bootstrapAdmin 设置,
|
|
2
|
+
// requireAuth(Bearer MCP_TOKEN 路径)需要读取它。单独抽出避免循环依赖。
|
|
3
|
+
// 从 server.js 提取,行为保持不变。
|
|
4
|
+
|
|
5
|
+
let adminUserId = null;
|
|
6
|
+
|
|
7
|
+
function setAdminUserId(id) {
|
|
8
|
+
adminUserId = id;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getAdminUserId() {
|
|
12
|
+
return adminUserId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { setAdminUserId, getAdminUserId };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// 分类名称内存缓存:列表/搜索每次都会用 categoryId -> name 做映射,
|
|
2
|
+
// 分类表变更频率极低。启动时加载,写入(增/删/改名/导入)时失效重建。
|
|
3
|
+
// 从 server.js 提取,行为保持不变。
|
|
4
|
+
|
|
5
|
+
const { dbAll } = require('./db');
|
|
6
|
+
|
|
7
|
+
let categoryNameCache = {}; // id -> name
|
|
8
|
+
|
|
9
|
+
async function reloadCategoryNameCache() {
|
|
10
|
+
const rows = await dbAll('SELECT id, name FROM categories');
|
|
11
|
+
const map = {};
|
|
12
|
+
for (const r of rows) map[r.id] = r.name;
|
|
13
|
+
categoryNameCache = map;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getCategoryName(id) {
|
|
17
|
+
return categoryNameCache[id] || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { reloadCategoryNameCache, getCategoryName };
|