876a3b8768
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
172 lines
6.2 KiB
JavaScript
172 lines
6.2 KiB
JavaScript
// ============================================================
|
||
// thought_lab/index.js - 思想实验室路由模块
|
||
// 职责:
|
||
// - /tools/thought_lab/labs/<lab_id>/ 实验页面与静态资源(仅导航鉴权 nav_gate)
|
||
// - GET /api/thought_lab/labs 返回已启用的 lab 列表(首页页签渲染用)
|
||
//
|
||
// 目录约定(参照 data_gateway 的 skills 分类思想):
|
||
// - 每个 lab 是 labs/ 下一个完全独立的子文件夹,互不干扰、互不继承
|
||
// - 后端侧 labs/<lab_id>/ 存放该 lab 的 config.json;
|
||
// 未来若该 lab 需要数据库、OSS、定时任务等后台逻辑,也落在该文件夹内
|
||
// - 前端页面对应 public/tools/thought_lab/labs/<lab_id>/index.html
|
||
// ============================================================
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const express = require('express')
|
||
|
||
const LABS_DIR = path.join(__dirname, 'labs')
|
||
const PUBLIC_DIR = path.join(process.cwd(), 'public', 'tools', 'thought_lab')
|
||
const FLAGS_PATH = path.join(process.cwd(), 'config', 'flags.json')
|
||
const NAV_AUTH_COOKIE = 'nav_gate'
|
||
|
||
// 全局导航鉴权开关(与首页 / 其他工具同一来源 config/flags.json -> navAuth.enable_auth)
|
||
// 开关关闭时全站放开,本模块同步放开,保持与容器行为一致
|
||
const isNavAuthEnabled = () => {
|
||
try {
|
||
const f = JSON.parse(fs.readFileSync(FLAGS_PATH, 'utf-8'))
|
||
const cfg = (f && f.navAuth) || {}
|
||
return cfg.enable_auth !== false
|
||
} catch { return true }
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 鉴权:仅导航鉴权(nav_gate cookie),无自定义 gate / GUID / token
|
||
// ------------------------------------------------------------
|
||
const parseCookie = (raw) => {
|
||
const out = {}
|
||
try {
|
||
String(raw || '').split(';').forEach(item => {
|
||
const idx = item.indexOf('=')
|
||
if (idx < 1) return
|
||
const key = item.slice(0, idx).trim()
|
||
const val = item.slice(idx + 1).trim()
|
||
if (key) out[key] = val
|
||
})
|
||
} catch {}
|
||
return out
|
||
}
|
||
|
||
const hasNavAuth = (req) => {
|
||
try {
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
return cookies[NAV_AUTH_COOKIE] === '1'
|
||
} catch { return false }
|
||
}
|
||
|
||
// 同源 Referer 判定:从首页导航点击/模态框进入 lab 页面时携带同源 Referer
|
||
const hasSameOriginReferer = (req) => {
|
||
try {
|
||
const referer = String(req.headers.referer || '')
|
||
const host = String(req.headers.host || '')
|
||
if (!referer || !host) return false
|
||
const u = new URL(referer)
|
||
return u.host === host
|
||
} catch { return false }
|
||
}
|
||
|
||
const navAuthPage = (req, res, next) => {
|
||
if (!isNavAuthEnabled()) return next()
|
||
if (hasNavAuth(req)) return next()
|
||
// 导航鉴权语义:允许从首页导航进入(同源 Referer),直链/外站访问仍拒绝
|
||
if (hasSameOriginReferer(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
}
|
||
|
||
const navAuthApi = (req, res, next) => {
|
||
if (!isNavAuthEnabled()) return next()
|
||
if (hasNavAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// lab 配置读取
|
||
// ------------------------------------------------------------
|
||
const loadLabConfig = (labId) => {
|
||
try {
|
||
const cfgPath = path.join(LABS_DIR, labId, 'config.json')
|
||
if (!fs.existsSync(cfgPath)) return null
|
||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||
if (!cfg || typeof cfg !== 'object') return null
|
||
return cfg
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 枚举所有已启用的 lab,按 sort_order 升序(同序按 id 字典序)
|
||
const listLabs = () => {
|
||
const result = []
|
||
try {
|
||
if (!fs.existsSync(LABS_DIR)) return result
|
||
const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
|
||
for (const d of dirs) {
|
||
if (!d.isDirectory()) continue
|
||
const cfg = loadLabConfig(d.name)
|
||
if (!cfg || cfg.enabled === false) continue
|
||
const id = String(cfg.id || d.name)
|
||
result.push({
|
||
id,
|
||
name: String(cfg.name || id),
|
||
icon: String(cfg.icon || '🧪'),
|
||
desc: String(cfg.desc || ''),
|
||
sort_order: Number(cfg.sort_order || 0),
|
||
href: `/tools/thought_lab/labs/${d.name}/index.html`
|
||
})
|
||
}
|
||
} catch {}
|
||
result.sort((a, b) => {
|
||
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
|
||
return String(a.id).localeCompare(String(b.id))
|
||
})
|
||
return result
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 路由注册
|
||
// 顺序:静态鉴权中间件必须注册在 express.static 之前(Rule 12.4)
|
||
// ------------------------------------------------------------
|
||
const bindRoutes = (app) => {
|
||
// 1) /tools/thought_lab 全部子路径:仅导航鉴权
|
||
app.use('/tools/thought_lab', navAuthPage)
|
||
|
||
// 2) 静态资源:public/tools/thought_lab/labs/<lab_id>/...
|
||
app.use('/tools/thought_lab', express.static(PUBLIC_DIR))
|
||
|
||
// 3) 白名单公开接口:lab 列表(仅名称/图标/简介等元数据,供首页页签渲染,
|
||
// 与 /api/tools 同级;按 Rule 12.8 在 API 守卫之前显式注册)
|
||
app.get('/api/thought_lab/labs', (req, res) => {
|
||
try {
|
||
return res.json({ ok: true, labs: listLabs() })
|
||
} catch (e) {
|
||
return res.status(500).json({ ok: false, error: 'internal_error' })
|
||
}
|
||
})
|
||
|
||
// 4) 其余 /api/thought_lab 接口:默认导航鉴权
|
||
app.use('/api/thought_lab', navAuthApi)
|
||
|
||
// 5) lab 后端自动绑定:labs/<lab_id>/index.js 若导出 bindRoutes 则独立注册
|
||
// (注册在 API 守卫之后,自动继承导航鉴权;各 lab 互不干扰)
|
||
bindLabBackends(app)
|
||
}
|
||
|
||
const bindLabBackends = (app) => {
|
||
try {
|
||
if (!fs.existsSync(LABS_DIR)) return
|
||
const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
|
||
for (const d of dirs) {
|
||
if (!d.isDirectory()) continue
|
||
const entry = path.join(LABS_DIR, d.name, 'index.js')
|
||
if (!fs.existsSync(entry)) continue
|
||
try {
|
||
const mod = require(entry)
|
||
if (mod && typeof mod.bindRoutes === 'function') mod.bindRoutes(app)
|
||
} catch (e) {
|
||
try { console.error(`[thought_lab] 绑定 lab 后端失败: ${d.name} ${String(e.message || e)}`) } catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
module.exports = { bindRoutes, listLabs, loadLabConfig }
|