refactor: 重构禅音工具路径与配置,新增私人剪贴板技能

这是一次大型重构和功能新增:
1. 将原 zen_box 工具重命名为 web_zen_box,调整所有相关路径与配置
2. 新增 web_order_box 原生Web应用的完整资源与部署脚本
3. 新增私人剪贴板后端技能与配套测试脚本
4. 修复订单盒子APP的API地址配置,避免路径重复拼接
5. 新增本地开发跨域白名单支持localhost
6. 清理旧版zen_box的冗余文件
This commit is contained in:
yangxiangyuan
2026-07-26 21:03:28 +08:00
parent a062a7e2c0
commit 212dbc4e1d
47 changed files with 6446 additions and 755 deletions
+11
View File
@@ -10,6 +10,7 @@ const { insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count }
const { loadSkillConfig } = require('./auth')
const { bindRoutes: bindExternalStorageRoutes } = require('./skills/external_storage')
const { bindRoutes: bindEmailSenderRoutes } = require('./skills/email_sender')
const { bindRoutes: bindPrivateClipboardRoutes } = require('./skills/private_clipboard')
const setNoCache = (res) => {
try {
@@ -94,6 +95,16 @@ const bindRoutes = (app) => {
console.error(`[data_gateway] Failed to bind email_sender routes: ${e.message}`)
}
// ============================================================
// 私人剪贴板 skill(对外写入剪贴板)
// 路由挂载在 /api/v1/ingest/private_clipboard/*
// ============================================================
try {
bindPrivateClipboardRoutes(app)
} catch (e) {
console.error(`[data_gateway] Failed to bind private_clipboard routes: ${e.message}`)
}
// ============================================================
// 外部写入接口(公网暴露)
// POST /api/v1/ingest/:skillId
@@ -0,0 +1,214 @@
// ============================================================
// clipboard_bridge.js - 桥接模块,复用私人剪贴板的数据库和文件存储逻辑
// 供 data_gateway 的 private_clipboard skill 调用
// ============================================================
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const DATA_DIR = path.join(process.cwd(), 'data')
const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'private_clipboard')
const DB_PATH = process.env.PRIVATE_CLIPBOARD_DB_PATH || path.join(DATA_DIR, 'private_clipboard.db')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
let db = null
const ensureSchema = () => {
if (!db) return
db.exec(`
CREATE TABLE IF NOT EXISTS clipboard_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text_content TEXT,
file_name TEXT,
file_mime TEXT,
file_size INTEGER,
file_path TEXT,
group_id INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clipboard_items_created_at ON clipboard_items(created_at);
`)
const cols = db.prepare('PRAGMA table_info(clipboard_items)').all()
if (!cols.some(c => c.name === 'group_id')) db.exec('ALTER TABLE clipboard_items ADD COLUMN group_id INTEGER')
}
const getDb = () => {
if (db) {
ensureSchema()
return db
}
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.pragma('busy_timeout = 4000')
ensureSchema()
return db
}
const normalizeName = (name) => {
let s = String(name || '').replace(/\0/g, '')
// 移除路径穿越模式
s = s.replace(/\.\./g, '')
const base = path.basename(s)
return base || 'file'
}
const extFromMime = (mime) => {
const m = String(mime || '').toLowerCase()
if (m === 'image/png') return '.png'
if (m === 'image/jpeg' || m === 'image/jpg') return '.jpg'
if (m === 'image/webp') return '.webp'
if (m === 'image/gif') return '.gif'
if (m === 'image/bmp') return '.bmp'
if (m === 'image/svg+xml') return '.svg'
return ''
}
const saveFile = (rawBuffer, name, mime) => {
const safe = normalizeName(name)
let ext = path.extname(safe)
if (!ext) ext = extFromMime(mime)
const stamp = Date.now()
const rand = crypto.randomBytes(6).toString('hex')
const fileName = `${stamp}-${rand}${ext}`
const rel = path.join('uploads', 'private_clipboard', fileName)
const abs = path.join(process.cwd(), rel)
fs.writeFileSync(abs, rawBuffer)
const size = rawBuffer.length
return { rel, fileName: safe, size }
}
const formatItem = (row) => {
const filePath = row.file_path ? String(row.file_path) : ''
const url = filePath ? '/' + filePath.replace(/^\/+/, '') : ''
return {
id: row.id,
type: row.type,
text_content: row.text_content || '',
file_name: row.file_name || '',
file_mime: row.file_mime || '',
file_size: row.file_size || 0,
file_url: url,
created_at: row.created_at
}
}
// ============================================================
// 公开 API
// ============================================================
/**
* 插入一条文本记录
*/
const insertText = (text, groupId) => {
const database = getDb()
const now = Date.now()
const info = database.prepare(
'INSERT INTO clipboard_items (type, text_content, group_id, created_at) VALUES (?, ?, ?, ?)'
).run('text', String(text || ''), groupId !== null && groupId !== undefined ? groupId : null, now)
return { id: info.lastInsertRowid, type: 'text', created_at: now }
}
/**
* 插入多个文件(支持一次上传多个)
* 返回 { group_id, items: [...] }
*/
const insertFiles = (files, groupId) => {
const database = getDb()
const now = Date.now()
// 如果没有提供 groupId,自动生成一个
const actualGroupId = (groupId !== null && groupId !== undefined) ? groupId : -now
const tx = database.transaction(() => {
const items = []
for (const f of files) {
const { rel, fileName, size } = saveFile(f.buffer, f.name, f.mime)
const type = String(f.mime || '').toLowerCase().startsWith('image/') ? 'image' : 'file'
const info = database.prepare(
'INSERT INTO clipboard_items (type, file_name, file_mime, file_size, file_path, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(
type, fileName, f.mime || '', size, rel, actualGroupId, now
)
items.push({
id: info.lastInsertRowid,
type,
file_name: fileName,
file_mime: f.mime || '',
file_size: size,
file_url: '/' + rel.replace(/^\/+/, ''),
created_at: now
})
}
return items
})
const items = tx()
return { group_id: actualGroupId, items }
}
/**
* 查询最近 N 条记录(按 group 分组)
*/
const queryLatest = (limit = 50) => {
const database = getDb()
const safeLimit = Math.min(Math.max(1, Number(limit) || 50), 500)
const rows = database.prepare(`
SELECT * FROM clipboard_items
ORDER BY created_at DESC
LIMIT ?
`).all(safeLimit)
// 按 group_id 分组
const groups = new Map()
for (const row of rows) {
const gid = row.group_id
const key = gid !== null && gid !== undefined ? `g:${gid}` : `r:${row.id}`
if (!groups.has(key)) {
groups.set(key, {
id: gid || row.id,
type: row.type,
text_content: row.text_content || '',
created_at: row.created_at,
items: [],
group_types: []
})
}
const group = groups.get(key)
group.group_types.push(row.type)
if (row.type === 'text') {
if (!group.text_content) group.text_content = row.text_content || ''
} else {
group.items.push(formatItem(row))
}
}
const result = Array.from(groups.values()).map(group => {
const unique = Array.from(new Set(group.group_types.filter(Boolean)))
let type = group.type
if (group.items.length) {
type = unique.length === 1 ? unique[0] : 'mixed'
} else {
type = 'text'
}
return {
id: group.id,
type,
text_content: group.text_content,
created_at: group.created_at,
items: group.items,
group_types: unique
}
})
// 按 created_at 降序
result.sort((a, b) => b.created_at - a.created_at)
return { items: result, total: result.length }
}
module.exports = { getDb, insertText, insertFiles, queryLatest }
@@ -0,0 +1,20 @@
{
"_comment": "私人剪贴板 skill - 供第三方程序向剪贴板写入数据",
"id": "private_clipboard",
"name": "私人剪贴板",
"enabled": true,
"api_key_comment": "API Key(明文),用于写入剪贴板数据的鉴权",
"api_key": "pcb_nuS0qf-W63qcvAPsrG5ePA",
"api_key_hash_comment": "API Key 的 SHA256 哈希,用于服务端校验",
"api_key_hash": "sha256:d28127ad7b0b84563891fbc2621f0623915055e12c7097cd31191014ef2b1121",
"rate_limit_comment": "每分钟最多 60 次请求",
"rate_limit_per_minute": 60,
"max_files_per_request_comment": "单次上传最多文件数",
"max_files_per_request": 10,
"max_file_size_comment": "单文件最大 200MB(与现有剪贴板逻辑一致)",
"max_file_size": 209715200,
"latest_limit_comment": "latest 接口默认返回最近 50 条",
"latest_limit": 50,
"db_path_comment": "剪贴板数据库路径(读取环境变量 PRIVATE_CLIPBOARD_DB_PATH,默认 data/private_clipboard.db)",
"upload_dir_comment": "剪贴板上传目录(复用现有 uploads/private_clipboard/)"
}
@@ -0,0 +1,231 @@
// ============================================================
// private_clipboard/index.js - 私人剪贴板对外写入 API
// 路由挂载在 /api/v1/ingest/private_clipboard/*
// ============================================================
const express = require('express')
const multer = require('multer')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { verifyApiKey } = require('../../auth')
const { logJSON } = require('../../../logger')
const { insertText, insertFiles, queryLatest } = require('./clipboard_bridge')
const SKILL_ID = 'private_clipboard'
const RATE_LIMIT_MAX = 60
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const MAX_FILES = 10
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
const MAX_TEXT_LENGTH = 50000
// 临时目录
const TEMP_DIR = path.join(os.tmpdir(), 'private_clipboard_ingest')
if (!fs.existsSync(TEMP_DIR)) {
try { fs.mkdirSync(TEMP_DIR, { recursive: true }) } catch {}
}
const upload = multer({
dest: TEMP_DIR,
limits: { fileSize: MAX_FILE_SIZE, files: MAX_FILES }
})
// 速率限制
const rateLimitMap = new Map()
const checkRate = (ip) => {
const rec = rateLimitMap.get(ip)
if (!rec || Date.now() - rec.start > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(ip, { start: Date.now(), count: 1 })
return { ok: true }
}
rec.count++
if (rec.count > RATE_LIMIT_MAX) {
const waitSec = Math.ceil((rec.start + RATE_LIMIT_WINDOW_MS - Date.now()) / 1000)
return { ok: false, retryAfter: Math.max(1, waitSec) }
}
return { ok: true }
}
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of rateLimitMap.entries()) {
if (now - rec.start > RATE_LIMIT_WINDOW_MS + 10000) rateLimitMap.delete(ip)
}
}, 60000)
// 安全文件名清理
const safeName = (name) => {
let s = String(name || 'file')
try {
const recovered = Buffer.from(s, 'latin1').toString('utf8')
if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered
} catch {}
// 移除路径穿越模式
s = s.replace(/\.\./g, '')
return s.replace(/[<>\":/\\|?*\x00-\x1F]/g, '_').trim() || 'file'
}
// HTML 转义(防御存储型 XSS)
const escapeHtml = (str) => {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
const setNoCache = (res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
}
const bindRoutes = (app) => {
const router = express.Router()
// API Key 鉴权中间件
router.use((req, res, next) => {
const auth = verifyApiKey(req)
if (!auth.ok) {
try { logJSON('private_clipboard.auth.fail', { error: auth.error, ip: req.ip }, 'private_clipboard') } catch {}
return res.status(401).json({ ok: false, error: auth.error })
}
const apiId = String(req.headers['x-api-id'] || '').trim()
if (apiId !== SKILL_ID) {
return res.status(403).json({ ok: false, error: 'skill id mismatch' })
}
next()
})
// 无缓存
router.use((req, res, next) => {
setNoCache(res)
next()
})
// ============================================================
// 写入文本
// POST /api/v1/ingest/private_clipboard/text
// Body: { "text": "...", "group_id": -123 }(group_id 可选)
// ============================================================
router.post('/text', express.json({
type: 'application/json',
limit: '1mb',
verify: (req, res, buf) => {
// 强制按 UTF-8 解码原始字节,绕过全局 express.json() 的可能编码错误
req._rawBody = buf.toString('utf8')
}
}), (req, res) => {
try {
// 速率限制
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
const rateCheck = checkRate(clientIp)
if (!rateCheck.ok) {
return res.status(429).json({ ok: false, error: '请求过于频繁,请在 ' + rateCheck.retryAfter + ' 秒后重试' })
}
// 优先使用 UTF-8 原始 buffer 解析,绕过全局 express.json() 的可能编码问题
let body = {}
let text = ''
if (req._rawBody) {
try {
body = JSON.parse(req._rawBody)
text = String(body.text || '').trim()
} catch { body = req.body || {} }
}
// 如果没有 rawBody 或解析失败,回退到 req.body
if (!text) {
body = req.body || {}
text = String(body.text || '').trim()
}
if (!text) return res.status(400).json({ ok: false, error: '文本内容不能为空' })
if (text.length > MAX_TEXT_LENGTH) {
return res.status(400).json({ ok: false, error: `文本超过 ${MAX_TEXT_LENGTH} 字符限制` })
}
// HTML 转义后存库,防御存储型 XSS
const safeText = escapeHtml(text)
const groupId = body.group_id !== undefined ? Number(body.group_id) : null
const result = insertText(safeText, groupId)
try { logJSON('private_clipboard.text.ok', { id: result.id, groupId }, 'private_clipboard') } catch {}
res.json({ ok: true, id: result.id, type: result.type, created_at: result.created_at })
} catch (e) {
try { logJSON('private_clipboard.text.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// ============================================================
// 上传文件/图片(支持多文件)
// POST /api/v1/ingest/private_clipboard/upload
// multipart/form-data, field: "files", 可选 field: "group_id"
// ============================================================
router.post('/upload', upload.array('files', MAX_FILES), async (req, res) => {
try {
// 速率限制
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
const rateCheck = checkRate(clientIp)
if (!rateCheck.ok) {
return res.status(429).json({ ok: false, error: '请求过于频繁,请在 ' + rateCheck.retryAfter + ' 秒后重试' })
}
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ ok: false, error: '未收到文件' })
}
if (req.files.length > MAX_FILES) {
return res.status(400).json({ ok: false, error: `文件数量不能超过 ${MAX_FILES} 个` })
}
const groupId = req.body.group_id !== undefined ? Number(req.body.group_id) : null
const files = req.files.map(f => ({
buffer: fs.readFileSync(f.path),
name: safeName(f.originalname || 'file'),
mime: f.mimetype || 'application/octet-stream'
}))
const result = insertFiles(files, groupId)
// 清理临时文件
for (const f of req.files) {
if (f.path) try { fs.unlinkSync(f.path) } catch {}
}
try { logJSON('private_clipboard.upload.ok', { count: result.items.length, groupId: result.group_id }, 'private_clipboard') } catch {}
res.json({ ok: true, group_id: result.group_id, items: result.items })
} catch (e) {
// 清理临时文件
if (req.files && Array.isArray(req.files)) {
for (const f of req.files) {
if (f.path) try { fs.unlinkSync(f.path) } catch {}
}
}
try { logJSON('private_clipboard.upload.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
res.status(500).json({ ok: false, error: '上传失败' })
}
})
// ============================================================
// 查询最近 N 条
// GET /api/v1/ingest/private_clipboard/latest?limit=50
// ============================================================
router.get('/latest', (req, res) => {
try {
const limit = parseInt(req.query.limit, 10) || 50
const result = queryLatest(limit)
res.json({ ok: true, ...result })
} catch (e) {
try { logJSON('private_clipboard.latest.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
res.status(500).json({ ok: false, error: '查询失败' })
}
})
// 挂载路由
app.use('/api/v1/ingest/private_clipboard', router)
console.log('[private_clipboard] Routes mounted at /api/v1/ingest/private_clipboard')
}
module.exports = { bindRoutes }
+25 -2
View File
@@ -100,7 +100,9 @@ const allowedOrigins = [
'https://traesite.umersoft.com',
'http://traesite.umersoft.com',
'https://traesite.umersoft.com:8975',
'http://traesite.umersoft.com:8975'
'http://traesite.umersoft.com:8975',
'http://localhost:8081',
'http://127.0.0.1:8081'
]
app.use(cors({
origin: function (origin, callback) {
@@ -5906,9 +5908,30 @@ app.get('/api/box_backup/download/lowfreq_decrypted', async (req, res) => {
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
})
// web_order_box routes - 原生 HTML/CSS/JS Web 应用
app.get('/tools/web_order_box', (req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'tools', 'web_order_box', 'index.html'))
})
app.use('/tools/web_order_box', (req, res, next) => {
try {
const authConfigPath = path.join(process.cwd(), 'public', 'tools', 'web_order_box', 'auth_config.json')
if (fs.existsSync(authConfigPath)) {
const authConfig = JSON.parse(fs.readFileSync(authConfigPath, 'utf-8'))
if (authConfig.enable_auth === false) return next()
}
if (hasNavAuthRoot(req)) return next()
return res.status(401).send('未授权')
} catch { return res.status(401).send('未授权') }
})
app.use('/tools/web_order_box', (req, res, next) => {
res.set('X-Frame-Options', 'SAMEORIGIN')
next()
})
app.use('/tools/web_order_box', express.static(path.join(process.cwd(), 'public', 'tools', 'web_order_box')))
app.get('/api/zen_box/playlists', (req, res) => {
try {
const zenDir = path.join(process.cwd(), 'public', 'tools', 'zen_box')
const zenDir = path.join(process.cwd(), 'public', 'tools', 'web_zen_box')
const files = fs.existsSync(zenDir) ? fs.readdirSync(zenDir, { withFileTypes: true }) : []
const playlists = files
.filter(entry => entry && entry.isFile && entry.isFile())