feat: 新增短链接工具,优化日历提醒功能

1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持
2. 新增移动端日历提醒工具与鉴权配置
3. 优化日历事件编辑与展示:
   - 添加事件循环重复设置
   - 新增全天事件支持
   - 时间选择器自动对齐15分钟间隔
   - 保存状态加载动画
   - 事件卡片显示重复规则
4. 更新API基础地址与路由配置
5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
yangxiangyuan
2026-07-28 16:23:37 +08:00
parent 212dbc4e1d
commit 154b79c8f1
18 changed files with 3508 additions and 23 deletions
+146 -1
View File
@@ -78,6 +78,7 @@ const plantHome = require('./plant_home')
const globalNews = require('./global_news')
const boxBackup = require('./box_backup')
const yuanzhupai = require('./yuanzhupai')
const shortLink = require('./short_link')
const dataGateway = require('./data_gateway')
const ossFileCabinet = require('./oss_file_cabinet')
yuanzhupai.initDb()
@@ -1579,7 +1580,9 @@ const mapSystemIdToUrl = (systemId) => {
'Tools-plant_home': '/tools/plant_home',
'Tools-apple_watch': '/tools/apple_watch',
'Tools-private_clipboard': '/tools/private_clipboard',
'Tools-style_check': '/tools/style_check/index.html'
'Tools-style_check': '/tools/style_check/index.html',
'Tools-short_link': '/tools/short_link',
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder'
}
if (m[systemId]) return m[systemId]
if (systemId.startsWith('Tools-')) {
@@ -5721,6 +5724,133 @@ app.use('/tools/yuanzhupai', (req, res, next) => {
return res.status(401).send('未授权')
})
// Short Link: 鉴权基础函数 + 路由守卫(必须在 express.static 之前)
const SHORT_LINK_AUTH_COOKIE = 'short_link_gate'
const readShortLinkAuthConfig = () => {
try {
const p = path.join(process.cwd(), 'public', 'tools', 'short_link', 'auth_config.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return {}
}
const readShortLinkJwk = () => {
try {
const p = path.join(process.cwd(), 'config', 'short_link.jwk.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return { kty: 'RSA', n: '', e: '' }
}
const getShortLinkCookieName = () => {
try { const n = String(readShortLinkAuthConfig().cookieName || ''); if (n) return n } catch {}
return SHORT_LINK_AUTH_COOKIE
}
const getShortLinkMaxAge = () => {
try { const days = Number(readShortLinkAuthConfig().max_age_days || 0); if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000 } catch {}
return 30 * 24 * 3600 * 1000
}
const getShortLinkGuidConfig = () => {
try {
const cfg = readShortLinkAuthConfig()
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
return { key: keyRaw || 'guid', value: valueRaw }
} catch { return { key: 'guid', value: '' } }
}
const hasValidShortLinkGuid = req => {
try {
const cfg = readShortLinkAuthConfig()
if (cfg.enable_auth === false) return false
const { key, value } = getShortLinkGuidConfig()
if (!value) return false
const q = req && req.query ? req.query : {}
const raw = q[key]
if (raw === undefined || raw === null) return false
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
return incoming === value
} catch { return false }
}
const isShortLinkForceGuid = () => {
try {
const cfg = readShortLinkAuthConfig()
if (cfg.enable_auth === false) return false
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
return false
} catch { return false }
}
const hasShortLinkAuth = req => {
try {
const config = readShortLinkAuthConfig()
if (config.enable_auth === false) return true
const cookies = parseCookie(req.headers.cookie || '')
return cookies[getShortLinkCookieName()] === '1'
} catch { return false }
}
// 第1层:精确入口守卫
app.get('/tools/short_link', (req, res, next) => {
setNoCache(res)
try {
const forceGuid = isShortLinkForceGuid()
if (hasShortLinkAuth(req)) return next()
const guidKey = getShortLinkGuidConfig().key
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (hasValidShortLinkGuid(req)) {
const maxAge = getShortLinkMaxAge()
const cookieName = getShortLinkCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/short_link/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
const token = String(req.query.token || '')
if (!token) {
if (forceGuid) return res.status(401).send('未授权')
return next()
}
const payload = verifyJwtWithKeys(token, jwkKeys(readShortLinkJwk()))
if (!payload) return res.status(401).send('未授权')
{
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-short_link')) return res.status(401).send('未授权')
}
const maxAge = getShortLinkMaxAge()
const cookieName = getShortLinkCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/short_link/index.html')
} catch (e) { return res.status(401).send('未授权') }
})
// 第2层:子路径守卫(拦截静态资源绕过)
app.use('/tools/short_link', (req, res, next) => {
const forceGuid = isShortLinkForceGuid()
if (hasShortLinkAuth(req)) return next()
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
const guidKey = getShortLinkGuidConfig().key
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (isHandshake && hasValidShortLinkGuid(req)) {
const maxAge = getShortLinkMaxAge()
const cookieName = getShortLinkCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/short_link/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
if (isHandshake && String(req.query.token || '')) {
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readShortLinkJwk()))
if (payload) {
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-short_link')) return res.status(401).send('未授权')
const maxAge = getShortLinkMaxAge()
const cookieName = getShortLinkCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/short_link/index.html')
}
}
if (isHandshake && forceGuid) return res.status(401).send('未授权')
return res.status(401).send('未授权')
})
const BOX_BACKUP_AUTH_COOKIE = 'box_disaster_sentinel_gate'
const readBoxBackupAuthConfig = () => {
try {
@@ -6395,6 +6525,21 @@ globalNews.bindRoutes(app)
dataGateway.bindRoutes(app)
ossFileCabinet.bindRoutes(app)
// 第3层:Short Link API 鉴权(必须在 bindRoutes 之前注册)
app.use('/api/short_link', (req, res, next) => {
if (req.path === '/auth/hint') return next()
if (hasShortLinkAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.get('/api/short_link/auth/hint', (req, res) => {
setNoCache(res)
res.json({ ok: true, message: '请通过首页九宫格点击「短链接」进入,或使用正确的访问链接' })
})
// Short Link 业务路由(/s/:code 公开跳转 + /api/short_link/* 管理接口)
shortLink.bindRoutes(app)
const marketsCfgAtBoot = getMarketsCfg()
const currentPort = Number(process.env.PORT || '8976') || 8976
const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) || 8977
+300
View File
@@ -0,0 +1,300 @@
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
// 数据目录
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const db = new Database(path.join(dataDir, 'short_link.db'))
db.pragma('journal_mode = WAL')
// 建表
db.exec(`
CREATE TABLE IF NOT EXISTS short_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
target_url TEXT NOT NULL,
title TEXT DEFAULT '',
source_type TEXT DEFAULT 'manual',
source_ref TEXT DEFAULT '',
created_at TEXT NOT NULL,
expires_at TEXT,
max_clicks INTEGER,
click_count INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_by TEXT DEFAULT 'admin'
);
CREATE TABLE IF NOT EXISTS short_link_clicks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
clicked_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
referer TEXT
);
CREATE INDEX IF NOT EXISTS idx_sl_clicks_code ON short_link_clicks(code);
`)
// 配置读取
const readConfig = () => {
try {
const p = path.join(process.cwd(), 'config', 'short_link.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return {}
}
// 短码生成(去除易混淆字符 0O1lI)
const SAFE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
const generateCode = (len = 6) => {
const bytes = crypto.randomBytes(len)
let code = ''
for (let i = 0; i < len; i++) {
code += SAFE_CHARS[bytes[i] % SAFE_CHARS.length]
}
return code
}
const generateUniqueCode = (len = 6) => {
for (let i = 0; i < 3; i++) {
const code = generateCode(len)
const exists = db.prepare('SELECT 1 FROM short_links WHERE code = ?').get(code)
if (!exists) return code
}
return generateCode(len + 2)
}
// 北京时间
const nowBJ = () => {
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(new Date()).replace(/\//g, '-').replace(/\u200E/g, '')
} catch {
return new Date().toISOString()
}
}
// 解析短码 → 目标 URL
const resolveCode = (code) => {
// 1. 先查 tool 别名(配置级)
const cfg = readConfig()
const aliases = cfg.tool_aliases || {}
if (aliases[code]) {
return { target: aliases[code], type: 'tool_alias', title: code }
}
// 2. 查数据库
const row = db.prepare('SELECT * FROM short_links WHERE code = ? AND is_active = 1').get(code)
if (!row) return null
if (row.expires_at && row.expires_at < nowBJ()) return { expired: true }
if (row.max_clicks && row.click_count >= row.max_clicks) return { exhausted: true }
return { target: row.target_url, type: row.source_type, title: row.title, row }
}
// 记录点击
const recordClick = (code, ip, userAgent, referer) => {
try {
db.prepare('INSERT INTO short_link_clicks (code, clicked_at, ip, user_agent, referer) VALUES (?, ?, ?, ?, ?)')
.run(code, nowBJ(), ip || '', userAgent || '', referer || '')
db.prepare('UPDATE short_links SET click_count = click_count + 1 WHERE code = ?').run(code)
} catch {}
}
// CRUD
const createLink = ({ target_url, title, code, source_type, source_ref, expires_at, max_clicks }) => {
const finalCode = code || generateUniqueCode()
const exists = db.prepare('SELECT 1 FROM short_links WHERE code = ?').get(finalCode)
if (exists) throw new Error(`短码 "${finalCode}" 已存在`)
const cfg = readConfig()
const aliases = cfg.tool_aliases || {}
if (aliases[finalCode]) throw new Error(`短码 "${finalCode}" 与工具别名冲突`)
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, source_ref, created_at, expires_at, max_clicks) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(finalCode, target_url, title || '', source_type || 'manual', source_ref || '', nowBJ(), expires_at || null, max_clicks || null)
return db.prepare('SELECT * FROM short_links WHERE code = ?').get(finalCode)
}
const listLinks = (page = 1, pageSize = 50, keyword = '') => {
const offset = (page - 1) * pageSize
let where = 'WHERE 1=1'
const params = []
if (keyword) {
where += ' AND (code LIKE ? OR target_url LIKE ? OR title LIKE ?)'
const kw = `%${keyword}%`
params.push(kw, kw, kw)
}
const total = db.prepare(`SELECT COUNT(*) as cnt FROM short_links ${where}`).get(...params).cnt
const rows = db.prepare(`SELECT * FROM short_links ${where} ORDER BY id DESC LIMIT ? OFFSET ?`).all(...params, pageSize, offset)
return { total, rows, page, pageSize }
}
const updateLink = (code, { target_url, title, expires_at, max_clicks, is_active }) => {
const existing = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
if (!existing) throw new Error('短链接不存在')
const fields = []
const params = []
if (target_url !== undefined) { fields.push('target_url = ?'); params.push(target_url) }
if (title !== undefined) { fields.push('title = ?'); params.push(title) }
if (expires_at !== undefined) { fields.push('expires_at = ?'); params.push(expires_at || null) }
if (max_clicks !== undefined) { fields.push('max_clicks = ?'); params.push(max_clicks || null) }
if (is_active !== undefined) { fields.push('is_active = ?'); params.push(is_active ? 1 : 0) }
if (fields.length === 0) return existing
params.push(code)
db.prepare(`UPDATE short_links SET ${fields.join(', ')} WHERE code = ?`).run(...params)
return db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
}
const deleteLink = (code) => {
const existing = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
if (!existing) throw new Error('短链接不存在')
db.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(code)
db.prepare('DELETE FROM short_links WHERE code = ?').run(code)
return existing
}
const getStats = (code, limit = 100) => {
const link = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
if (!link) throw new Error('短链接不存在')
const clicks = db.prepare('SELECT * FROM short_link_clicks WHERE code = ? ORDER BY id DESC LIMIT ?').all(code, limit)
return { link, clicks }
}
const batchCreate = (items) => {
const results = []
for (const item of items) {
try {
results.push({ ok: true, data: createLink(item) })
} catch (e) {
results.push({ ok: false, error: String(e.message || e), target_url: item.target_url })
}
}
return results
}
const bindRoutes = (app) => {
// ===== 公开跳转路由(无需鉴权)=====
app.get('/l/:code', (req, res) => {
try {
const code = String(req.params.code || '').trim()
if (!code) return res.status(400).send('Bad Request')
const result = resolveCode(code)
if (!result) return res.status(404).send('链接不存在或已失效')
if (result.expired) return res.status(410).send('链接已过期')
if (result.exhausted) return res.status(410).send('链接已达最大访问次数')
if (result.type !== 'tool_alias') {
recordClick(code, req.ip, req.headers['user-agent'], req.headers['referer'])
}
const target = result.target
if (target.startsWith('/')) {
const proto = req.headers['x-forwarded-proto'] || req.protocol
const host = req.headers['host'] || 'localhost:8976'
return res.redirect(302, `${proto}://${host}${target}`)
}
return res.redirect(302, target)
} catch (e) {
res.status(500).send('Server Error')
}
})
// ===== 管理 API(鉴权由 index.js 的 app.use('/api/short_link') 处理)=====
// 获取配置
app.get('/api/short_link/config', (req, res) => {
try {
const cfg = readConfig()
res.json({
ok: true,
domain: cfg.domain || '',
tool_aliases: cfg.tool_aliases || {},
ui_mode: cfg.ui_mode || 'debug'
})
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// 列表
app.get('/api/short_link/list', (req, res) => {
try {
const page = Math.max(1, Number(req.query.page) || 1)
const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 50))
const keyword = String(req.query.keyword || '').trim()
const data = listLinks(page, pageSize, keyword)
res.json({ ok: true, ...data })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// 创建
app.post('/api/short_link/create', (req, res) => {
try {
const body = req.body || {}
const target_url = String(body.target_url || '').trim()
if (!target_url) return res.status(400).json({ ok: false, error: '目标链接不能为空' })
const data = createLink({
target_url,
title: String(body.title || '').trim(),
code: String(body.code || '').trim() || undefined,
source_type: String(body.source_type || 'manual').trim(),
source_ref: String(body.source_ref || '').trim(),
expires_at: body.expires_at || null,
max_clicks: body.max_clicks ? Number(body.max_clicks) : null
})
res.json({ ok: true, data })
} catch (e) {
res.status(400).json({ ok: false, error: String(e.message || e) })
}
})
// 编辑
app.put('/api/short_link/:code', (req, res) => {
try {
const code = String(req.params.code || '').trim()
const data = updateLink(code, req.body || {})
res.json({ ok: true, data })
} catch (e) {
res.status(400).json({ ok: false, error: String(e.message || e) })
}
})
// 删除
app.delete('/api/short_link/:code', (req, res) => {
try {
const code = String(req.params.code || '').trim()
const data = deleteLink(code)
res.json({ ok: true, data })
} catch (e) {
res.status(400).json({ ok: false, error: String(e.message || e) })
}
})
// 统计
app.get('/api/short_link/stats/:code', (req, res) => {
try {
const code = String(req.params.code || '').trim()
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100))
const data = getStats(code, limit)
res.json({ ok: true, ...data })
} catch (e) {
res.status(400).json({ ok: false, error: String(e.message || e) })
}
})
// 批量创建
app.post('/api/short_link/batch', (req, res) => {
try {
const items = req.body && req.body.items
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ ok: false, error: '请提供 items 数组' })
if (items.length > 100) return res.status(400).json({ ok: false, error: '单次最多 100 条' })
const results = batchCreate(items)
res.json({ ok: true, results })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes, readConfig }