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