feat: 新增多项功能并添加新工具
- 私人剪贴板新增限时直通URL生成功能 - doc_cloud_keeper新增标题复制按钮 - 新增贪蛇出动游戏工具 - 新增OSS中转直链工具 - 添加批量抽帧测试脚本
This commit is contained in:
+359
-1
@@ -473,6 +473,7 @@ const ensurePscLoaded = () => {
|
||||
ensurePscLoaded()
|
||||
|
||||
let privateClipboardRouter = null
|
||||
let privateClipboardShareRouter = null
|
||||
const ensurePrivateClipboardLoaded = () => {
|
||||
let mod = null
|
||||
try { mod = require('./private_clipboard') } catch (e) { try { logJSON('private_clipboard.require.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}; setTimeout(ensurePrivateClipboardLoaded, 2000); return }
|
||||
@@ -484,6 +485,10 @@ const ensurePrivateClipboardLoaded = () => {
|
||||
mod.bindRoutes(app)
|
||||
try { logJSON('private_clipboard.bind_routes.done', { ok: true }, 'private_clipboard') } catch {}
|
||||
}
|
||||
if (mod && mod.createPublicShareRouter) {
|
||||
privateClipboardShareRouter = mod.createPublicShareRouter()
|
||||
try { logJSON('private_clipboard.create_share_router.done', { ok: true }, 'private_clipboard') } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
try { logJSON('private_clipboard.create_router.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
|
||||
}
|
||||
@@ -1636,7 +1641,9 @@ const mapSystemIdToUrl = (systemId) => {
|
||||
'Tools-private_clipboard': '/tools/private_clipboard',
|
||||
'Tools-style_check': '/tools/style_check/index.html',
|
||||
'Tools-short_link': '/tools/short_link',
|
||||
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder'
|
||||
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder',
|
||||
'Tools-greedy_snake': '/tools/greedy_snake',
|
||||
'Tools-oss_browser_link': '/tools/oss_browser_link'
|
||||
}
|
||||
if (m[systemId]) return m[systemId]
|
||||
if (systemId.startsWith('Tools-')) {
|
||||
@@ -2585,6 +2592,12 @@ app.use('/uploads/private_clipboard', (req, res, next) => {
|
||||
if (hasPrivateClipboardAuth(req)) return next()
|
||||
return res.status(401).send('未授权')
|
||||
})
|
||||
// 私人剪贴板「限时直通URL」公开访问入口:无任何鉴权,供第三方浏览器/程序直连;
|
||||
// 安全性由 token 随机性(32位hex)与 3 天有效期控制,过期返回失效提示页。
|
||||
app.use('/share', (req, res, next) => {
|
||||
if (privateClipboardShareRouter) return privateClipboardShareRouter(req, res, next)
|
||||
return res.status(503).json({ ok: false, error: 'service_not_ready' })
|
||||
})
|
||||
app.use('/api/private_clipboard', (req, res, next) => {
|
||||
// 白名单:登录和登出接口不需要鉴权
|
||||
if (req.path === '/auth/login' || req.path === '/auth/logout') return next()
|
||||
@@ -6585,6 +6598,351 @@ app.post('/api/zen_box/config', (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 贪蛇出动 greedy_snake:鉴权基础函数 + 路由守卫 + 轻量战绩存储
|
||||
// 必须注册在下方全局 express.static 之前,否则静态资源会绕过鉴权中间件
|
||||
// ============================================================
|
||||
const GREEDY_SNAKE_AUTH_COOKIE = 'greedy_snake_gate'
|
||||
const readGreedySnakeAuthConfig = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'public', 'tools', 'greedy_snake', 'auth_config.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
const readGreedySnakeJwk = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'config', 'greedy_snake.jwk.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return { kty: 'RSA', n: '', e: '' }
|
||||
}
|
||||
const getGreedySnakeCookieName = () => {
|
||||
try { const n = String(readGreedySnakeAuthConfig().cookieName || ''); if (n) return n } catch {}
|
||||
return GREEDY_SNAKE_AUTH_COOKIE
|
||||
}
|
||||
const getGreedySnakeMaxAge = () => {
|
||||
try { const days = Number(readGreedySnakeAuthConfig().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 getGreedySnakeGuidConfig = () => {
|
||||
try {
|
||||
const cfg = readGreedySnakeAuthConfig()
|
||||
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 hasValidGreedySnakeGuid = req => {
|
||||
try {
|
||||
const cfg = readGreedySnakeAuthConfig()
|
||||
if (cfg.enable_auth === false) return false
|
||||
const { key, value } = getGreedySnakeGuidConfig()
|
||||
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 isGreedySnakeForceGuid = () => {
|
||||
try {
|
||||
const cfg = readGreedySnakeAuthConfig()
|
||||
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 hasGreedySnakeAuth = req => {
|
||||
try {
|
||||
const config = readGreedySnakeAuthConfig()
|
||||
if (config.enable_auth === false) return true
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
return cookies[getGreedySnakeCookieName()] === '1'
|
||||
} catch { return false }
|
||||
}
|
||||
app.get('/tools/greedy_snake', (req, res, next) => {
|
||||
setNoCache(res)
|
||||
try {
|
||||
const forceGuid = isGreedySnakeForceGuid()
|
||||
if (hasGreedySnakeAuth(req)) return next()
|
||||
const guidKey = getGreedySnakeGuidConfig().key
|
||||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||
if (hasValidGreedySnakeGuid(req)) {
|
||||
const maxAge = getGreedySnakeMaxAge()
|
||||
const cookieName = getGreedySnakeCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/greedy_snake/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(readGreedySnakeJwk()))
|
||||
if (!payload) return res.status(401).send('未授权')
|
||||
{
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-greedy_snake')) return res.status(401).send('未授权')
|
||||
}
|
||||
const maxAge = getGreedySnakeMaxAge()
|
||||
const cookieName = getGreedySnakeCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/greedy_snake/index.html')
|
||||
} catch (e) { return res.status(401).send('未授权') }
|
||||
})
|
||||
app.use('/tools/greedy_snake', (req, res, next) => {
|
||||
const forceGuid = isGreedySnakeForceGuid()
|
||||
if (hasGreedySnakeAuth(req)) return next()
|
||||
const guidKey = getGreedySnakeGuidConfig().key
|
||||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||||
if (isHandshake && hasValidGreedySnakeGuid(req)) {
|
||||
const maxAge = getGreedySnakeMaxAge()
|
||||
const cookieName = getGreedySnakeCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/greedy_snake/index.html')
|
||||
}
|
||||
if (isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)) return res.status(401).send('未授权')
|
||||
if (isHandshake && String(req.query.token || '')) {
|
||||
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readGreedySnakeJwk()))
|
||||
if (payload) {
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-greedy_snake')) return res.status(401).send('未授权')
|
||||
const maxAge = getGreedySnakeMaxAge()
|
||||
const cookieName = getGreedySnakeCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/greedy_snake/index.html')
|
||||
}
|
||||
}
|
||||
if (isHandshake && forceGuid) return res.status(401).send('未授权')
|
||||
return res.status(401).send('未授权')
|
||||
})
|
||||
// 贪蛇出动:战绩/配置 API(默认受 gate 保护)
|
||||
const GREEDY_SNAKE_DATA_FILE = path.join(process.cwd(), 'data', 'greedy_snake.json')
|
||||
const readGreedySnakeData = () => {
|
||||
try {
|
||||
if (fs.existsSync(GREEDY_SNAKE_DATA_FILE)) return JSON.parse(fs.readFileSync(GREEDY_SNAKE_DATA_FILE, 'utf-8'))
|
||||
} catch {}
|
||||
return { gems: 0, totalCoins: 0, best: 0, todayBest: null, _init: false }
|
||||
}
|
||||
const writeGreedySnakeData = data => {
|
||||
try {
|
||||
const dir = path.dirname(GREEDY_SNAKE_DATA_FILE)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(GREEDY_SNAKE_DATA_FILE, JSON.stringify(data, null, 2), 'utf-8')
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
const greedySnakeTodayBJ = () => {
|
||||
try { return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }) } catch { return new Date().toISOString().slice(0, 10) }
|
||||
}
|
||||
app.use('/api/greedy_snake', (req, res, next) => {
|
||||
if (hasGreedySnakeAuth(req)) return next()
|
||||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
})
|
||||
app.get('/api/greedy_snake/config', (req, res) => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'config', 'greedy_snake.json')
|
||||
const cfg = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : {}
|
||||
return res.json({ ok: true, data: cfg })
|
||||
} catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||||
})
|
||||
app.get('/api/greedy_snake/stats', (req, res) => {
|
||||
try {
|
||||
const d = readGreedySnakeData()
|
||||
if (!d._init) {
|
||||
// 首次访问:写入初始钻石
|
||||
d.gems = d.gems || 100
|
||||
d._init = true
|
||||
writeGreedySnakeData(d)
|
||||
}
|
||||
const today = greedySnakeTodayBJ()
|
||||
const todayBest = d.todayBest && d.todayBest.date === today ? d.todayBest : null
|
||||
return res.json({ ok: true, data: { gems: d.gems || 0, totalCoins: d.totalCoins || 0, best: d.best || 0, todayBest } })
|
||||
} catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||||
})
|
||||
app.post('/api/greedy_snake/stats', (req, res) => {
|
||||
try {
|
||||
const b = req.body || {}
|
||||
const d = readGreedySnakeData()
|
||||
if (typeof b.gems === 'number' && Number.isFinite(b.gems)) d.gems = Math.max(0, Math.floor(b.gems))
|
||||
if (typeof b.totalCoins === 'number' && Number.isFinite(b.totalCoins)) d.totalCoins = Math.max(d.totalCoins || 0, Math.floor(b.totalCoins))
|
||||
const score = Number(b.score)
|
||||
if (Number.isFinite(score) && score > 0) {
|
||||
d.best = Math.max(d.best || 0, Math.floor(score))
|
||||
const today = greedySnakeTodayBJ()
|
||||
if (!d.todayBest || d.todayBest.date !== today || (d.todayBest.score || 0) < score) {
|
||||
d.todayBest = { date: today, score: Math.floor(score), name: String(b.name || '').slice(0, 20) || '贪蛇小将' }
|
||||
}
|
||||
}
|
||||
writeGreedySnakeData(d)
|
||||
const today = greedySnakeTodayBJ()
|
||||
return res.json({ ok: true, data: { gems: d.gems || 0, totalCoins: d.totalCoins || 0, best: d.best || 0, todayBest: d.todayBest && d.todayBest.date === today ? d.todayBest : null } })
|
||||
} catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// OSS中转直链 oss_browser_link:鉴权基础函数 + 路由守卫 + 受保护下载
|
||||
// 必须注册在下方全局 express.static 之前,否则静态资源会绕过鉴权中间件
|
||||
// ============================================================
|
||||
const OSS_BROWSER_LINK_AUTH_COOKIE = 'oss_browser_link_gate'
|
||||
const readOssBrowserLinkAuthConfig = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'public', 'tools', 'oss_browser_link', 'auth_config.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
const readOssBrowserLinkJwk = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'config', 'oss_browser_link.jwk.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return { kty: 'RSA', n: '', e: '' }
|
||||
}
|
||||
const getOssBrowserLinkCookieName = () => {
|
||||
try { const n = String(readOssBrowserLinkAuthConfig().cookieName || ''); if (n) return n } catch {}
|
||||
return OSS_BROWSER_LINK_AUTH_COOKIE
|
||||
}
|
||||
const getOssBrowserLinkMaxAge = () => {
|
||||
try { const days = Number(readOssBrowserLinkAuthConfig().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 getOssBrowserLinkGuidConfig = () => {
|
||||
try {
|
||||
const cfg = readOssBrowserLinkAuthConfig()
|
||||
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 hasValidOssBrowserLinkGuid = req => {
|
||||
try {
|
||||
const cfg = readOssBrowserLinkAuthConfig()
|
||||
if (cfg.enable_auth === false) return false
|
||||
const { key, value } = getOssBrowserLinkGuidConfig()
|
||||
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 isOssBrowserLinkForceGuid = () => {
|
||||
try {
|
||||
const cfg = readOssBrowserLinkAuthConfig()
|
||||
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 hasOssBrowserLinkAuth = req => {
|
||||
try {
|
||||
const config = readOssBrowserLinkAuthConfig()
|
||||
if (config.enable_auth === false) return true
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
return cookies[getOssBrowserLinkCookieName()] === '1'
|
||||
} catch { return false }
|
||||
}
|
||||
app.get('/tools/oss_browser_link', (req, res, next) => {
|
||||
setNoCache(res)
|
||||
try {
|
||||
const forceGuid = isOssBrowserLinkForceGuid()
|
||||
if (hasOssBrowserLinkAuth(req)) return next()
|
||||
const guidKey = getOssBrowserLinkGuidConfig().key
|
||||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||
if (hasValidOssBrowserLinkGuid(req)) {
|
||||
const maxAge = getOssBrowserLinkMaxAge()
|
||||
const cookieName = getOssBrowserLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/oss_browser_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(readOssBrowserLinkJwk()))
|
||||
if (!payload) return res.status(401).send('未授权')
|
||||
{
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-oss_browser_link')) return res.status(401).send('未授权')
|
||||
}
|
||||
const maxAge = getOssBrowserLinkMaxAge()
|
||||
const cookieName = getOssBrowserLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/oss_browser_link/index.html')
|
||||
} catch (e) { return res.status(401).send('未授权') }
|
||||
})
|
||||
app.use('/tools/oss_browser_link', (req, res, next) => {
|
||||
const forceGuid = isOssBrowserLinkForceGuid()
|
||||
if (hasOssBrowserLinkAuth(req)) return next()
|
||||
const guidKey = getOssBrowserLinkGuidConfig().key
|
||||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||||
if (isHandshake && hasValidOssBrowserLinkGuid(req)) {
|
||||
const maxAge = getOssBrowserLinkMaxAge()
|
||||
const cookieName = getOssBrowserLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/oss_browser_link/index.html')
|
||||
}
|
||||
if (isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)) return res.status(401).send('未授权')
|
||||
if (isHandshake && String(req.query.token || '')) {
|
||||
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readOssBrowserLinkJwk()))
|
||||
if (payload) {
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-oss_browser_link')) return res.status(401).send('未授权')
|
||||
const maxAge = getOssBrowserLinkMaxAge()
|
||||
const cookieName = getOssBrowserLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/oss_browser_link/index.html')
|
||||
}
|
||||
}
|
||||
if (isHandshake && forceGuid) return res.status(401).send('未授权')
|
||||
return res.status(401).send('未授权')
|
||||
})
|
||||
// OSS中转直链:受保护的 zip 下载(必须在全局 express.static 之前注册)
|
||||
const OSS_BROWSER_LINK_ZIP_NAME = 'oss-browser-win32-x64.zip'
|
||||
app.get(`/download_files/${OSS_BROWSER_LINK_ZIP_NAME}`, (req, res) => {
|
||||
if (!hasOssBrowserLinkAuth(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
const p = path.join(process.cwd(), 'public', 'download_files', OSS_BROWSER_LINK_ZIP_NAME)
|
||||
if (!fs.existsSync(p)) return res.status(404).json({ ok: false, error: '文件尚未中转,暂不可下载' })
|
||||
res.download(p, OSS_BROWSER_LINK_ZIP_NAME)
|
||||
})
|
||||
// OSS中转直链:API(默认受 gate 保护,auth/hint 白名单放行)
|
||||
app.get('/api/oss_browser_link/auth/hint', (req, res) => {
|
||||
res.json({ ok: true, message: '请通过首页九宫格「OSS中转直链」进入,或使用正确的访问链接' })
|
||||
})
|
||||
app.use('/api/oss_browser_link', (req, res, next) => {
|
||||
if (hasOssBrowserLinkAuth(req)) return next()
|
||||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
})
|
||||
app.get('/api/oss_browser_link/config', (req, res) => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'config', 'oss_browser_link.json')
|
||||
const cfg = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : {}
|
||||
return res.json({ ok: true, data: { ui_mode: cfg.ui_mode || 'debug', public_url: cfg.public_url || '', launch_protocol: cfg.launch_protocol || 'ossbrowser' } })
|
||||
} catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||||
})
|
||||
app.get('/api/oss_browser_link/info', (req, res) => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'public', 'download_files', OSS_BROWSER_LINK_ZIP_NAME)
|
||||
if (!fs.existsSync(p)) return res.json({ ok: true, data: { available: false, name: OSS_BROWSER_LINK_ZIP_NAME, size: 0 } })
|
||||
const st = fs.statSync(p)
|
||||
const mtime = new Date(st.mtime).toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' })
|
||||
return res.json({ ok: true, data: { available: true, name: OSS_BROWSER_LINK_ZIP_NAME, size: st.size, updated_at: mtime } })
|
||||
} catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||||
})
|
||||
|
||||
// Static: keep weekly tool assets always fresh
|
||||
app.use(express.static(path.join(process.cwd(), 'public'), {
|
||||
setHeaders: (res, filePath) => {
|
||||
|
||||
@@ -8,6 +8,33 @@ 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')
|
||||
|
||||
// 限时直通URL:有效期 3 天;分享链接面向第三方访问,默认走公网 HTTPS 入口
|
||||
const SHARE_TTL_MS = 3 * 24 * 3600 * 1000
|
||||
const getShareBaseUrl = () => String(process.env.PUBLIC_BASE_URL || '').trim() || 'https://traesite.umersoft.com:8975'
|
||||
const SHARE_EXPIRED_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>链接已失效</title>
|
||||
</head>
|
||||
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#10162e;font-family:'PingFang SC','Microsoft YaHei',system-ui,sans-serif;">
|
||||
<div style="text-align:center;padding:40px 24px;">
|
||||
<div style="font-size:48px;line-height:1;margin-bottom:16px;">⏳</div>
|
||||
<div style="color:#e8ecff;font-size:18px;font-weight:600;line-height:1.8;">你的链接期限已经大于三天。无法访问。</div>
|
||||
<div style="color:#8f9ac8;font-size:13px;margin-top:12px;">请联系分享者重新生成限时直通URL。</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
const renderShareExpired = (res) => {
|
||||
try {
|
||||
res.status(410)
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.send(SHARE_EXPIRED_HTML)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
|
||||
@@ -40,8 +67,15 @@ const ensureSchema = () => {
|
||||
sort_order INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS clipboard_share_links (
|
||||
token TEXT PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clipboard_items_created_at ON clipboard_items(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_clipboard_folders_sort_order ON clipboard_folders(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clipboard_share_links_item_id ON clipboard_share_links(item_id);
|
||||
`)
|
||||
const cols = db.prepare('PRAGMA table_info(clipboard_items)').all()
|
||||
const hasGroup = cols.some(c => c.name === 'group_id')
|
||||
@@ -426,6 +460,33 @@ const createRouter = () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 限时直通URL:生成 3 天有效的无鉴权直链 =====
|
||||
router.post('/share/create', (req, res) => {
|
||||
try {
|
||||
const id = Number.parseInt(String((req.body && req.body.id) || ''), 10)
|
||||
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
|
||||
const database = getDb()
|
||||
// 顺手清理已过期的旧链接,控制表体积
|
||||
database.prepare('DELETE FROM clipboard_share_links WHERE expires_at < ?').run(Date.now())
|
||||
const row = database.prepare('SELECT id, file_name, file_path FROM clipboard_items WHERE id = ?').get(id)
|
||||
if (!row || !row.file_path) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||||
const abs = path.join(process.cwd(), String(row.file_path || '').replace(/^\/+/, ''))
|
||||
if (!fs.existsSync(abs)) return res.status(404).json({ ok: false, error: 'file_not_found' })
|
||||
// 同一内容只保留一条有效直链:重新生成即作废旧链接,防止链接被反复外传
|
||||
database.prepare('DELETE FROM clipboard_share_links WHERE item_id = ?').run(id)
|
||||
const token = crypto.randomBytes(16).toString('hex')
|
||||
const now = Date.now()
|
||||
const expiresAt = now + SHARE_TTL_MS
|
||||
database.prepare('INSERT INTO clipboard_share_links (token, item_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, id, now, expiresAt)
|
||||
const url = `${getShareBaseUrl()}/share/clip/${token}`
|
||||
let expiresText = ''
|
||||
try { expiresText = new Date(expiresAt).toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }) } catch { expiresText = new Date(expiresAt).toLocaleString() }
|
||||
return res.json({ ok: true, url, expires_at: expiresAt, expires_at_text: expiresText, ttl_days: 3 })
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/list', (req, res) => {
|
||||
try {
|
||||
const pageSizeRaw = String((req.query && req.query.page_size) || '50').trim().toLowerCase()
|
||||
@@ -680,10 +741,14 @@ const createRouter = () => {
|
||||
const id = parseInt(req.params.id, 10)
|
||||
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
|
||||
if (id < 0) {
|
||||
const rows = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE group_id = ?').all(id)
|
||||
const rows = getDb().prepare('SELECT id, file_path, favorite_folder_code FROM clipboard_items WHERE group_id = ?').all(id)
|
||||
if (!rows.length) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||||
const hasFavorite = rows.some(row => row && row.favorite_folder_code)
|
||||
if (hasFavorite) return res.status(409).json({ ok: false, error: 'item_favorited' })
|
||||
const ids = rows.map(row => row && row.id).filter(v => Number.isFinite(v))
|
||||
if (ids.length) {
|
||||
getDb().prepare(`DELETE FROM clipboard_share_links WHERE item_id IN (${ids.map(() => '?').join(',')})`).run(...ids)
|
||||
}
|
||||
getDb().prepare('DELETE FROM clipboard_items WHERE group_id = ?').run(id)
|
||||
rows.forEach(row => {
|
||||
if (row && row.file_path) {
|
||||
@@ -696,6 +761,7 @@ const createRouter = () => {
|
||||
const row = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE id = ?').get(id)
|
||||
if (!row) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||||
if (row.favorite_folder_code) return res.status(409).json({ ok: false, error: 'item_favorited' })
|
||||
getDb().prepare('DELETE FROM clipboard_share_links WHERE item_id = ?').run(id)
|
||||
getDb().prepare('DELETE FROM clipboard_items WHERE id = ?').run(id)
|
||||
if (row && row.file_path) {
|
||||
const rel = String(row.file_path || '').replace(/^\/+/, '')
|
||||
@@ -711,4 +777,38 @@ const createRouter = () => {
|
||||
return router
|
||||
}
|
||||
|
||||
module.exports = { createRouter, hasBusinessAuth: hasAuth }
|
||||
// ===== 限时直通URL 的公开访问路由(无任何鉴权,仅供第三方浏览器/程序直连) =====
|
||||
// 在 index.js 中必须挂载在 /api/private_clipboard 鉴权守卫之外的独立公开路径(/share)
|
||||
const createPublicShareRouter = () => {
|
||||
const router = express.Router()
|
||||
router.get('/clip/:token', (req, res) => {
|
||||
try {
|
||||
const token = String(req.params.token || '')
|
||||
if (!/^[a-f0-9]{32}$/.test(token)) return renderShareExpired(res)
|
||||
const row = getDb().prepare(`
|
||||
SELECT s.expires_at AS expires_at, i.type AS type, i.file_name AS file_name, i.file_mime AS file_mime, i.file_path AS file_path
|
||||
FROM clipboard_share_links s
|
||||
JOIN clipboard_items i ON i.id = s.item_id
|
||||
WHERE s.token = ?
|
||||
`).get(token)
|
||||
if (!row || !row.file_path) return renderShareExpired(res)
|
||||
if (Number(row.expires_at || 0) < Date.now()) return renderShareExpired(res)
|
||||
const abs = path.join(process.cwd(), String(row.file_path || '').replace(/^\/+/, ''))
|
||||
if (!fs.existsSync(abs)) return renderShareExpired(res)
|
||||
const mime = String(row.file_mime || '').trim() || 'application/octet-stream'
|
||||
res.setHeader('Content-Type', mime)
|
||||
res.setHeader('Cache-Control', 'private, no-store, max-age=0')
|
||||
const safeName = normalizeName(row.file_name || 'file')
|
||||
const isImage = String(row.type || '').toLowerCase() === 'image' || mime.toLowerCase().startsWith('image/')
|
||||
const dispositionType = isImage ? 'inline' : 'attachment'
|
||||
const fallbackName = safeName.replace(/[^\x20-\x7E]/g, '_')
|
||||
res.setHeader('Content-Disposition', `${dispositionType}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`)
|
||||
return res.sendFile(abs)
|
||||
} catch (e) {
|
||||
return renderShareExpired(res)
|
||||
}
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
module.exports = { createRouter, createPublicShareRouter, hasBusinessAuth: hasAuth }
|
||||
|
||||
Reference in New Issue
Block a user