f362c84405
add a comprehensive gitignore file to exclude unnecessary files like dependencies, runtime data, uploads, local configs and debug temporary files
5944 lines
249 KiB
JavaScript
5944 lines
249 KiB
JavaScript
const express = require('express')
|
||
const cors = require('cors')
|
||
const path = require('path')
|
||
const fs = require('fs')
|
||
const axios = require('axios')
|
||
const Database = require('better-sqlite3')
|
||
const crypto = require('crypto')
|
||
|
||
// ===== 从 ~/Toolbox_local_creds.env.local 加载变量到 process.env =====
|
||
// ★ 规则:所有密码、密钥、Token、用户名等高危内容,禁止硬编码在源码里。
|
||
// 一律放在 ~/Toolbox_local_creds.env.local 中,在此处自动加载到 process.env
|
||
// 各模块通过 process.env.XXX 读取,不要直接 import 或 require 凭证。
|
||
// 优先级:已存在的 process.env > env 文件 > 模块内 '' fallback
|
||
try {
|
||
const os = require('os')
|
||
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
|
||
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
|
||
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
|
||
try {
|
||
if (fs.existsSync(usersRoot)) {
|
||
fs.readdirSync(usersRoot).forEach(name => {
|
||
const dir = path.join(usersRoot, name)
|
||
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
|
||
})
|
||
}
|
||
} catch {}
|
||
for (const p of candidates) {
|
||
if (!fs.existsSync(p)) continue
|
||
const content = fs.readFileSync(p, 'utf-8')
|
||
content.split(/\r?\n/).forEach(line => {
|
||
const trimmed = line.trim()
|
||
if (!trimmed || trimmed.startsWith('#')) return
|
||
const idx = trimmed.indexOf('=')
|
||
if (idx <= 0) return
|
||
const key = trimmed.slice(0, idx).trim()
|
||
const val = trimmed.slice(idx + 1).trim()
|
||
if (key && !Object.prototype.hasOwnProperty.call(process.env, key)) {
|
||
process.env[key] = val
|
||
}
|
||
})
|
||
break
|
||
}
|
||
} catch {}
|
||
// ===== end env loader =====
|
||
|
||
const { fetchPosts, bootstrapCookies } = require('./weiboClient')
|
||
const { getCookiesFromDevTools, screenshotUrl } = require('./devtools')
|
||
const { insertPost, updateRetweeted, queryPostsAny, queryPostsAll, insertKeyword, insertTrackedUser, getSettings, upsertArchive, getArchive, insertSettingHistory } = require('./db')
|
||
const { fetchAll, queryByDate, getConfig: getMarketsCfg, setConfig: setMarketsCfg, startScheduler: startMarketsScheduler, sendTestEmail, maybeNotify } = require('./markets')
|
||
const marketSkills = require('./market_skills/manager')
|
||
const securityCalendar = require('./security_calendar')
|
||
const securityCalendarBridge = require('./security_calendar_calendar_bridge')
|
||
const marketsWeeklyReport = require('./markets_weekly_report')
|
||
const { upsertMarketArchive, getMarketArchive } = require('./db')
|
||
const { getConfig: getWallCfg, setConfig: setWallCfg, listCards: listWallCards, createCard: createWallCard, updateCardById: updateWallCardById, deleteCardById: deleteWallCardById } = require('./wall')
|
||
const funds = require('./funds')
|
||
const ccbPrivateFunds = require('./ccb_private_funds')
|
||
const cloud = require('./cloud')
|
||
const cloudBalanceWatch = require('./cloud_balance_watch')
|
||
const expense = require('./expense')
|
||
const investmentLedger = require('./investment_ledger')
|
||
const styleCheck = require('./style_check')
|
||
let weekly = null
|
||
try { weekly = require('./weekly') } catch (e) { try { require('./logger').logJSON('weekly.require.error', { error: String(e.message || e) }, 'weekly') } catch {} }
|
||
let weeklyBound = false
|
||
let weeklyEmbed = null
|
||
try { weeklyEmbed = require('./weekly_embed') } catch (e) { try { require('./logger').logJSON('weekly_embed.require.error', { error: String(e.message || e) }, 'weekly_embed') } catch {} }
|
||
let weeklyEmbedBound = false
|
||
const aiLib = require('./ai-lib')
|
||
const { log, logJSON, getLogs, clearLogs } = require('./logger')
|
||
const cloudNotes = require('./cloud_notes')
|
||
const cloudSheets = require('./cloud_sheets')
|
||
const docCloudKeeper = require('./doc_cloud_keeper')
|
||
const docCalendarBridge = require('./doc_calendar_bridge')
|
||
const rationalLock = require('./rational_lock')
|
||
const languageBehaviorLock = require('./language_behavior_lock')
|
||
const plantHome = require('./plant_home')
|
||
const globalNews = require('./global_news')
|
||
const boxBackup = require('./box_backup')
|
||
const yuanzhupai = require('./yuanzhupai')
|
||
yuanzhupai.initDb()
|
||
|
||
const app = express()
|
||
app.use(cors())
|
||
app.use(express.json({ limit: '50mb' }))
|
||
|
||
const readLocalCreds = () => {
|
||
try {
|
||
const os = require('os')
|
||
const path = require('path')
|
||
const fs = require('fs')
|
||
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
|
||
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
|
||
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
|
||
try {
|
||
if (fs.existsSync(usersRoot)) {
|
||
fs.readdirSync(usersRoot).forEach(name => {
|
||
const dir = path.join(usersRoot, name)
|
||
if (dir !== userProfile && fs.existsSync(dir)) {
|
||
candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
|
||
}
|
||
})
|
||
}
|
||
} catch {}
|
||
let content = null
|
||
for (const p of candidates) {
|
||
if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break }
|
||
}
|
||
if (!content) return {}
|
||
const creds = {}
|
||
content.split(/\r?\n/).forEach(line => {
|
||
const trimmed = line.trim()
|
||
if (!trimmed || trimmed.startsWith('#')) return
|
||
const idx = trimmed.indexOf('=')
|
||
if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim()
|
||
})
|
||
return creds
|
||
} catch { return {} }
|
||
}
|
||
|
||
const checkLocalCredsToken = (req, localCreds) => {
|
||
const serverToken = String(localCreds.TOOLBOX_LOCAL_CREDS_TOKEN || '')
|
||
if (!serverToken) return true
|
||
const clientToken = String(req.query.token || '')
|
||
return clientToken === serverToken
|
||
}
|
||
|
||
const sendNoCreds = (res) => res.json({ ok: true, creds: null })
|
||
|
||
// Weekly: disable all HTTP caching for APIs
|
||
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')
|
||
res.set('Surrogate-Control', 'no-store')
|
||
res.set('Vary', 'Cookie')
|
||
} catch {}
|
||
}
|
||
app.use((req, res, next) => {
|
||
try {
|
||
if (
|
||
req.path.startsWith('/api/weekly') ||
|
||
req.path.startsWith('/tools/weekly/api/weekly') ||
|
||
req.path.startsWith('/api/weekly_embed') ||
|
||
req.path.startsWith('/api/doc_cloud_keeper') ||
|
||
req.path.startsWith('/api/doc_calendar_bridge') ||
|
||
req.path.startsWith('/api/markets/security_calendar') ||
|
||
req.path.startsWith('/api/markets/weekly_report') ||
|
||
req.path.startsWith('/api/global_news') ||
|
||
req.path.startsWith('/api/box_backup')
|
||
) setNoCache(res)
|
||
} catch {}
|
||
next()
|
||
})
|
||
|
||
const ensureRebalanceLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./rebalance') } catch (e) { try { logJSON('rebalance.require.error', { error: String(e.message || e) }, 'rebalance') } catch {}; setTimeout(ensureRebalanceLoaded, 2000); return }
|
||
try {
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('rebalance.bind_routes.done', { ok: true }, 'rebalance') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('rebalance.bind_routes.error', { error: String(e.message || e) }, 'rebalance') } catch {}
|
||
}
|
||
}
|
||
ensureRebalanceLoaded()
|
||
|
||
const ensureDltDrawsLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./dlt_draws') } catch (e) { try { logJSON('dlt_draws.require.error', { error: String(e.message || e) }, 'dlt_draws') } catch {}; setTimeout(ensureDltDrawsLoaded, 2000); return }
|
||
try {
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('dlt_draws.bind_routes.done', { ok: true }, 'dlt_draws') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('dlt_draws.bind_routes.error', { error: String(e.message || e) }, 'dlt_draws') } catch {}
|
||
}
|
||
}
|
||
ensureDltDrawsLoaded()
|
||
|
||
const ensureCalendarReminderLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./calendar_reminder') } catch (e) { try { logJSON('calendar_reminder.require.error', { error: String(e.message || e) }, 'calendar_reminder') } catch {}; setTimeout(ensureCalendarReminderLoaded, 2000); return }
|
||
try {
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('calendar_reminder.bind_routes.done', { ok: true }, 'calendar_reminder') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('calendar_reminder.bind_routes.error', { error: String(e.message || e) }, 'calendar_reminder') } catch {}
|
||
}
|
||
}
|
||
ensureCalendarReminderLoaded()
|
||
|
||
const CALENDAR_VOICE_AUTH_COOKIE = 'calendar_voice_gate'
|
||
const readCalendarVoiceAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'calendar_voice', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readCalendarVoiceJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'calendar_voice.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getCalendarVoiceCookieName = () => {
|
||
try {
|
||
const cfg = readCalendarVoiceAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return CALENDAR_VOICE_AUTH_COOKIE
|
||
}
|
||
const getCalendarVoiceMaxAge = () => {
|
||
try {
|
||
const cfg = readCalendarVoiceAuthConfig()
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
} catch {}
|
||
return 120 * 24 * 3600 * 1000
|
||
}
|
||
const getCalendarVoiceGuidConfig = () => {
|
||
try {
|
||
const cfg = readCalendarVoiceAuthConfig()
|
||
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 hasValidCalendarVoiceGuid = (req) => {
|
||
try {
|
||
const cfg = readCalendarVoiceAuthConfig()
|
||
if (cfg.enable_auth === false) return false
|
||
const { key, value } = getCalendarVoiceGuidConfig()
|
||
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 hasCalendarVoiceAuth = (req) => {
|
||
try {
|
||
const cfg = readCalendarVoiceAuthConfig()
|
||
if (cfg.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = getCalendarVoiceCookieName()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
const isCalendarVoiceForceGuid = () => {
|
||
try {
|
||
const cfg = readCalendarVoiceAuthConfig()
|
||
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 true
|
||
} catch { return true }
|
||
}
|
||
const hasValidCalendarVoiceToken = (req) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return false
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readCalendarVoiceJwk()))
|
||
if (!payload) return false
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-calendar_voice')) return false
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
const grantCalendarVoiceAuth = (res) => {
|
||
const maxAgeCalendarVoice = getCalendarVoiceMaxAge()
|
||
const cookieName = getCalendarVoiceCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeCalendarVoice, path: '/' })
|
||
}
|
||
app.get('/tools/calendar_voice', (req, res, next) => {
|
||
try {
|
||
const forceGuid = isCalendarVoiceForceGuid()
|
||
if (hasCalendarVoiceAuth(req)) return next()
|
||
const guidKey = getCalendarVoiceGuidConfig().key
|
||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||
if (hasValidCalendarVoiceGuid(req)) {
|
||
grantCalendarVoiceAuth(res)
|
||
return next()
|
||
}
|
||
if (hasGuidInput) return res.status(401).send('未授权')
|
||
if (hasValidCalendarVoiceToken(req)) {
|
||
grantCalendarVoiceAuth(res)
|
||
return next()
|
||
}
|
||
if (String(req.query.token || '')) return res.status(401).send('未授权')
|
||
if (forceGuid) return res.status(401).send('未授权')
|
||
grantCalendarVoiceAuth(res)
|
||
return next()
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/calendar_voice', (req, res, next) => {
|
||
const forceGuid = isCalendarVoiceForceGuid()
|
||
if (hasCalendarVoiceAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||
if (isHandshake && hasValidCalendarVoiceGuid(req)) {
|
||
grantCalendarVoiceAuth(res)
|
||
return next()
|
||
}
|
||
if (isHandshake && hasValidCalendarVoiceToken(req)) {
|
||
grantCalendarVoiceAuth(res)
|
||
return next()
|
||
}
|
||
if (isHandshake && String(req.query.token || '')) return res.status(401).send('未授权')
|
||
if (isHandshake && !forceGuid) {
|
||
grantCalendarVoiceAuth(res)
|
||
return next()
|
||
}
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/calendar_voice', (req, res, next) => {
|
||
if (hasCalendarVoiceAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const ensureCalendarVoiceLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./calendar_voice') } catch (e) { try { logJSON('calendar_voice.require.error', { error: String(e.message || e) }, 'calendar_voice') } catch {}; return }
|
||
try {
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('calendar_voice.bind_routes.done', { ok: true }, 'calendar_voice') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('calendar_voice.bind_routes.error', { error: String(e.message || e) }, 'calendar_voice') } catch {}
|
||
}
|
||
}
|
||
ensureCalendarVoiceLoaded()
|
||
|
||
|
||
const ensurePscLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./psc') } catch (e) { try { logJSON('psc.require.error', { error: String(e.message || e) }, 'psc') } catch {}; setTimeout(ensurePscLoaded, 2000); return }
|
||
try {
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('psc.bind_routes.done', { ok: true }, 'psc') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('psc.bind_routes.error', { error: String(e.message || e) }, 'psc') } catch {}
|
||
}
|
||
}
|
||
ensurePscLoaded()
|
||
|
||
let privateClipboardRouter = 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 }
|
||
try {
|
||
if (mod && mod.createRouter) {
|
||
privateClipboardRouter = mod.createRouter()
|
||
try { logJSON('private_clipboard.create_router.done', { ok: true }, 'private_clipboard') } catch {}
|
||
} else if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('private_clipboard.bind_routes.done', { ok: true }, 'private_clipboard') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('private_clipboard.create_router.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
|
||
}
|
||
}
|
||
ensurePrivateClipboardLoaded()
|
||
|
||
const ensureFitnessDiaryLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./fitness_diary') } catch (e) { try { logJSON('fitness_diary.require.error', { error: String(e.message || e) }, 'fitness_diary') } catch {}; setTimeout(ensureFitnessDiaryLoaded, 2000); return }
|
||
try {
|
||
if (mod && mod.initDb) {
|
||
// Use __dirname to ensure correct path regardless of CWD
|
||
mod.initDb(path.join(__dirname, '..', '..', 'data', 'fitness_diary.db'))
|
||
}
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('fitness_diary.bind_routes.done', { ok: true }, 'fitness_diary') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('fitness_diary.bind_routes.error', { error: String(e.message || e) }, 'fitness_diary') } catch {}
|
||
}
|
||
}
|
||
|
||
const ensurePlantHomeLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./plant_home') } catch (e) { try { logJSON('plant_home.require.error', { error: String(e.message || e) }, 'plant_home') } catch {}; setTimeout(ensurePlantHomeLoaded, 2000); return }
|
||
try {
|
||
if (mod && mod.initDb) {
|
||
mod.initDb(path.join(__dirname, '..', '..', 'data', 'plant_home.db'))
|
||
}
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('plant_home.bind_routes.done', { ok: true }, 'plant_home') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('plant_home.bind_routes.error', { error: String(e.message || e) }, 'plant_home') } catch {}
|
||
}
|
||
}
|
||
|
||
const ensureAppleWatchLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./apple_watch') } catch (e) { try { logJSON('apple_watch.require.error', { error: String(e.message || e) }, 'apple_watch') } catch {}; setTimeout(ensureAppleWatchLoaded, 2000); return }
|
||
try {
|
||
if (mod && mod.initDb) {
|
||
mod.initDb(path.join(__dirname, '..', '..', 'data', 'apple_watch.db'))
|
||
}
|
||
if (mod && mod.bindRoutes) {
|
||
mod.bindRoutes(app)
|
||
try { logJSON('apple_watch.bind_routes.done', { ok: true }, 'apple_watch') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('apple_watch.bind_routes.error', { error: String(e.message || e) }, 'apple_watch') } catch {}
|
||
}
|
||
}
|
||
|
||
const FITNESS_DIARY_AUTH_COOKIE = 'fitness_diary_gate'
|
||
const readFitnessDiaryAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'fitness_diary', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readFitnessDiaryJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'fitness_diary.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getFitnessDiaryCookieName = () => {
|
||
try {
|
||
const cfg = readFitnessDiaryAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return FITNESS_DIARY_AUTH_COOKIE
|
||
}
|
||
const getFitnessDiaryMaxAge = () => {
|
||
try {
|
||
const cfg = readFitnessDiaryAuthConfig()
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
}
|
||
const getFitnessDiaryGuidConfig = () => {
|
||
try {
|
||
const cfg = readFitnessDiaryAuthConfig()
|
||
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 hasValidFitnessDiaryGuid = (req) => {
|
||
try {
|
||
const cfg = readFitnessDiaryAuthConfig()
|
||
if (cfg.enable_auth === false) return false
|
||
const { key, value } = getFitnessDiaryGuidConfig()
|
||
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 hasFitnessDiaryAuth = (req) => {
|
||
try {
|
||
const config = readFitnessDiaryAuthConfig()
|
||
if (config.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = getFitnessDiaryCookieName()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
|
||
app.get('/tools/fitness_diary', (req, res, next) => {
|
||
try {
|
||
if (hasFitnessDiaryAuth(req)) return next()
|
||
const guidKey = getFitnessDiaryGuidConfig().key
|
||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||
if (hasValidFitnessDiaryGuid(req)) {
|
||
const maxAgeFD = getFitnessDiaryMaxAge()
|
||
const cookieName = getFitnessDiaryCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeFD, path: '/' })
|
||
return next()
|
||
}
|
||
if (hasGuidInput) return res.status(401).send('未授权')
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readFitnessDiaryJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'FitnessDiary')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeFD = getFitnessDiaryMaxAge()
|
||
const cookieName = getFitnessDiaryCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeFD })
|
||
return res.redirect('/tools/fitness_diary/index.html')
|
||
} catch (e) {
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
|
||
app.use('/tools/fitness_diary', (req, res, next) => {
|
||
if (hasFitnessDiaryAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||
if (isHandshake && (String(req.query.token || '') || hasValidFitnessDiaryGuid(req))) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
|
||
app.use('/api/fitness_diary', (req, res, next) => {
|
||
if (hasFitnessDiaryAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
ensureFitnessDiaryLoaded()
|
||
|
||
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
|
||
const readPlantHomeAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'plant_home', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readPlantHomeJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'plant_home.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getPlantHomeCookieName = () => {
|
||
try {
|
||
const cfg = readPlantHomeAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return PLANT_HOME_AUTH_COOKIE
|
||
}
|
||
const getPlantHomeMaxAge = () => {
|
||
try {
|
||
const cfg = readPlantHomeAuthConfig()
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
}
|
||
const getPlantHomeGuidConfig = () => {
|
||
try {
|
||
const cfg = readPlantHomeAuthConfig()
|
||
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 hasValidPlantHomeGuid = (req) => {
|
||
try {
|
||
const cfg = readPlantHomeAuthConfig()
|
||
if (cfg.enable_auth === false) return false
|
||
const { key, value } = getPlantHomeGuidConfig()
|
||
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 isPlantHomeForceGuid = () => {
|
||
try {
|
||
const cfg = readPlantHomeAuthConfig()
|
||
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 hasPlantHomeAuth = (req) => {
|
||
try {
|
||
const config = readPlantHomeAuthConfig()
|
||
if (config.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = getPlantHomeCookieName()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/plant_home', (req, res, next) => {
|
||
try {
|
||
const forceGuid = isPlantHomeForceGuid()
|
||
if (hasPlantHomeAuth(req)) return next()
|
||
const guidKey = getPlantHomeGuidConfig().key
|
||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||
if (hasValidPlantHomeGuid(req)) {
|
||
const maxAge = getPlantHomeMaxAge()
|
||
const cookieName = getPlantHomeCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return next()
|
||
}
|
||
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(readPlantHomeJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-plant_home')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAge = getPlantHomeMaxAge()
|
||
const cookieName = getPlantHomeCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge })
|
||
return res.redirect('/tools/plant_home/index.html')
|
||
} catch (e) {
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
app.use('/tools/plant_home', (req, res, next) => {
|
||
const forceGuid = isPlantHomeForceGuid()
|
||
if (hasPlantHomeAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||
if (isHandshake && hasValidPlantHomeGuid(req)) {
|
||
const maxAge = getPlantHomeMaxAge()
|
||
const cookieName = getPlantHomeCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return next()
|
||
}
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
if (isHandshake && forceGuid) return res.status(401).send('未授权')
|
||
if (!forceGuid) {
|
||
const maxAge = getPlantHomeMaxAge()
|
||
const cookieName = getPlantHomeCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return next()
|
||
}
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/uploads/plant_home', (req, res, next) => {
|
||
if (hasPlantHomeAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/plant_home', (req, res, next) => {
|
||
if (hasPlantHomeAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
ensurePlantHomeLoaded()
|
||
|
||
const APPLE_WATCH_AUTH_COOKIE = 'apple_watch_gate'
|
||
const readAppleWatchJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'apple_watch.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasAppleWatchAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'apple_watch', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'apple_watch', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return APPLE_WATCH_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
|
||
app.get('/tools/apple_watch', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readAppleWatchJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-apple_watch')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAge = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'apple_watch', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'apple_watch', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return APPLE_WATCH_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge })
|
||
return res.redirect('/tools/apple_watch/index.html')
|
||
} catch (e) {
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
|
||
app.use('/tools/apple_watch', (req, res, next) => {
|
||
if (hasAppleWatchAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
|
||
app.use('/api/apple_watch', (req, res, next) => {
|
||
if (hasAppleWatchAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
ensureAppleWatchLoaded()
|
||
|
||
const RATIONAL_LOCK_AUTH_COOKIE = 'rational_lock_gate'
|
||
const readRationalLockAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'rational_lock', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readRationalLockJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'rational_lock.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getRationalLockCookieName = () => {
|
||
try {
|
||
const cfg = readRationalLockAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return RATIONAL_LOCK_AUTH_COOKIE
|
||
}
|
||
const getRationalLockMaxAge = () => {
|
||
try {
|
||
const cfg = readRationalLockAuthConfig()
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
}
|
||
const hasRationalLockAuth = (req) => {
|
||
try {
|
||
const cfg = readRationalLockAuthConfig()
|
||
if (cfg.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = getRationalLockCookieName()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/rational_lock', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readRationalLockJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-rational_lock')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAge = getRationalLockMaxAge()
|
||
const cookieName = getRationalLockCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/rational_lock/index.html')
|
||
} catch {
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
app.use('/tools/rational_lock', (req, res, next) => {
|
||
if (hasRationalLockAuth(req)) return next()
|
||
if (req.method === 'GET') return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/rational_lock', (req, res, next) => {
|
||
return next()
|
||
})
|
||
|
||
const LANGUAGE_BEHAVIOR_LOCK_AUTH_COOKIE = 'language_behavior_lock_gate'
|
||
const readLanguageBehaviorLockAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'language_behavior_lock', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readLanguageBehaviorLockJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'language_behavior_lock.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getLanguageBehaviorLockCookieName = () => {
|
||
try {
|
||
const cfg = readLanguageBehaviorLockAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return LANGUAGE_BEHAVIOR_LOCK_AUTH_COOKIE
|
||
}
|
||
const getLanguageBehaviorLockMaxAge = () => {
|
||
try {
|
||
const cfg = readLanguageBehaviorLockAuthConfig()
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
}
|
||
const hasLanguageBehaviorLockAuth = (req) => {
|
||
try {
|
||
const cfg = readLanguageBehaviorLockAuthConfig()
|
||
if (cfg.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = getLanguageBehaviorLockCookieName()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/language_behavior_lock', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readLanguageBehaviorLockJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-language_behavior_lock')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAge = getLanguageBehaviorLockMaxAge()
|
||
const cookieName = getLanguageBehaviorLockCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/language_behavior_lock/index.html')
|
||
} catch {
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
app.use('/tools/language_behavior_lock', (req, res, next) => {
|
||
if (hasLanguageBehaviorLockAuth(req)) return next()
|
||
if (req.method === 'GET') return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/language_behavior_lock', (req, res, next) => {
|
||
return next()
|
||
})
|
||
|
||
const ensureRationalLockLoaded = () => {
|
||
try {
|
||
if (rationalLock && rationalLock.bindRoutes) {
|
||
rationalLock.bindRoutes(app)
|
||
try { logJSON('rational_lock.bind_routes.done', { ok: true }, 'rational_lock') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('rational_lock.bind_routes.error', { error: String(e.message || e) }, 'rational_lock') } catch {}
|
||
}
|
||
}
|
||
ensureRationalLockLoaded()
|
||
|
||
const ensureLanguageBehaviorLockLoaded = () => {
|
||
try {
|
||
if (languageBehaviorLock && languageBehaviorLock.bindRoutes) {
|
||
languageBehaviorLock.bindRoutes(app)
|
||
try { logJSON('language_behavior_lock.bind_routes.done', { ok: true }, 'language_behavior_lock') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('language_behavior_lock.bind_routes.error', { error: String(e.message || e) }, 'language_behavior_lock') } catch {}
|
||
}
|
||
}
|
||
ensureLanguageBehaviorLockLoaded()
|
||
|
||
|
||
|
||
const invoicesDir = path.join(process.cwd(), 'uploads', 'cloud', 'invoices')
|
||
if (!fs.existsSync(invoicesDir)) fs.mkdirSync(invoicesDir, { recursive: true })
|
||
|
||
const settingsPath = path.join(process.cwd(), 'config', 'settings.json')
|
||
if (!fs.existsSync(path.dirname(settingsPath))) fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
|
||
if (!fs.existsSync(settingsPath)) fs.writeFileSync(settingsPath, JSON.stringify({ users: [{ uid: '7716940453', screen_name: '' }], keywords: ['上海', '上海 无料'], headers: {}, count: 50 }, null, 2))
|
||
|
||
const flagsPath = path.join(process.cwd(), 'config', 'flags.json')
|
||
if (!fs.existsSync(flagsPath)) fs.writeFileSync(flagsPath, JSON.stringify({
|
||
debugTool: { enabled: true, launchDate: null, autoHideAfterDays: 30 },
|
||
debug: { weibo: true, markets: true, wall: true, funds: true },
|
||
navAuth: { enable_auth: true, iss: 'TRAE-NAV', iss_strict: false }
|
||
}, null, 2))
|
||
|
||
const computeDebugEnabled = () => {
|
||
let f = { debugTool: { enabled: true, launchDate: null, autoHideAfterDays: 30 } }
|
||
try { f = JSON.parse(fs.readFileSync(flagsPath, 'utf-8')) } catch {}
|
||
const cfg = f.debugTool || {}
|
||
if (!cfg.enabled) return false
|
||
if (cfg.launchDate) {
|
||
const launch = new Date(cfg.launchDate)
|
||
if (!isNaN(launch.getTime())) {
|
||
const hideAfterDays = Number(cfg.autoHideAfterDays || 30)
|
||
const hideTime = new Date(launch.getTime() + hideAfterDays * 24 * 3600 * 1000)
|
||
if (Date.now() > hideTime.getTime()) return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
const computeToolDebug = tool => {
|
||
let f = { debug: { weibo: true, markets: true, wall: true } }
|
||
try { f = JSON.parse(fs.readFileSync(flagsPath, 'utf-8')) } catch {}
|
||
const m = f.debug || {}
|
||
const envKey = `DEBUG_${String(tool || '').toUpperCase()}`
|
||
if (process.env[envKey] === '0') return false
|
||
if (process.env[envKey] === '1') return true
|
||
return !!m[tool]
|
||
}
|
||
|
||
const computeNavAuthEnabled = () => {
|
||
let f = { navAuth: { enable_auth: true } }
|
||
try { f = JSON.parse(fs.readFileSync(flagsPath, 'utf-8')) } catch {}
|
||
const cfg = f.navAuth || {}
|
||
return cfg.enable_auth !== false
|
||
}
|
||
const computeNavIssStrict = () => {
|
||
let f = { navAuth: { iss_strict: false } }
|
||
try { f = JSON.parse(fs.readFileSync(flagsPath, 'utf-8')) } catch {}
|
||
const cfg = f.navAuth || {}
|
||
return cfg.iss_strict === true
|
||
}
|
||
const getNavIssFromFlags = () => {
|
||
try {
|
||
let f = { navAuth: { iss: 'TRAE-NAV' } }
|
||
try { f = JSON.parse(fs.readFileSync(flagsPath, 'utf-8')) } catch {}
|
||
const cfg = f.navAuth || {}
|
||
const v = String(cfg.iss || '').trim()
|
||
return v || 'TRAE-NAV'
|
||
} catch {
|
||
return 'TRAE-NAV'
|
||
}
|
||
}
|
||
|
||
const isHttps = (req) => {
|
||
const xfwd = String(((req.get && req.get('X-Forwarded-Proto')) || (req.headers && req.headers['x-forwarded-proto']) || '')).toLowerCase()
|
||
if (xfwd.split(',')[0].trim() === 'https') return true
|
||
if (req.secure) return true
|
||
const arr = String((req.headers && req.headers['x-arr-ssl']) || '')
|
||
if (arr) return true
|
||
const xp = String((req.headers && req.headers['x-forwarded-protocol']) || '').toLowerCase()
|
||
if (xp === 'https') return true
|
||
return false
|
||
}
|
||
app.use((req, res, next) => {
|
||
if (isHttps(req)) {
|
||
res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
|
||
res.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||
}
|
||
next()
|
||
})
|
||
app.use((req, res, next) => {
|
||
const orig = res.cookie.bind(res)
|
||
res.cookie = (name, val, options) => {
|
||
const o = Object.assign({ httpOnly: true, sameSite: 'lax' }, options || {})
|
||
if (isHttps(req)) o.secure = true
|
||
return orig(name, val, o)
|
||
}
|
||
next()
|
||
})
|
||
|
||
app.use((req, res, next) => {
|
||
if (req.path.startsWith('/tools/debug') && !computeDebugEnabled()) return res.status(404).end()
|
||
next()
|
||
})
|
||
|
||
app.use((req, res, next) => {
|
||
if (req.path.startsWith('/tools/weekly/api/weekly')) req.url = req.url.replace('/tools/weekly/api/weekly','/api/weekly')
|
||
if (req.path.startsWith('/tools/fitness_diary/api/fitness_diary')) req.url = req.url.replace('/tools/fitness_diary/api/fitness_diary','/api/fitness_diary')
|
||
if (req.path.startsWith('/tools/apple_watch/api/apple_watch')) req.url = req.url.replace('/tools/apple_watch/api/apple_watch','/api/apple_watch')
|
||
if (req.path.startsWith('/tools/ai-lib/api/ai-lib')) req.url = req.url.replace('/tools/ai-lib/api/ai-lib','/api/ai-lib')
|
||
next()
|
||
})
|
||
|
||
app.use((req, res, next) => {
|
||
if (req.path.startsWith('/api/ai-lib')) {
|
||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||
res.set('Pragma', 'no-cache')
|
||
res.set('Expires', '0')
|
||
}
|
||
next()
|
||
})
|
||
const EXPENSE_JWK = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'expense.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
})()
|
||
const parseCookie = (cookieStr) => {
|
||
const out = {}
|
||
String(cookieStr || '').split(/;\s*/).forEach(p => { const idx = p.indexOf('='); if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1)) })
|
||
return out
|
||
}
|
||
const NAV_BOOKMARKS_DB = (() => {
|
||
const dbPath = path.join(process.cwd(), 'data', 'nav_bookmarks.db')
|
||
try { fs.mkdirSync(path.dirname(dbPath), { recursive: true }) } catch {}
|
||
const db = new Database(dbPath)
|
||
db.pragma('journal_mode = WAL')
|
||
db.pragma('synchronous = NORMAL')
|
||
db.pragma('busy_timeout = 4000')
|
||
db.exec(`CREATE TABLE IF NOT EXISTS nav_categories (
|
||
code TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT,
|
||
updated_at TEXT
|
||
)`)
|
||
db.exec(`CREATE TABLE IF NOT EXISTS nav_bookmarks (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
url TEXT NOT NULL,
|
||
icon TEXT,
|
||
category_code TEXT,
|
||
remark TEXT,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT,
|
||
updated_at TEXT
|
||
)`)
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_nav_bookmarks_category ON nav_bookmarks(category_code)`)
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_nav_bookmarks_sort ON nav_bookmarks(sort_order)`)
|
||
return db
|
||
})()
|
||
const navNow = () => new Date().toISOString()
|
||
const navSeedCategories = () => {
|
||
try {
|
||
const count = NAV_BOOKMARKS_DB.prepare('SELECT COUNT(1) AS c FROM nav_categories').get()
|
||
if (count && count.c > 0) return
|
||
const seed = [
|
||
{ code: '1', name: '工作', sort: 1 },
|
||
{ code: '2', name: '投资', sort: 2 },
|
||
{ code: '3', name: '娱乐旅游出行', sort: 3 },
|
||
{ code: '4', name: '健康强体', sort: 4 },
|
||
{ code: '5', name: '花草鱼鸟', sort: 5 },
|
||
{ code: '6', name: '学习与AI开发', sort: 6 },
|
||
{ code: '7', name: '生活和家居', sort: 7 },
|
||
{ code: '8', name: '杨邮车(老家)', sort: 8 },
|
||
{ code: '9', name: '备用1', sort: 9 },
|
||
{ code: '10', name: '备用2', sort: 10 },
|
||
{ code: '11', name: '备用3', sort: 11 },
|
||
{ code: '99', name: '其他', sort: 99 }
|
||
]
|
||
const stmt = NAV_BOOKMARKS_DB.prepare('INSERT INTO nav_categories (code, name, sort_order, created_at, updated_at) VALUES (@code, @name, @sort, @createdAt, @updatedAt)')
|
||
const now = navNow()
|
||
const tx = NAV_BOOKMARKS_DB.transaction(() => {
|
||
seed.forEach(row => stmt.run({ code: row.code, name: row.name, sort: row.sort, createdAt: now, updatedAt: now }))
|
||
})
|
||
tx()
|
||
} catch {}
|
||
}
|
||
const navSeedBookmarksFromJson = () => {
|
||
try {
|
||
const count = NAV_BOOKMARKS_DB.prepare('SELECT COUNT(1) AS c FROM nav_bookmarks').get()
|
||
if (count && count.c > 0) return
|
||
const p = path.join(process.cwd(), 'public', 'common', 'nav.json')
|
||
if (!fs.existsSync(p)) return
|
||
const items = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (!Array.isArray(items) || !items.length) return
|
||
const now = navNow()
|
||
const stmt = NAV_BOOKMARKS_DB.prepare(`INSERT INTO nav_bookmarks (id, name, url, icon, category_code, remark, sort_order, created_at, updated_at)
|
||
VALUES (@id, @name, @url, @icon, @categoryCode, @remark, @sortOrder, @createdAt, @updatedAt)`)
|
||
const tx = NAV_BOOKMARKS_DB.transaction(() => {
|
||
items.forEach((item, idx) => {
|
||
const id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${idx}-${Math.random().toString(16).slice(2)}`
|
||
stmt.run({
|
||
id,
|
||
name: String(item.name || '未命名'),
|
||
url: String(item.url || '#'),
|
||
icon: item.icon ? String(item.icon) : '',
|
||
categoryCode: '99',
|
||
remark: '',
|
||
sortOrder: idx + 1,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
})
|
||
})
|
||
})
|
||
tx()
|
||
} catch {}
|
||
}
|
||
navSeedCategories()
|
||
navSeedBookmarksFromJson()
|
||
const b64urlToBuf = (s) => {
|
||
s = String(s || '').replace(/-/g, '+').replace(/_/g, '/')
|
||
const pad = s.length % 4
|
||
if (pad) s += '='.repeat(4 - pad)
|
||
return Buffer.from(s, 'base64')
|
||
}
|
||
const jwkKeys = (obj) => {
|
||
if (!obj) return []
|
||
if (Array.isArray(obj.keys)) return obj.keys.filter(k => k && k.n && k.e)
|
||
if (obj.kty && obj.n && obj.e) return [obj]
|
||
return []
|
||
}
|
||
const decodePart = s => {
|
||
s = String(s || '').replace(/-/g, '+').replace(/_/g, '/')
|
||
const pad = s.length % 4
|
||
if (pad) s += '='.repeat(4 - pad)
|
||
return Buffer.from(s, 'base64').toString()
|
||
}
|
||
const readNavPubJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'nav.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return null
|
||
}
|
||
const audMatch = (payload, expected) => {
|
||
try {
|
||
const a = String((payload && payload.aud) || '')
|
||
if (!expected) return true
|
||
if (expected.endsWith('-')) return a.startsWith(expected)
|
||
return a === expected
|
||
} catch { return false }
|
||
}
|
||
const verifyJwtWithKeys = (token, keys) => {
|
||
try {
|
||
const parts = String(token || '').split('.')
|
||
if (parts.length !== 3) return null
|
||
const data = parts[0] + '.' + parts[1]
|
||
const sig = b64urlToBuf(parts[2])
|
||
let ks = Array.isArray(keys) ? keys.slice() : []
|
||
try {
|
||
const ext = String(process.env.NAV_JWK_JSON || '')
|
||
if (ext) {
|
||
const obj = JSON.parse(ext)
|
||
const extra = jwkKeys(obj)
|
||
ks = ks.concat(extra)
|
||
} else {
|
||
try {
|
||
const navPub = readNavPubJwk()
|
||
const extra = jwkKeys(navPub)
|
||
ks = ks.concat(extra)
|
||
} catch {}
|
||
try {
|
||
const priv = readNavPrivateJwk()
|
||
const pub = (priv && priv.kty && priv.n && priv.e) ? { kty: priv.kty, n: priv.n, e: priv.e } : null
|
||
if (pub) ks = ks.concat([pub])
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
for (const jwk of ks) {
|
||
try {
|
||
const pub = crypto.createPublicKey({ key: jwk, format: 'jwk' })
|
||
const ok = crypto.verify('RSA-SHA256', Buffer.from(data), pub, sig)
|
||
if (!ok) continue
|
||
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString())
|
||
const now = Math.floor(Date.now() / 1000)
|
||
if (payload.exp && now > payload.exp) continue
|
||
return payload
|
||
} catch {}
|
||
}
|
||
return null
|
||
} catch { return null }
|
||
}
|
||
const debugVerifyJwt = (token, keys, opts) => {
|
||
const expectedIss = (opts && opts.expectedIss) || getNavIssFromFlags()
|
||
const out = { ok: false, sig_ok: false, matched_source: '', checked_keys: 0, keys_sources: [], payload: null, header: null, now: Math.floor(Date.now()/1000), exp: null, exp_ok: null, iss: '', expected_iss: String(expectedIss), iss_ok: null, iss_strict: !!(opts && opts.issStrict), reason: '' }
|
||
try {
|
||
const parts = String(token || '').split('.')
|
||
if (parts.length !== 3) { out.reason = 'bad_format'; return out }
|
||
out.header = JSON.parse(decodePart(parts[0]))
|
||
const data = parts[0] + '.' + parts[1]
|
||
const sig = b64urlToBuf(parts[2])
|
||
let pool = []
|
||
try {
|
||
const ext = String(process.env.NAV_JWK_JSON || '')
|
||
if (ext) { const obj = JSON.parse(ext); const extra = jwkKeys(obj).map(k => ({ src: 'env', key: k })); pool = pool.concat(extra) }
|
||
} catch {}
|
||
try {
|
||
const navPub = readNavPubJwk()
|
||
const extra = jwkKeys(navPub).map(k => ({ src: 'file_nav_pub', key: k }))
|
||
pool = pool.concat(extra)
|
||
} catch {}
|
||
try {
|
||
const priv = readNavPrivateJwk()
|
||
const pub = (priv && priv.kty && priv.n && priv.e) ? { kty: priv.kty, n: priv.n, e: priv.e } : null
|
||
const extra = jwkKeys(pub).map(k => ({ src: 'file_nav_private_pub', key: k }))
|
||
pool = pool.concat(extra)
|
||
} catch {}
|
||
if (Array.isArray(keys)) pool = pool.concat(keys.map(k => ({ src: 'passed', key: k })))
|
||
out.keys_sources = pool.map(p => p.src)
|
||
for (const item of pool) {
|
||
try {
|
||
out.checked_keys++
|
||
const pub = crypto.createPublicKey({ key: item.key, format: 'jwk' })
|
||
const ok = crypto.verify('RSA-SHA256', Buffer.from(data), pub, sig)
|
||
if (!ok) continue
|
||
out.sig_ok = true
|
||
out.matched_source = item.src
|
||
out.payload = JSON.parse(decodePart(parts[1]))
|
||
out.exp = out.payload.exp || null
|
||
out.exp_ok = out.exp ? (out.now <= out.exp) : true
|
||
out.iss = String(out.payload.iss || '')
|
||
out.iss_ok = out.iss_strict ? (out.iss === out.expected_iss) : true
|
||
out.ok = !!(out.sig_ok && out.exp_ok && out.iss_ok)
|
||
return out
|
||
} catch {}
|
||
}
|
||
out.reason = 'no_key_matched'
|
||
return out
|
||
} catch (e) { out.reason = String(e && e.message || 'error'); return out }
|
||
}
|
||
const readNavPrivateJwk = () => {
|
||
try {
|
||
const s = String(process.env.NAV_PRIVATE_JWK_JSON || process.env.NAV_PRIVATE_JWK || '')
|
||
if (s) {
|
||
const obj = JSON.parse(s)
|
||
return obj
|
||
}
|
||
} catch {}
|
||
try {
|
||
const f = String(process.env.NAV_PRIVATE_JWK_FILE || '')
|
||
if (f && fs.existsSync(f)) {
|
||
const obj = JSON.parse(fs.readFileSync(f, 'utf-8'))
|
||
return obj
|
||
}
|
||
} catch {}
|
||
const candidates = [
|
||
path.join(process.cwd(), 'config', 'nav-private.jwk.json'),
|
||
path.resolve(__dirname, '../../config/nav-private.jwk.json'),
|
||
path.resolve(__dirname, '../config/nav-private.jwk.json')
|
||
]
|
||
for (const pth of candidates) {
|
||
try {
|
||
if (fs.existsSync(pth)) {
|
||
const obj = JSON.parse(fs.readFileSync(pth, 'utf-8'))
|
||
return obj
|
||
}
|
||
} catch {}
|
||
}
|
||
return null
|
||
}
|
||
const signJwtRS256 = (payload, jwk) => {
|
||
const header = { alg: 'RS256', typ: 'JWT' }
|
||
const enc = v => Buffer.from(JSON.stringify(v)).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')
|
||
const data = enc(header) + '.' + enc(payload)
|
||
const priv = crypto.createPrivateKey({ key: jwk, format: 'jwk' })
|
||
const sig = crypto.sign('RSA-SHA256', Buffer.from(data), priv).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')
|
||
return data + '.' + sig
|
||
}
|
||
const readNavTargets = () => {
|
||
try {
|
||
const s = String(process.env.NAV_TARGETS_JSON || '')
|
||
if (!s) return {}
|
||
const obj = JSON.parse(s)
|
||
return obj && typeof obj === 'object' ? obj : {}
|
||
} catch { return {} }
|
||
}
|
||
const mapSystemIdToUrl = (systemId) => {
|
||
const ext = readNavTargets()
|
||
if (ext && typeof ext[systemId] === 'string') {
|
||
const v = String(ext[systemId] || '').trim()
|
||
if (v) return v
|
||
}
|
||
const m = {
|
||
'Tools-weibo': '/tools/weibo',
|
||
'Tools-markets': '/tools/markets',
|
||
'Tools-wall-': '/tools/wall',
|
||
'Tools-funds': '/tools/funds',
|
||
'Tools-ccb_private_funds': '/tools/ccb_private_funds',
|
||
'Tools-cloud': '/tools/cloud',
|
||
'Tools-weekly': '/tools/weekly',
|
||
'Tools-ai-lib': '/tools/ai-lib',
|
||
'Tools-expense': '/tools/expense',
|
||
'Tools-rebalance': '/tools/rebalance',
|
||
'Tools-dlt_draws': '/tools/dlt_draws',
|
||
'Tools-calendar_reminder': '/tools/calendar_reminder',
|
||
'Tools-calendar_voice': '/tools/calendar_voice',
|
||
'TRAE-PSC': '/tools/psc',
|
||
'FitnessDiary': '/tools/fitness_diary',
|
||
'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper',
|
||
'Tools-yuanzhupai': '/tools/yuanzhupai',
|
||
'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'
|
||
}
|
||
if (m[systemId]) return m[systemId]
|
||
if (systemId.startsWith('Tools-')) {
|
||
const name = systemId.slice('Tools-'.length).trim()
|
||
if (name) return `/tools/${name}`
|
||
}
|
||
if (systemId.startsWith('Tools-wall-')) return '/tools/wall'
|
||
return ''
|
||
}
|
||
const expenseVerifyJwt = (token) => verifyJwtWithKeys(token, jwkKeys(EXPENSE_JWK))
|
||
const EXPENSE_AUTH_COOKIE = 'expense_gate'
|
||
const hasExpenseAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'expense', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'expense', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return EXPENSE_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/expense', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = expenseVerifyJwt(token)
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-expense')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeExpense = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'expense', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'expense', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return EXPENSE_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeExpense })
|
||
return res.redirect('/tools/expense')
|
||
} catch (e) {
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
app.use('/tools/expense', (req, res, next) => {
|
||
if (hasExpenseAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/uploads/expense', (req, res, next) => {
|
||
if (hasExpenseAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/expense', (req, res, next) => {
|
||
if (hasExpenseAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const AI_LIB_AUTH_COOKIE = 'ai_lib_gate'
|
||
const readAiLibJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'ai-lib.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const aiLibVerifyJwt = (token) => verifyJwtWithKeys(token, jwkKeys(readAiLibJwk()))
|
||
const hasAiLibAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'ai-lib', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'ai-lib', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return AI_LIB_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/ai-lib', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = aiLibVerifyJwt(token)
|
||
if (!payload) {
|
||
logJSON('ai-lib.handshake.fail', { token_len: token.length, keys: jwkKeys(readAiLibJwk()).length }, 'ai-lib')
|
||
return res.status(401).send('未授权')
|
||
}
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-ai-lib')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeAiLib = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'ai-lib', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'ai-lib', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return AI_LIB_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeAiLib })
|
||
return res.redirect('/tools/ai-lib')
|
||
} catch (e) {
|
||
logJSON('ai-lib.handshake.error', { message: String(e.message || e) }, 'ai-lib')
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
app.use('/tools/ai-lib', (req, res, next) => {
|
||
if (hasAiLibAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/uploads/ai-lib', (req, res, next) => {
|
||
if (hasAiLibAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/ai-lib', (req, res, next) => {
|
||
if (req.path === '/auth/local_creds') return next()
|
||
if (hasAiLibAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const CLOUD_AUTH_COOKIE = 'cloud_gate'
|
||
const readCloudJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'cloud.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasCloudAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'cloud', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CLOUD_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/cloud', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readCloudJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-cloud')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeCloud = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CLOUD_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeCloud, path: '/' })
|
||
return res.redirect('/tools/cloud')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/cloud', (req, res, next) => {
|
||
if (hasCloudAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/uploads/cloud', (req, res, next) => {
|
||
if (hasCloudAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/cloud', (req, res, next) => {
|
||
if (hasCloudAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const FUNDS_AUTH_COOKIE = 'funds_gate'
|
||
const readFundsJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'funds.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasFundsAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'funds', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'funds', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return FUNDS_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/funds', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readFundsJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-funds')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeFunds = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'funds', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'funds', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return FUNDS_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeFunds, path: '/' })
|
||
return res.redirect('/tools/funds')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/funds', (req, res, next) => {
|
||
if (hasFundsAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/funds', (req, res, next) => {
|
||
if (hasFundsAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const CCB_PRIVATE_FUNDS_AUTH_COOKIE = 'ccb_private_funds_gate'
|
||
const readCcbPrivateFundsJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'ccb_private_funds.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasCcbPrivateFundsAuth = req => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'ccb_private_funds', 'auth_config.json')
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||
if (config.enable_auth === false) return true
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'ccb_private_funds', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CCB_PRIVATE_FUNDS_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/ccb_private_funds', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readCcbPrivateFundsJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-ccb_private_funds')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAge = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'ccb_private_funds', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'ccb_private_funds', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CCB_PRIVATE_FUNDS_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/ccb_private_funds')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/ccb_private_funds', (req, res, next) => {
|
||
if (hasCcbPrivateFundsAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/ccb_private_funds', (req, res, next) => {
|
||
if (hasCcbPrivateFundsAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const MARKETS_AUTH_COOKIE = 'markets_gate'
|
||
const readMarketsAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'markets', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readMarketsJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'markets.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getMarketsCookieName = () => {
|
||
try {
|
||
const cfg = readMarketsAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return MARKETS_AUTH_COOKIE
|
||
}
|
||
const getMarketsMaxAge = () => {
|
||
try {
|
||
const cfg = readMarketsAuthConfig()
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
} catch {}
|
||
return 120 * 24 * 3600 * 1000
|
||
}
|
||
const getMarketsGuidConfig = () => {
|
||
try {
|
||
const cfg = readMarketsAuthConfig()
|
||
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 hasValidMarketsGuid = (req) => {
|
||
try {
|
||
const cfg = readMarketsAuthConfig()
|
||
if (cfg.enable_auth === false) return false
|
||
const { key, value } = getMarketsGuidConfig()
|
||
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 hasMarketsAuth = (req) => {
|
||
try {
|
||
const config = readMarketsAuthConfig()
|
||
if (config.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = getMarketsCookieName()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
const isMarketsForceGuid = () => {
|
||
try {
|
||
const cfg = readMarketsAuthConfig()
|
||
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 }
|
||
}
|
||
app.get('/tools/markets', (req, res, next) => {
|
||
try {
|
||
const forceGuid = isMarketsForceGuid()
|
||
if (hasMarketsAuth(req)) return next()
|
||
const guidKey = getMarketsGuidConfig().key
|
||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||
if (hasValidMarketsGuid(req)) {
|
||
const maxAgeMarkets = getMarketsMaxAge()
|
||
const cookieName = getMarketsCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeMarkets, path: '/' })
|
||
return next()
|
||
}
|
||
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(readMarketsJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-markets')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeMarkets = getMarketsMaxAge()
|
||
const cookieName = getMarketsCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeMarkets, path: '/' })
|
||
return res.redirect('/tools/markets')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/markets', (req, res, next) => {
|
||
if (hasMarketsAuth(req)) return next()
|
||
const guidKey = getMarketsGuidConfig().key
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && hasValidMarketsGuid(req)) return next()
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/markets', (req, res, next) => {
|
||
if (hasMarketsAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const WALL_AUTH_COOKIE = 'wall_gate'
|
||
const readWallJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'wall.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasWallAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'wall', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'wall', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return WALL_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/wall', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readWallJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-wall-')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeWall = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'wall', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'wall', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return WALL_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeWall, path: '/' })
|
||
return res.redirect('/tools/wall')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/wall', (req, res, next) => {
|
||
if (hasWallAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/wall', (req, res, next) => {
|
||
if (hasWallAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const PRIVATE_CLIPBOARD_AUTH_COOKIE = 'private_clipboard_gate'
|
||
const readPrivateClipboardAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'private_clipboard', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readPrivateClipboardJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'private_clipboard.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getPrivateClipboardCookieName = () => {
|
||
try {
|
||
const cfg = readPrivateClipboardAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return PRIVATE_CLIPBOARD_AUTH_COOKIE
|
||
}
|
||
const getPrivateClipboardMaxAge = () => {
|
||
try {
|
||
const cfg = readPrivateClipboardAuthConfig()
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
} catch {}
|
||
return 120 * 24 * 3600 * 1000
|
||
}
|
||
const getPrivateClipboardGuidConfig = () => {
|
||
try {
|
||
const cfg = readPrivateClipboardAuthConfig()
|
||
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 hasValidPrivateClipboardGuid = (req) => {
|
||
try {
|
||
const cfg = readPrivateClipboardAuthConfig()
|
||
if (cfg.enable_auth === false) return false
|
||
const { key, value } = getPrivateClipboardGuidConfig()
|
||
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 hasPrivateClipboardAuth = (req) => {
|
||
try {
|
||
const cfg = readPrivateClipboardAuthConfig()
|
||
if (cfg.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = getPrivateClipboardCookieName()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
const isPrivateClipboardForceGuid = () => {
|
||
try {
|
||
const cfg = readPrivateClipboardAuthConfig()
|
||
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 true
|
||
} catch { return true }
|
||
}
|
||
app.get('/tools/private_clipboard', (req, res, next) => {
|
||
try {
|
||
const forceGuid = isPrivateClipboardForceGuid()
|
||
if (hasPrivateClipboardAuth(req)) return next()
|
||
const guidKey = getPrivateClipboardGuidConfig().key
|
||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||
if (hasValidPrivateClipboardGuid(req)) {
|
||
const maxAgeClipboard = getPrivateClipboardMaxAge()
|
||
const cookieName = getPrivateClipboardCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeClipboard, path: '/' })
|
||
return next()
|
||
}
|
||
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(readPrivateClipboardJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-private_clipboard')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeClipboard = getPrivateClipboardMaxAge()
|
||
const cookieName = getPrivateClipboardCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeClipboard, path: '/' })
|
||
return next()
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/private_clipboard', (req, res, next) => {
|
||
const forceGuid = isPrivateClipboardForceGuid()
|
||
if (hasPrivateClipboardAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && hasValidPrivateClipboardGuid(req)) return next()
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/uploads/private_clipboard', (req, res, next) => {
|
||
if (hasPrivateClipboardAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/private_clipboard', (req, res, next) => {
|
||
if (hasPrivateClipboardAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
app.use('/api/private_clipboard', (req, res, next) => {
|
||
if (privateClipboardRouter) return privateClipboardRouter(req, res, next)
|
||
return res.status(503).json({ ok: false, error: 'service_not_ready' })
|
||
})
|
||
|
||
const WEEKLY_AUTH_COOKIE = 'weekly_gate'
|
||
const readWeeklyJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'weekly.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasWeeklyAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'weekly', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'weekly', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return WEEKLY_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/weekly', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readWeeklyJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-weekly')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeWeekly = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'weekly', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'weekly', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return WEEKLY_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeWeekly })
|
||
return res.redirect('/tools/weekly')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/weekly', (req, res, next) => {
|
||
if (hasWeeklyAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/uploads/weekly', (req, res, next) => {
|
||
if (hasWeeklyAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/weekly', (req, res, next) => {
|
||
try {
|
||
const p = String(req.path || '')
|
||
if (p.startsWith('/ext/')) return next()
|
||
if (p === '/auth/local_creds') return next()
|
||
} catch {}
|
||
if (hasWeeklyAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
const CLOUD_NOTES_AUTH_COOKIE = 'cloud_notes_gate'
|
||
const hasCloudNotesAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'cloud_notes', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud_notes', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CLOUD_NOTES_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
const getCloudNotesCookieName = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud_notes', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CLOUD_NOTES_AUTH_COOKIE
|
||
}
|
||
app.post('/api/cloud_notes/login', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const u = cloudNotes.login(String(body.username || ''), String(body.password || ''))
|
||
if (!u) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
const maxAgeCN = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud_notes', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 1 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = getCloudNotesCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeCN })
|
||
res.json({ ok: true, user: { id: u.id, username: u.username } })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/logout', (req, res) => {
|
||
try {
|
||
const cookieName = getCloudNotesCookieName()
|
||
res.clearCookie(cookieName, { httpOnly: true, sameSite: 'lax' })
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/auth/local_creds', (req, res) => {
|
||
try {
|
||
const creds = readLocalCreds()
|
||
if (!checkLocalCredsToken(req, creds)) return sendNoCreds(res)
|
||
const u = creds.CLOUD_NOTES_USERNAME || ''
|
||
const p = creds.CLOUD_NOTES_PASSWORD || ''
|
||
if (!u && !p) return sendNoCreds(res)
|
||
res.json({ ok: true, creds: { username: u, password: p } })
|
||
} catch { sendNoCreds(res) }
|
||
})
|
||
app.get('/tools/cloud_notes', (req, res) => {
|
||
try {
|
||
return res.sendFile(path.join(process.cwd(), 'public', 'tools', 'cloud_notes', 'index.html'))
|
||
} catch { return res.status(404).send('Not Found') }
|
||
})
|
||
app.use('/uploads/cloud_notes', (req, res, next) => {
|
||
if (hasCloudNotesAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/cloud_notes', (req, res, next) => {
|
||
if (req.path === '/login' || req.path === '/logout' || req.path === '/auth/local_creds') return next()
|
||
if (hasCloudNotesAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
app.get('/api/cloud_notes/me', (req, res) => {
|
||
try {
|
||
if (!hasCloudNotesAuth(req)) return res.status(401).json({ ok: false })
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1, username: '用户' } })()
|
||
res.json({ ok: true, user: { id: u.id, username: String(u.username || '用户') } })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const cnImagesDir = path.join(process.cwd(), 'uploads', 'cloud_notes', 'images')
|
||
if (!fs.existsSync(cnImagesDir)) fs.mkdirSync(cnImagesDir, { recursive: true })
|
||
const cnFilesDir = path.join(process.cwd(), 'uploads', 'cloud_notes', 'files')
|
||
if (!fs.existsSync(cnFilesDir)) fs.mkdirSync(cnFilesDir, { recursive: true })
|
||
app.post('/api/cloud_notes/upload/image', express.raw({ type: 'application/octet-stream', limit: '50mb' }), (req, res) => {
|
||
try {
|
||
const raw = req.body || Buffer.alloc(0)
|
||
const name = String(req.query.filename || 'image').replace(/[<>:"/\\|?*]/g, '_')
|
||
const out = `${Date.now()}-${name}`
|
||
fs.writeFileSync(path.join(cnImagesDir, out), raw)
|
||
res.json({ ok: true, path: `/uploads/cloud_notes/images/${out}` })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/attachment', express.raw({ type: 'application/octet-stream', limit: '200mb' }), (req, res) => {
|
||
try {
|
||
const raw = req.body || Buffer.alloc(0)
|
||
const name = String(req.query.filename || 'file.bin').replace(/[<>:"/\\|?*]/g, '_')
|
||
const note_id = parseInt(String(req.query.note_id || '0'), 10) || 0
|
||
const out = `${Date.now()}-${name}`
|
||
const full = path.join(cnFilesDir, out)
|
||
fs.writeFileSync(full, raw)
|
||
const stats = fs.statSync(full)
|
||
const id = cloudNotes.createAttachment({ note_id, filename: name, file_path: `/uploads/cloud_notes/files/${out}`, file_size: stats.size })
|
||
res.json({ ok: true, id, path: `/uploads/cloud_notes/files/${out}` })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/attachments', (req, res) => {
|
||
try {
|
||
const noteId = parseInt(String(req.query.note_id || '0'), 10) || 0
|
||
if (!noteId) return res.json({ ok: true, rows: [] })
|
||
const rows = cloudNotes.listAttachments(noteId)
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
|
||
const summarizeNoteHtml = html => {
|
||
const plain = String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||
if (!plain) return ''
|
||
if (plain.length <= 120) return plain
|
||
return plain.slice(0, 120) + '...'
|
||
}
|
||
|
||
const WEEKLY_PASSPHRASE = process.env.SYSTEM_AUTH_PASS_93220 || process.env.WEEKLY_PASSWORD || ''
|
||
const weeklyCfgPath = path.join(process.cwd(), 'config', 'weekly.json')
|
||
const getWeeklyConfig = () => {
|
||
try {
|
||
if (fs.existsSync(weeklyCfgPath)) return JSON.parse(fs.readFileSync(weeklyCfgPath, 'utf-8'))
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const getWeeklyBaseUrl = () => {
|
||
const cfg = getWeeklyConfig()
|
||
const base = cfg && cfg.internal_base_url ? String(cfg.internal_base_url).trim() : ''
|
||
if (base) return base
|
||
const p = process.env.PORT || 5050
|
||
return `http://127.0.0.1:${p}`
|
||
}
|
||
|
||
const callWeeklyCloudNote = async payload => {
|
||
try {
|
||
const url = `${getWeeklyBaseUrl()}/api/weekly/ext/cloud_note`
|
||
const resp = await axios.post(url, Object.assign({ key: WEEKLY_PASSPHRASE }, payload || {}), { timeout: 5000 })
|
||
const data = resp && resp.data ? resp.data : {}
|
||
return {
|
||
ok: !!data.ok,
|
||
id: data.id || 0,
|
||
week_id: data.week_id || '',
|
||
deleted: !!data.deleted,
|
||
skipped: !!data.skipped,
|
||
reason: data.reason || '',
|
||
error: data.ok ? '' : String(data.error || '')
|
||
}
|
||
} catch (e) {
|
||
return { ok: false, id: 0, week_id: '', deleted: false, skipped: false, reason: '', error: String(e && e.message || e || 'error') }
|
||
}
|
||
}
|
||
|
||
const sortNotesForList = rows => {
|
||
const list = Array.isArray(rows) ? rows.slice() : []
|
||
list.sort((a, b) => {
|
||
const ap = a && a.pinned ? 1 : 0
|
||
const bp = b && b.pinned ? 1 : 0
|
||
if (ap !== bp) return bp - ap
|
||
const as = a && a.starred ? 1 : 0
|
||
const bs = b && b.starred ? 1 : 0
|
||
if (as !== bs) return bs - as
|
||
const at = String((a && a.title) || '')
|
||
const bt = String((b && b.title) || '')
|
||
if (at === bt) return (a && a.id ? a.id : 0) - (b && b.id ? b.id : 0)
|
||
try { return at.localeCompare(bt) } catch { return at > bt ? 1 : -1 }
|
||
})
|
||
return list
|
||
}
|
||
app.get('/api/cloud_notes/dashboard', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const data = cloudNotes.dashboard(parseInt(String(u.id||'1'),10))
|
||
res.json({ ok: true, data })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.delete('/api/cloud_notes/attachments/:id', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.params.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
cloudNotes.deleteAttachmentWithFile(id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/note/save', async (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const body = Object.assign({}, req.body || {}, { user_id: parseInt(String(u.id||'1'),10) })
|
||
const rawId = parseInt(String(body.id || '0'), 10) || 0
|
||
const isCreate = !rawId
|
||
const result = cloudNotes.saveNote(body)
|
||
if (result && result.conflict) {
|
||
return res.status(409).json({ ok: false, conflict: true, latest: result.latest || null })
|
||
}
|
||
const id = result && result.note_id ? result.note_id : 0
|
||
const version_id = result && result.version_id ? result.version_id : 0
|
||
let weekly_sync = null
|
||
try {
|
||
const row = cloudNotes.getNote(id)
|
||
const summary = summarizeNoteHtml(row && row.html)
|
||
weekly_sync = await callWeeklyCloudNote({
|
||
action: isCreate ? 'create' : 'update',
|
||
note_id: id,
|
||
title: row && row.title,
|
||
summary,
|
||
pinned: row && row.pinned,
|
||
starred: row && row.starred
|
||
})
|
||
} catch (e) {
|
||
weekly_sync = { ok: false, id: 0, week_id: '', deleted: false, skipped: false, reason: '', error: String(e && e.message || e || 'error') }
|
||
}
|
||
res.json({ ok: true, id, version_id, weekly_sync })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/note/clone_attachments', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const srcId = parseInt(String(body.src_id || '0'), 10) || 0
|
||
const destId = parseInt(String(body.dest_id || '0'), 10) || 0
|
||
if (!srcId || !destId || srcId === destId) {
|
||
return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
}
|
||
cloudNotes.cloneAttachments(srcId, destId)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/note/versions', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const versions = cloudNotes.listVersions(id)
|
||
res.json({ ok: true, versions })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/note/list', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudNotes.listNotes(parseInt(String(u.id||'1'),10))
|
||
res.json({ ok: true, rows: sortNotesForList(rows) })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/note/list_by_tag', (req, res) => {
|
||
try {
|
||
const tagId = parseInt(String(req.query.tag_id || '0'), 10) || 0
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudNotes.listNotesByTag(parseInt(String(u.id||'1'),10), tagId)
|
||
res.json({ ok: true, rows: sortNotesForList(rows) })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/note/trash_list', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudNotes.listTrashNotes(parseInt(String(u.id||'1'),10))
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/note/search', (req, res) => {
|
||
try {
|
||
const q = String(req.query.q || '').trim()
|
||
if (!q) return res.json({ ok: true, rows: [], keyword: '' })
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudNotes.searchNotes(parseInt(String(u.id||'1'),10), q)
|
||
res.json({ ok: true, rows, keyword: q })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/note/get', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const row = cloudNotes.getNote(id)
|
||
res.json({ ok: true, row })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/note/star', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
cloudNotes.setStar({ id: parseInt(String(body.id||'0'),10), starred: !!body.starred })
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/note/pin', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
cloudNotes.setPin({ id: parseInt(String(body.id||'0'),10), pinned: !!body.pinned })
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/note/trash', async (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const id = parseInt(String(body.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
let weekly_sync = null
|
||
try {
|
||
const row = cloudNotes.getNote(id)
|
||
const summary = summarizeNoteHtml(row && row.html)
|
||
weekly_sync = await callWeeklyCloudNote({
|
||
action: 'soft_delete',
|
||
note_id: id,
|
||
title: row && row.title,
|
||
summary,
|
||
pinned: row && row.pinned,
|
||
starred: row && row.starred
|
||
})
|
||
} catch (e) {
|
||
weekly_sync = { ok: false, id: 0, week_id: '', deleted: false, skipped: false, reason: '', error: String(e && e.message || e || 'error') }
|
||
}
|
||
cloudNotes.softDeleteNote(id)
|
||
res.json({ ok: true, weekly_sync })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/note/restore', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const id = parseInt(String(body.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
cloudNotes.restoreNote(id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/note/trash_delete', async (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const id = parseInt(String(body.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
let weekly_sync = null
|
||
try {
|
||
weekly_sync = await callWeeklyCloudNote({ action: 'hard_delete', note_id: id })
|
||
} catch (e) {
|
||
weekly_sync = { ok: false, id: 0, week_id: '', deleted: false, skipped: false, reason: '', error: String(e && e.message || e || 'error') }
|
||
}
|
||
cloudNotes.hardDeleteNote(id)
|
||
res.json({ ok: true, weekly_sync })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/tags/list', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudNotes.listTags(parseInt(String(u.id||'1'),10))
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/tags/save', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudNotes.login('probe', ''); return c || { id: 1 } })()
|
||
const body = req.body || {}
|
||
const tags = Array.isArray(body.tags) ? body.tags : []
|
||
cloudNotes.saveTags(parseInt(String(u.id||'1'),10), tags)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_notes/settings/tiny_toolbar', (req, res) => {
|
||
try {
|
||
const settings = cloudNotes.getTinyToolbarSettings()
|
||
res.json({ ok: true, settings })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_notes/settings/tiny_toolbar', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const saved = cloudNotes.saveTinyToolbarSettings(body.settings || {})
|
||
res.json({ ok: true, settings: saved })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const CLOUD_SHEETS_AUTH_COOKIE = 'cloud_sheets_gate'
|
||
const getCloudSheetsCookieName = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud_sheets', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CLOUD_SHEETS_AUTH_COOKIE
|
||
}
|
||
const hasCloudSheetsAuth = req => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'cloud_sheets', 'auth_config.json')
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||
if (config.enable_auth === false) return true
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
return cookies[getCloudSheetsCookieName()] === '1'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
app.post('/api/cloud_sheets/login', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const u = cloudSheets.login(String(body.username || ''), String(body.password || ''))
|
||
if (!u) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
const maxAge = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'cloud_sheets', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 1 * 24 * 3600 * 1000
|
||
})()
|
||
res.cookie(getCloudSheetsCookieName(), '1', { httpOnly: true, sameSite: 'lax', maxAge })
|
||
res.json({ ok: true, user: { id: u.id, username: u.username } })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/logout', (req, res) => {
|
||
try {
|
||
res.clearCookie(getCloudSheetsCookieName(), { httpOnly: true, sameSite: 'lax' })
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_sheets/auth/local_creds', (req, res) => {
|
||
try {
|
||
const creds = readLocalCreds()
|
||
if (!checkLocalCredsToken(req, creds)) return sendNoCreds(res)
|
||
const u = creds.CLOUD_SHEETS_USERNAME || ''
|
||
const p = creds.CLOUD_SHEETS_PASSWORD || ''
|
||
if (!u && !p) return sendNoCreds(res)
|
||
res.json({ ok: true, creds: { username: u, password: p } })
|
||
} catch { sendNoCreds(res) }
|
||
})
|
||
app.get('/tools/cloud_sheets', (req, res) => {
|
||
try {
|
||
return res.sendFile(path.join(process.cwd(), 'public', 'tools', 'cloud_sheets', 'index.html'))
|
||
} catch { return res.status(404).send('Not Found') }
|
||
})
|
||
app.use('/uploads/cloud_sheets', (req, res, next) => {
|
||
if (hasCloudSheetsAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/cloud_sheets', (req, res, next) => {
|
||
if (req.path === '/login' || req.path === '/logout' || req.path === '/auth/local_creds') return next()
|
||
if (hasCloudSheetsAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
app.get('/api/cloud_sheets/me', (req, res) => {
|
||
try {
|
||
if (!hasCloudSheetsAuth(req)) return res.status(401).json({ ok: false })
|
||
const u = (() => { const c = cloudSheets.login('probe', ''); return c || { id: 1, username: '用户' } })()
|
||
res.json({ ok: true, user: { id: u.id, username: String(u.username || '用户') } })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const csFilesDir = path.join(process.cwd(), 'uploads', 'cloud_sheets', 'files')
|
||
if (!fs.existsSync(csFilesDir)) fs.mkdirSync(csFilesDir, { recursive: true })
|
||
app.post('/api/cloud_sheets/attachment', express.raw({ type: 'application/octet-stream', limit: '200mb' }), (req, res) => {
|
||
try {
|
||
const raw = req.body || Buffer.alloc(0)
|
||
const name = String(req.query.filename || 'file.bin').replace(/[<>:"/\\|?*]/g, '_')
|
||
const sheet_id = parseInt(String(req.query.sheet_id || '0'), 10) || 0
|
||
const out = `${Date.now()}-${name}`
|
||
const full = path.join(csFilesDir, out)
|
||
fs.writeFileSync(full, raw)
|
||
const stats = fs.statSync(full)
|
||
const id = cloudSheets.createAttachment({ sheet_id, filename: name, file_path: `/uploads/cloud_sheets/files/${out}`, file_size: stats.size })
|
||
res.json({ ok: true, id, path: `/uploads/cloud_sheets/files/${out}` })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_sheets/attachments', (req, res) => {
|
||
try {
|
||
const sheetId = parseInt(String(req.query.sheet_id || '0'), 10) || 0
|
||
if (!sheetId) return res.json({ ok: true, rows: [] })
|
||
const rows = cloudSheets.listAttachments(sheetId)
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.delete('/api/cloud_sheets/attachments/:id', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.params.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
cloudSheets.deleteAttachmentWithFile(id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/sheet/save', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudSheets.login('probe', ''); return c || { id: 1 } })()
|
||
const body = Object.assign({}, req.body || {}, { user_id: parseInt(String(u.id || '1'), 10) })
|
||
const result = cloudSheets.saveSheet(body)
|
||
if (result && result.conflict) {
|
||
return res.status(409).json({ ok: false, conflict: true, latest: result.latest || null })
|
||
}
|
||
const id = result && result.sheet_id ? result.sheet_id : 0
|
||
const version_id = result && result.version_id ? result.version_id : 0
|
||
res.json({ ok: true, id, version_id })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/sheet/clone_attachments', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const srcId = parseInt(String(body.src_id || '0'), 10) || 0
|
||
const destId = parseInt(String(body.dest_id || '0'), 10) || 0
|
||
if (!srcId || !destId || srcId === destId) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
cloudSheets.cloneAttachments(srcId, destId)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_sheets/sheet/list', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudSheets.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudSheets.listSheets(parseInt(String(u.id || '1'), 10))
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_sheets/sheet/trash_list', (req, res) => {
|
||
try {
|
||
const u = (() => { const c = cloudSheets.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudSheets.listTrashSheets(parseInt(String(u.id || '1'), 10))
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_sheets/sheet/search', (req, res) => {
|
||
try {
|
||
const q = String(req.query.q || '').trim()
|
||
if (!q) return res.json({ ok: true, rows: [], keyword: '' })
|
||
const u = (() => { const c = cloudSheets.login('probe', ''); return c || { id: 1 } })()
|
||
const rows = cloudSheets.searchSheets(parseInt(String(u.id || '1'), 10), q)
|
||
res.json({ ok: true, rows, keyword: q })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_sheets/sheet/get', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const row = cloudSheets.getSheet(id)
|
||
res.json({ ok: true, row })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud_sheets/sheet/versions', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const versions = cloudSheets.listVersions(id)
|
||
res.json({ ok: true, versions })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/sheet/star', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
cloudSheets.setStar({ id: parseInt(String(body.id || '0'), 10), starred: !!body.starred })
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/sheet/pin', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
cloudSheets.setPin({ id: parseInt(String(body.id || '0'), 10), pinned: !!body.pinned })
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/sheet/trash', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const id = parseInt(String(body.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
cloudSheets.softDeleteSheet(id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/sheet/restore', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const id = parseInt(String(body.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
cloudSheets.restoreSheet(id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud_sheets/sheet/trash_delete', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const id = parseInt(String(body.id || '0'), 10) || 0
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
cloudSheets.hardDeleteSheet(id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const DOC_CLOUD_KEEPER_AUTH_COOKIE = 'doc_cloud_keeper_gate'
|
||
const getDocCloudKeeperCookieName = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'doc_cloud_keeper', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return DOC_CLOUD_KEEPER_AUTH_COOKIE
|
||
}
|
||
const hasDocCloudKeeperAuth = req => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'doc_cloud_keeper', 'auth_config.json')
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||
if (config.enable_auth === false) return true
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
return cookies[getDocCloudKeeperCookieName()] === '1'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
const readDocCloudKeeperJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'doc_cloud_keeper.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const syncDocToCalendarUpsert = async docId => {
|
||
try {
|
||
const id = parseInt(String(docId || '0'), 10) || 0
|
||
if (!id) return { ok: false, error: 'bad_doc_id' }
|
||
const doc = docCloudKeeper.getDocument(id)
|
||
if (!doc || doc.deleted_at) return await docCalendarBridge.remove(id)
|
||
const folder = docCloudKeeper.getFolderById ? docCloudKeeper.getFolderById(doc.folder_id) : null
|
||
const folderName = folder ? String(folder.name || '') : '根目录'
|
||
return await docCalendarBridge.upsert(doc, folderName)
|
||
} catch (e) {
|
||
return { ok: false, error: String(e && e.message ? e.message : e) }
|
||
}
|
||
}
|
||
const syncDocToCalendarRemove = async docId => {
|
||
try { return await docCalendarBridge.remove(docId) } catch (e) { return { ok: false, error: String(e && e.message ? e.message : e) } }
|
||
}
|
||
app.post('/api/doc_cloud_keeper/login', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const u = docCloudKeeper.login(String(body.username || ''), String(body.password || ''))
|
||
if (!u) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
const maxAge = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'doc_cloud_keeper', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 1 * 24 * 3600 * 1000
|
||
})()
|
||
res.cookie(getDocCloudKeeperCookieName(), '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
res.json({ ok: true, user: { id: u.id, username: u.username } })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/logout', (req, res) => {
|
||
try {
|
||
res.clearCookie(getDocCloudKeeperCookieName(), { httpOnly: true, sameSite: 'lax', path: '/' })
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/auth/local_creds', (req, res) => {
|
||
try {
|
||
const creds = readLocalCreds()
|
||
if (!checkLocalCredsToken(req, creds)) return sendNoCreds(res)
|
||
const u = creds.DOCK_USERNAME || ''
|
||
const p = creds.DOCK_PASSWORD || ''
|
||
if (!u && !p) return sendNoCreds(res)
|
||
res.json({ ok: true, creds: { username: u, password: p } })
|
||
} catch { sendNoCreds(res) }
|
||
})
|
||
app.get('/tools/doc_cloud_keeper', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '').trim()
|
||
if (token) {
|
||
let payload = verifyJwtWithKeys(token, jwkKeys(readDocCloudKeeperJwk()))
|
||
if (!payload) {
|
||
try {
|
||
const dbg = debugVerifyJwt(token, [], { issStrict: false })
|
||
const now = Math.floor(Date.now() / 1000)
|
||
const exp = Number((dbg && dbg.payload && dbg.payload.exp) || 0)
|
||
const inGrace = Number.isFinite(exp) && exp > 0 && now > exp && (now - exp) <= (24 * 3600)
|
||
if (dbg && dbg.sig_ok && dbg.payload && inGrace) payload = dbg.payload
|
||
} catch {}
|
||
}
|
||
if (payload) {
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-doc_cloud_keeper')) {
|
||
// invalid aud, just fall through to index.html
|
||
} else {
|
||
const maxAge = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'doc_cloud_keeper', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 180 * 24 * 3600 * 1000
|
||
})()
|
||
res.cookie(getDocCloudKeeperCookieName(), '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/doc_cloud_keeper/index.html')
|
||
}
|
||
}
|
||
}
|
||
return res.sendFile(path.join(process.cwd(), 'public', 'tools', 'doc_cloud_keeper', 'index.html'))
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/uploads/doc_cloud_keeper', (req, res, next) => {
|
||
setNoCache(res)
|
||
if (hasDocCloudKeeperAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
const docCloudKeeperAgentTokens = new Map()
|
||
|
||
app.use('/api/doc_cloud_keeper', (req, res, next) => {
|
||
if (req.path === '/login' || req.path === '/logout' || req.path === '/auth/local_creds') return next()
|
||
if (req.path === '/docs/agent_upload' || req.path === '/docs/download') {
|
||
// 允许助手凭借有效 token 直接上传或下载,跳过 cookie 鉴权
|
||
const token = String(req.headers['authorization'] || '').replace('Bearer ', '').trim()
|
||
if (token) {
|
||
const docId = parseInt(String(req.query.id || req.query.doc_id || '0'), 10) || 0
|
||
const valid = docCloudKeeperAgentTokens.get(token)
|
||
if (valid && valid.doc_id === docId && Date.now() < valid.exp) {
|
||
return next()
|
||
}
|
||
}
|
||
}
|
||
if (hasDocCloudKeeperAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
app.use('/api/doc_calendar_bridge', (req, res, next) => {
|
||
if (hasDocCloudKeeperAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
app.get('/api/doc_cloud_keeper/me', (req, res) => {
|
||
try {
|
||
if (!hasDocCloudKeeperAuth(req)) return res.status(401).json({ ok: false })
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
res.json({ ok: true, user: { id: u.id, username: String(u.username || 'admin') } })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const dckTempDir = path.join(process.cwd(), 'uploads', 'doc_cloud_keeper', 'temp')
|
||
if (!fs.existsSync(dckTempDir)) fs.mkdirSync(dckTempDir, { recursive: true })
|
||
app.post('/api/doc_cloud_keeper/upload/temp', express.raw({ type: 'application/octet-stream', limit: '300mb' }), (req, res) => {
|
||
try {
|
||
const raw = req.body || Buffer.alloc(0)
|
||
const filename = String(req.query.filename || 'document.txt')
|
||
const sha256 = String(req.query.sha256 || '')
|
||
const out = docCloudKeeper.createTempUpload(filename, raw, sha256)
|
||
res.json({ ok: true, temp_name: out.temp_name, size_bytes: out.size_bytes, ext: out.ext, sha256: out.sha256 })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/oss_probe/upload', express.raw({ type: '*/*', limit: '300mb' }), async (req, res) => {
|
||
try {
|
||
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '')
|
||
const filename = String(req.query.filename || 'oss_probe.txt')
|
||
const mime = String(req.query.mime || 'application/octet-stream')
|
||
const out = await docCloudKeeper.probeUploadToOss({ filename, mime, buffer: raw })
|
||
res.json(out)
|
||
} catch (e) {
|
||
res.json({
|
||
ok: false,
|
||
logs: [
|
||
'开始 OSS 检测上传',
|
||
'上传过程中出现异常,流程已中断',
|
||
String(e && e.message ? e.message : e)
|
||
],
|
||
error: String(e && e.message ? e.message : e)
|
||
})
|
||
}
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/create', (req, res) => {
|
||
try {
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
const id = docCloudKeeper.createDocumentByTemp(Object.assign({}, req.body || {}, { user_id: u.id }))
|
||
syncDocToCalendarUpsert(id).catch(() => {})
|
||
res.json({ ok: true, id })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/overwrite', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
docCloudKeeper.overwriteDocumentByTemp(body)
|
||
syncDocToCalendarUpsert(body.id).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/update', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
docCloudKeeper.updateDocumentMeta(body)
|
||
syncDocToCalendarUpsert(body.id).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/rename', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
docCloudKeeper.renameDocument(body.id, body.filename)
|
||
syncDocToCalendarUpsert(body.id).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/move', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
docCloudKeeper.moveDocument(body.id, body.folder_id)
|
||
syncDocToCalendarUpsert(body.id).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/trash', (req, res) => {
|
||
try {
|
||
const id = (req.body || {}).id
|
||
docCloudKeeper.softDeleteDocument(id)
|
||
syncDocToCalendarRemove(id).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/restore', (req, res) => {
|
||
try {
|
||
const id = (req.body || {}).id
|
||
docCloudKeeper.restoreDocument(id)
|
||
syncDocToCalendarUpsert(id).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/trash_delete', (req, res) => {
|
||
try {
|
||
const id = (req.body || {}).id
|
||
docCloudKeeper.hardDeleteDocument(id)
|
||
syncDocToCalendarRemove(id).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/bulk_trash', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const out = docCloudKeeper.bulkSoftDelete(body.ids || [])
|
||
Promise.allSettled((body.ids || []).map(id => syncDocToCalendarRemove(id)))
|
||
res.json({ ok: true, result: out })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/bulk_move', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const out = docCloudKeeper.bulkMove(body.ids || [], body.folder_id)
|
||
Promise.allSettled((body.ids || []).map(id => syncDocToCalendarUpsert(id)))
|
||
res.json({ ok: true, result: out })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/bulk_trash_delete', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const out = docCloudKeeper.bulkHardDelete(body.ids || [])
|
||
Promise.allSettled((body.ids || []).map(id => syncDocToCalendarRemove(id)))
|
||
res.json({ ok: true, result: out })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_calendar_bridge/upsert', async (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const out = await syncDocToCalendarUpsert(body.doc_id)
|
||
res.json({ ok: !!out.ok, result: out })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_calendar_bridge/remove', async (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const out = await syncDocToCalendarRemove(body.doc_id)
|
||
res.json({ ok: !!out.ok, result: out })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_calendar_bridge/restore', async (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const out = await syncDocToCalendarUpsert(body.doc_id)
|
||
res.json({ ok: !!out.ok, result: out })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_calendar_bridge/by_doc_id', (req, res) => {
|
||
try {
|
||
const docId = parseInt(String(req.query.doc_id || '0'), 10) || 0
|
||
const row = docCalendarBridge.byDocId(docId)
|
||
res.json({ ok: true, row })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_calendar_bridge/sync_full', async (req, res) => {
|
||
try {
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
const active = docCloudKeeper.listDocuments({ user_id: u.id, include_deleted: false, sort_by: 'updated_at', sort_dir: 'desc' }) || []
|
||
const trashed = docCloudKeeper.listDocuments({ user_id: u.id, include_deleted: true, sort_by: 'updated_at', sort_dir: 'desc' }) || []
|
||
const upsertResults = await Promise.all(active.map(r => syncDocToCalendarUpsert(r.id)))
|
||
const removeResults = await Promise.all(trashed.map(r => syncDocToCalendarRemove(r.id)))
|
||
res.json({
|
||
ok: true,
|
||
stats: {
|
||
active: active.length,
|
||
trashed: trashed.length,
|
||
upsert_ok: upsertResults.filter(x => x && x.ok).length,
|
||
remove_ok: removeResults.filter(x => x && x.ok).length
|
||
}
|
||
})
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/list', (req, res) => {
|
||
try {
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
const rows = docCloudKeeper.listDocuments({
|
||
user_id: u.id,
|
||
include_deleted: String(req.query.trash || '') === '1',
|
||
q: req.query.q,
|
||
folder_id: req.query.folder_id,
|
||
priority: req.query.priority,
|
||
status: req.query.status,
|
||
sort_by: req.query.sort_by,
|
||
sort_dir: req.query.sort_dir
|
||
})
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/get', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const row = docCloudKeeper.getDocument(id)
|
||
res.json({ ok: true, row })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/get_edit_token', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.body.id || '0'), 10) || 0
|
||
const row = docCloudKeeper.getDocument(id)
|
||
if (!row) throw new Error('Document not found')
|
||
|
||
// Generate a short-lived token (1 hour) specifically for the local agent
|
||
const crypto = require('crypto')
|
||
const token = crypto.randomBytes(32).toString('hex')
|
||
docCloudKeeperAgentTokens.set(token, {
|
||
doc_id: id,
|
||
exp: Date.now() + 3600 * 1000 // 1 hour
|
||
})
|
||
|
||
// Cleanup old tokens occasionally
|
||
if (docCloudKeeperAgentTokens.size > 100) {
|
||
const now = Date.now()
|
||
for (const [k, v] of docCloudKeeperAgentTokens.entries()) {
|
||
if (now > v.exp) docCloudKeeperAgentTokens.delete(k)
|
||
}
|
||
}
|
||
|
||
res.json({ ok: true, token, doc_id: id, filename: row.filename })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/agent_upload', express.raw({ type: 'application/octet-stream', limit: '300mb' }), (req, res) => {
|
||
try {
|
||
// 助手直接上传修改后的文件内容,需要提取 doc_id 和文件流
|
||
const raw = req.body || Buffer.alloc(0)
|
||
const docId = parseInt(String(req.query.doc_id || '0'), 10) || 0
|
||
const explicitSize = parseInt(String(req.query.size_bytes || '0'), 10) || raw.length
|
||
const row = docCloudKeeper.getDocument(docId)
|
||
if (!row) throw new Error('Document not found')
|
||
|
||
// 复用 overwrite 的逻辑:先存到 temp,再覆盖
|
||
const crypto = require('crypto')
|
||
const sha256 = crypto.createHash('sha256').update(raw).digest('hex')
|
||
const out = docCloudKeeper.createTempUpload(row.filename, raw, sha256)
|
||
|
||
docCloudKeeper.overwriteDocumentByTemp({
|
||
id: docId,
|
||
temp_name: out.temp_name,
|
||
size_bytes: explicitSize
|
||
})
|
||
syncDocToCalendarUpsert(docId).catch(() => {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const dckAttachmentName = rawName => {
|
||
const safe = String(rawName || 'document').replace(/[\r\n"]/g, '_')
|
||
const fallback = safe.replace(/[^\x20-\x7E]/g, '_')
|
||
const utf8 = encodeURIComponent(safe)
|
||
return `attachment; filename="${fallback}"; filename*=UTF-8''${utf8}`
|
||
}
|
||
const dckFormatSize = bytes => {
|
||
const n = Number(bytes || 0)
|
||
if (!Number.isFinite(n) || n <= 0) return '0 B'
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
let v = n
|
||
let i = 0
|
||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1 }
|
||
return `${v.toFixed(i === 0 ? 0 : 2)} ${units[i]}`
|
||
}
|
||
const dckSafeFileLabel = text => String(text || '').replace(/[:]/g, ':').replace(/[|]/g, '|').replace(/[<>"/\\?*]/g, '_').trim()
|
||
const dckHistoryDownloadName = payload => {
|
||
const data = payload || {}
|
||
const filename = dckSafeFileLabel(data.filename || 'document')
|
||
const sizeStr = dckSafeFileLabel(dckFormatSize(data.size_bytes || 0))
|
||
const recordTime = dckSafeFileLabel(String(data.record_time || '').replace(/:/g, ':'))
|
||
const uploadTime = dckSafeFileLabel(String(data.upload_time || '').replace(/:/g, ':'))
|
||
const sourceName = dckSafeFileLabel(data.source_name || '下载文件')
|
||
const deletedPrefix = data.is_deleted ? '【物理删除】' : ''
|
||
const parts = [
|
||
`文件:${filename}`,
|
||
`大小:${sizeStr}`
|
||
]
|
||
if (recordTime) parts.push(`记录时间:${recordTime}`)
|
||
if (uploadTime) parts.push(`上传时间:${uploadTime}`)
|
||
parts.push(`来源:${sourceName}`)
|
||
return `${deletedPrefix}${parts.join(' | ')}`
|
||
}
|
||
app.get('/api/doc_cloud_keeper/docs/download', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const hit = docCloudKeeper.getDocumentForDownload(id)
|
||
if (!hit) return res.status(404).send('Not Found')
|
||
res.setHeader('Content-Disposition', dckAttachmentName(hit.row.filename))
|
||
res.setHeader('Content-Type', 'application/octet-stream')
|
||
res.sendFile(hit.full)
|
||
} catch (e) { res.status(500).send(String(e.message || e)) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/versions', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const versions = docCloudKeeper.listVersions(id)
|
||
res.json({ ok: true, versions })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/compare_versions', async (req, res) => {
|
||
try {
|
||
const comparison = await docCloudKeeper.compareDocumentVersions(req.body || {})
|
||
res.json({ ok: true, comparison })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/oss_versions', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const versions = docCloudKeeper.listOssBackups(id)
|
||
res.json({ ok: true, versions })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/version_download', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const hit = docCloudKeeper.getVersionForDownload(id)
|
||
if (!hit) return res.status(404).send('Not Found')
|
||
const v = hit.row
|
||
const ext = v.ext ? `.${v.ext}` : ''
|
||
const friendlyName = dckHistoryDownloadName({
|
||
filename: v.filename || `version-${v.version_no || id}${ext}`,
|
||
size_bytes: v.size_bytes || 0,
|
||
record_time: v.created_at || '',
|
||
upload_time: '',
|
||
source_name: `云服务器版本库${ext}`
|
||
})
|
||
res.setHeader('Content-Disposition', dckAttachmentName(friendlyName))
|
||
res.setHeader('Content-Type', 'application/octet-stream')
|
||
res.sendFile(hit.full)
|
||
} catch (e) { res.status(500).send(String(e.message || e)) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/oss_version_download', async (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const hit = await docCloudKeeper.createOssDownloadStream(id)
|
||
if (!hit || !hit.stream || !hit.row) return res.status(404).send('Not Found')
|
||
const row = hit.row
|
||
const ext = row.ext ? `.${row.ext}` : ''
|
||
const friendlyName = dckHistoryDownloadName({
|
||
filename: row.filename || `oss-backup-${id}${ext}`,
|
||
size_bytes: row.size_bytes || 0,
|
||
record_time: row.created_at || '',
|
||
upload_time: row.last_uploaded_at || '',
|
||
source_name: `OSS容灾库${ext}`,
|
||
is_deleted: !!row.is_physical_deleted
|
||
})
|
||
res.setHeader('Content-Disposition', dckAttachmentName(friendlyName))
|
||
res.setHeader('Content-Type', row.mime || 'application/octet-stream')
|
||
hit.stream.on('error', err => {
|
||
if (!res.headersSent) res.status(500).send(String(err && err.message ? err.message : err))
|
||
else res.destroy(err)
|
||
})
|
||
hit.stream.pipe(res)
|
||
} catch (e) { res.status(500).send(String(e.message || e)) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/docs/restore_version', (req, res) => {
|
||
try {
|
||
docCloudKeeper.restoreVersion(req.body || {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/preview', async (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const out = await docCloudKeeper.previewDocument(id)
|
||
res.json(out)
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/docs/preview_pdf', (req, res) => {
|
||
try {
|
||
const id = parseInt(String(req.query.id || '0'), 10) || 0
|
||
const hit = docCloudKeeper.getDocumentForPreview(id)
|
||
if (!hit) return res.status(404).send('Not Found')
|
||
res.setHeader('Content-Type', 'application/pdf')
|
||
res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"')
|
||
res.setHeader('Cache-Control', 'no-cache')
|
||
res.sendFile(hit.full)
|
||
} catch (e) { res.status(500).send(String(e.message || e)) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/folders/list', (req, res) => {
|
||
try {
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
const rows = docCloudKeeper.listFolders(u.id)
|
||
res.json({ ok: true, rows })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/folders/create', (req, res) => {
|
||
try {
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
const id = docCloudKeeper.createFolder(Object.assign({}, req.body || {}, { user_id: u.id }))
|
||
res.json({ ok: true, id })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/folders/update', (req, res) => {
|
||
try {
|
||
docCloudKeeper.updateFolder(req.body || {})
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/folders/trash', (req, res) => {
|
||
try {
|
||
docCloudKeeper.softDeleteFolder((req.body || {}).id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/folders/restore', (req, res) => {
|
||
try {
|
||
docCloudKeeper.restoreFolder((req.body || {}).id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/folders/trash_delete', (req, res) => {
|
||
try {
|
||
docCloudKeeper.hardDeleteFolder((req.body || {}).id)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/doc_cloud_keeper/stats', (req, res) => {
|
||
try {
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
const s = docCloudKeeper.stats(u.id)
|
||
res.json({ ok: true, stats: s })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/doc_cloud_keeper/change_password', (req, res) => {
|
||
try {
|
||
const u = docCloudKeeper.getCurrentUser()
|
||
const body = req.body || {}
|
||
docCloudKeeper.changePassword(u.id, body.old_password, body.new_password)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const WEIBO_AUTH_COOKIE = 'weibo_gate'
|
||
const readWeiboJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'weibo.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasWeiboAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'weibo', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'weibo', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return WEIBO_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/weibo', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readWeiboJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-weibo')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeWeibo = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'weibo', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'weibo', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return WEIBO_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeWeibo })
|
||
return res.redirect('/tools/weibo')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/weibo', (req, res, next) => {
|
||
if (hasWeiboAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/weibo', (req, res, next) => {
|
||
if (hasWeiboAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
app.get('/api/ping', (req, res) => {
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.get('/api/debug/https', (req, res) => {
|
||
res.json({ https: isHttps(req), xfwd: String((req.headers && req.headers['x-forwarded-proto']) || '') })
|
||
})
|
||
|
||
app.get('/api/debug/test-cookie', (req, res) => {
|
||
res.cookie('test_cookie', 'ok', { path: '/', maxAge: 60 * 1000 })
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
const CALENDAR_AUTH_COOKIE = 'calendar_reminder_gate'
|
||
const readCalendarJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'calendar_reminder.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const hasCalendarAuth = (req) => {
|
||
try {
|
||
const configPath = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
if (config.enable_auth === false) return true;
|
||
}
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const name = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CALENDAR_AUTH_COOKIE
|
||
})()
|
||
return cookies[name] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/calendar_reminder', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readCalendarJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-calendar_reminder')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAgeCal = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
const cookieName = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
}
|
||
} catch {}
|
||
return CALENDAR_AUTH_COOKIE
|
||
})()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeCal })
|
||
return res.redirect('/tools/calendar_reminder')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/calendar_reminder', (req, res, next) => {
|
||
if (hasCalendarAuth(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
|
||
app.get('/go', (req, res) => {
|
||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
|
||
res.set('Pragma', 'no-cache')
|
||
res.set('Expires', '0')
|
||
try {
|
||
const priv = readNavPrivateJwk()
|
||
if (!priv) return res.status(500).send('导航私钥未配置')
|
||
const systemId = String(req.query.systemId || '')
|
||
if (!systemId) return res.status(400).send('参数错误')
|
||
const now = Math.floor(Date.now()/1000)
|
||
const exp = now + Math.max(30, Math.min(300, Number(req.query.ttl||120)))
|
||
const jti = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2,10)
|
||
const iss = getNavIssFromFlags()
|
||
const token = signJwtRS256({ iss, aud: systemId, iat: now, exp, jti }, priv)
|
||
let dest = String(req.query.url || '')
|
||
if (systemId === 'TRAE-PSC') dest = '/tools/psc'
|
||
if (!dest) dest = mapSystemIdToUrl(systemId)
|
||
if (!dest) return res.status(400).send('目标地址缺失')
|
||
const hasQuery = dest.includes('?')
|
||
const sep = hasQuery ? '&' : '?'
|
||
const finalUrl = dest + sep + 'token=' + encodeURIComponent(token)
|
||
if (/^https?:\/\//i.test(finalUrl)) {
|
||
try {
|
||
const u = new URL(finalUrl)
|
||
const host = String(req.headers.host || '')
|
||
if (u.host !== host) {
|
||
res.set('Content-Type', 'text/html; charset=utf-8')
|
||
return res.status(200).send(`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><script>location.href=${JSON.stringify(finalUrl)}</script></body></html>`)
|
||
}
|
||
} catch {}
|
||
}
|
||
res.redirect(finalUrl)
|
||
} catch (e) {
|
||
res.status(500).send('服务器错误')
|
||
}
|
||
})
|
||
|
||
app.get('/api/debug/jwt/verify', (req, res) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
const tool = String(req.query.tool || '')
|
||
const readers = {
|
||
'expense': () => EXPENSE_JWK,
|
||
'ai-lib': () => readAiLibJwk(),
|
||
'cloud': () => readCloudJwk(),
|
||
'funds': () => readFundsJwk(),
|
||
'markets': () => readMarketsJwk(),
|
||
'wall': () => readWallJwk(),
|
||
'weekly': () => readWeeklyJwk(),
|
||
'weibo': () => readWeiboJwk()
|
||
}
|
||
const keys = jwkKeys((readers[tool] || (() => readAiLibJwk()))())
|
||
const payload = verifyJwtWithKeys(token, keys)
|
||
res.json({ ok: !!payload, keys: keys.length, payload })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/debug/nav/verify', (req, res) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
const issStrict = String(req.query.iss_strict || '') === '1' ? true : computeNavIssStrict()
|
||
const dbg = debugVerifyJwt(token, [], { issStrict })
|
||
const hasCookie = !!(req.headers && req.headers.cookie && parseCookie(req.headers.cookie || '')['nav_gate'] === '1')
|
||
res.json({ ok: dbg.ok, has_cookie: hasCookie, dbg })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/debug/nav/private', (req, res) => {
|
||
try {
|
||
const sources = []
|
||
let loaded = null
|
||
try {
|
||
const s = String(process.env.NAV_PRIVATE_JWK_JSON || process.env.NAV_PRIVATE_JWK || '')
|
||
if (s) { sources.push({ src: 'env_json', len: s.length }); loaded = JSON.parse(s) }
|
||
} catch {}
|
||
if (!loaded) {
|
||
const f = String(process.env.NAV_PRIVATE_JWK_FILE || '')
|
||
if (f) {
|
||
if (fs.existsSync(f)) { sources.push({ src: 'env_file', path: f, exists: true }); try { loaded = JSON.parse(fs.readFileSync(f, 'utf-8')) } catch {} }
|
||
else sources.push({ src: 'env_file', path: f, exists: false })
|
||
}
|
||
}
|
||
const candidates = [
|
||
path.join(process.cwd(), 'config', 'nav-private.jwk.json'),
|
||
path.resolve(__dirname, '../../config/nav-private.jwk.json'),
|
||
path.resolve(__dirname, '../config/nav-private.jwk.json')
|
||
]
|
||
for (const p of candidates) {
|
||
try {
|
||
const ex = fs.existsSync(p)
|
||
sources.push({ src: 'candidate', path: p, exists: ex })
|
||
if (!loaded && ex) { loaded = JSON.parse(fs.readFileSync(p, 'utf-8')) }
|
||
} catch {}
|
||
}
|
||
const ok = !!(loaded && loaded.kty && loaded.n && loaded.e && loaded.d)
|
||
res.json({ ok, sources, loaded_keys: loaded ? Object.keys(loaded) : [] })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/flags', (req, res) => {
|
||
const enabled = computeDebugEnabled()
|
||
res.json({ ok: true, debugToolEnabled: enabled, debug: { weibo: computeToolDebug('weibo'), markets: computeToolDebug('markets'), wall: computeToolDebug('wall'), funds: computeToolDebug('funds'), weekly: computeToolDebug('weekly') } })
|
||
})
|
||
|
||
app.get('/api/weibo/devtools/status', async (req, res) => {
|
||
try {
|
||
const cookie = await getCookiesFromDevTools(['https://m.weibo.cn','https://weibo.com'])
|
||
const s = String(cookie || '')
|
||
const pairs = s.split(';').map(v => v.trim()).filter(Boolean)
|
||
logJSON('weibo.devtools.status', { connected: !!cookie, cookie_len: s.length, pairs: pairs.length }, 'weibo')
|
||
res.json({ ok: true, connected: !!cookie, cookie_len: s.length, pairs: pairs.length })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/settings', (req, res) => {
|
||
const fileSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
||
const dbSettings = getSettings()
|
||
res.json({ file: fileSettings, db: dbSettings })
|
||
})
|
||
|
||
app.post('/api/settings', (req, res) => {
|
||
const body = req.body || {}
|
||
const users = Array.isArray(body.users) ? body.users : []
|
||
const keywords = Array.isArray(body.keywords) ? body.keywords : []
|
||
let headers = body.headers
|
||
const normalizeCookie = c => String(c || '').replace(/^Cookie\s*:\s*/i, '').replace(/\r?\n/g, '').trim()
|
||
if (typeof headers === 'string') {
|
||
headers = { Cookie: normalizeCookie(headers) }
|
||
} else if (headers && typeof headers === 'object') {
|
||
if (!headers.Cookie && headers.cookie) headers.Cookie = headers.cookie
|
||
if (headers.Cookie) headers.Cookie = normalizeCookie(headers.Cookie)
|
||
} else {
|
||
headers = {}
|
||
}
|
||
let count = parseInt(String(body.count || '50'), 10)
|
||
if (!Number.isFinite(count)) count = 50
|
||
count = Math.max(1, Math.min(200, count))
|
||
fs.writeFileSync(settingsPath, JSON.stringify({ users, keywords, headers, count }, null, 2))
|
||
for (const k of keywords) insertKeyword.run(String(k).trim())
|
||
for (const u of users) insertTrackedUser.run(String(u.uid || '').trim(), String(u.screen_name || '').trim())
|
||
try { insertSettingHistory.run({ tool: 'weibo', payload: JSON.stringify({ users, keywords, headers, count }) }) } catch {}
|
||
logJSON('settings.update', { users, keywords, headers, count })
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.get('/api/weibo/:uid/fetch', async (req, res) => {
|
||
try {
|
||
const uid = req.params.uid
|
||
const fileSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
||
const count = Math.max(1, Math.min(200, parseInt(req.query.count || String(fileSettings.count || '50'), 10)))
|
||
logJSON('weibo.fetch.start', { uid, count }, 'weibo')
|
||
const pad = (n, w = 2) => String(n).padStart(w, '0')
|
||
const fmtBeijing = (dateVal) => {
|
||
try {
|
||
const tz = 'Asia/Shanghai'
|
||
const d = (dateVal instanceof Date) ? dateVal : new Date(dateVal)
|
||
if (isNaN(d.getTime())) return ''
|
||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||
timeZone: tz,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false
|
||
}).formatToParts(d)
|
||
const get = (type) => { const p = parts.find(x => x.type === type); return p ? p.value : '' }
|
||
const y = get('year'), m = get('month'), day = get('day'), h = get('hour'), mi = get('minute'), s = get('second')
|
||
const ms = pad(d.getMilliseconds(), 3)
|
||
return `${y}-${m}-${day} ${h}:${mi}:${s} ${ms}`
|
||
} catch { return '' }
|
||
}
|
||
const tryFetch = async (headersObj) => {
|
||
const posts = await fetchPosts(uid, count, headersObj || {})
|
||
for (const p of posts) {
|
||
insertPost.run(p)
|
||
if (p.retweeted_mid) updateRetweeted.run({
|
||
mid: p.mid,
|
||
retweeted_mid: p.retweeted_mid,
|
||
retweeted_screen_name: p.retweeted_screen_name,
|
||
retweeted_text: p.retweeted_text,
|
||
retweeted_text_plain: p.retweeted_text_plain,
|
||
retweeted_url: p.retweeted_url,
|
||
retweeted_pics_json: p.retweeted_pics_json,
|
||
text_plain: p.text_plain
|
||
})
|
||
}
|
||
const latest = posts.reduce((acc, p) => {
|
||
const t = String(p.created_at || '')
|
||
if (!t) return acc
|
||
if (!acc) return t
|
||
return (t > acc) ? t : acc
|
||
}, '')
|
||
return { fetched: posts.length, latest_time: latest }
|
||
}
|
||
const summarize = c => {
|
||
const s = String(c || '')
|
||
const pairs = s.split(';').map(v => v.trim()).filter(Boolean)
|
||
const names = pairs.map(p => p.split('=')[0]).filter(Boolean)
|
||
return { len: s.length, pairs: pairs.length, names: names.slice(0, 12) }
|
||
}
|
||
let fetched = 0
|
||
let latestTime = ''
|
||
let usedSource = ''
|
||
const devCookie = await getCookiesFromDevTools(['https://m.weibo.cn', 'https://weibo.com'])
|
||
if (devCookie) {
|
||
logJSON('weibo.fetch.try', { uid, source: 'devtools', cookie: summarize(devCookie) }, 'weibo')
|
||
try {
|
||
const r = await tryFetch({ Cookie: devCookie })
|
||
fetched = r.fetched; latestTime = r.latest_time
|
||
usedSource = 'devtools'
|
||
} catch (e) {
|
||
logJSON('weibo.fetch.try.error', { uid, source: 'devtools', error: String(e.message || e) }, 'weibo')
|
||
}
|
||
} else {
|
||
logJSON('weibo.fetch.try.skip', { uid, source: 'devtools' }, 'weibo')
|
||
}
|
||
if (!usedSource) {
|
||
const sHdr = fileSettings.headers || {}
|
||
const sCookie = sHdr.Cookie || sHdr.cookie || ''
|
||
if (sCookie) {
|
||
logJSON('weibo.fetch.try', { uid, source: 'settings', cookie: summarize(sCookie) }, 'weibo')
|
||
try {
|
||
const r = await tryFetch(sHdr)
|
||
fetched = r.fetched; latestTime = r.latest_time
|
||
usedSource = 'settings'
|
||
} catch (e) {
|
||
logJSON('weibo.fetch.try.error', { uid, source: 'settings', error: String(e.message || e) }, 'weibo')
|
||
}
|
||
} else {
|
||
logJSON('weibo.fetch.try.skip', { uid, source: 'settings' }, 'weibo')
|
||
}
|
||
}
|
||
if (!usedSource) {
|
||
const anonCookie = await bootstrapCookies(uid)
|
||
const headersPreferred = Object.assign({}, fileSettings.headers || {}, anonCookie ? { Cookie: anonCookie } : {})
|
||
logJSON('weibo.fetch.try', { uid, source: 'anonymous', cookie: summarize(anonCookie) }, 'weibo')
|
||
try {
|
||
const r = await tryFetch(headersPreferred)
|
||
fetched = r.fetched; latestTime = r.latest_time
|
||
usedSource = 'anonymous'
|
||
} catch (e) {
|
||
const msg = String(e.message || e)
|
||
logJSON('weibo.fetch.error', { uid, error: msg }, 'weibo')
|
||
const hint = '建议在服务器Chrome登录微博并开启9222端口,本工具将自动读取登录Cookie。'
|
||
return res.status(500).json({ ok: false, error: msg, hint })
|
||
}
|
||
}
|
||
logJSON('weibo.fetch.done', { uid, fetched, source: usedSource }, 'weibo')
|
||
res.json({ ok: true, fetched, latest_time: latestTime, source: usedSource })
|
||
} catch (e) {
|
||
const msg = String(e.message || e)
|
||
logJSON('weibo.fetch.error', { error: msg }, 'weibo')
|
||
const hint = 'Weibo API可能需要浏览器Cookie, 请在设置中填写Cookie后重试'
|
||
res.status(500).json({ ok: false, error: msg, hint })
|
||
}
|
||
})
|
||
|
||
app.get('/api/weibo/preview', async (req, res) => {
|
||
try {
|
||
const uid = String(req.query.uid || '7716940453')
|
||
const url = String(req.query.url || `https://m.weibo.cn/u/${uid}`)
|
||
const cookieDev = await getCookiesFromDevTools(['https://m.weibo.cn','https://weibo.com'])
|
||
const cookieSettings = (() => { try { const s = JSON.parse(fs.readFileSync(settingsPath,'utf-8')); const h=s.headers||{}; return h.Cookie||h.cookie||'' } catch { return '' } })()
|
||
const cookieAnon = await bootstrapCookies(uid)
|
||
const source = cookieDev ? 'devtools' : (cookieSettings ? 'settings' : (cookieAnon ? 'anonymous' : 'empty'))
|
||
const summarize = c => { const s=String(c||''); const pairs=s.split(';').map(v=>v.trim()).filter(Boolean); const names=pairs.map(p=>p.split('=')[0]).filter(Boolean); return { len:s.length, pairs:pairs.length, names:names.slice(0,12) } }
|
||
logJSON('weibo.preview.start', { uid, url, source, cookie: summarize(cookieDev||cookieSettings||cookieAnon) }, 'weibo')
|
||
const data = await screenshotUrl(url)
|
||
if (!data) {
|
||
logJSON('weibo.preview.error', { uid, url, source, error: 'screenshot_empty' }, 'weibo')
|
||
return res.json({ ok: false, url, source, screenshot: '', hint: '服务器Chrome未开启9222或未登录微博' })
|
||
}
|
||
res.json({ ok: true, url, source, screenshot: `data:image/png;base64,${data}` })
|
||
} catch (e) {
|
||
logJSON('weibo.preview.error', { error: String(e.message||e) }, 'weibo')
|
||
res.status(500).json({ ok: false, error: String(e.message||e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/weibo/search', (req, res) => {
|
||
const uid = String(req.query.uid || '7716940453')
|
||
const q = String(req.query.q || '').trim()
|
||
const mode = String(req.query.mode || 'any')
|
||
const limit = Math.max(1, Math.min(200, parseInt(req.query.limit || '50', 10)))
|
||
const terms = q.length > 0 ? q.split(/\s+/).filter(Boolean) : []
|
||
let rows = []
|
||
if (terms.length === 0) {
|
||
rows = queryPostsAny(uid, [''], limit)
|
||
} else if (mode === 'all') {
|
||
rows = queryPostsAll(uid, terms, limit)
|
||
} else {
|
||
rows = queryPostsAny(uid, terms, limit)
|
||
}
|
||
logJSON('weibo.search', { uid, q, mode, limit, rows: rows.length }, 'weibo')
|
||
res.json({ ok: true, rows })
|
||
})
|
||
|
||
app.get('/api/debug/logs', (req, res) => {
|
||
const limit = Math.max(1, Math.min(10000, parseInt(String(req.query.limit || '2000'), 10)))
|
||
const channel = String(req.query.channel || '').trim() || undefined
|
||
const lines = getLogs(limit, channel)
|
||
res.json({ ok: true, lines })
|
||
})
|
||
|
||
app.post('/api/debug/clear', (req, res) => {
|
||
const channel = String((req.query && req.query.channel) || '').trim() || undefined
|
||
clearLogs(channel)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/debug/event', (req, res) => {
|
||
const body = req.body || {}
|
||
const channel = body && body.source ? String(body.source).trim() : undefined
|
||
logJSON('frontend.event', body, channel)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/weibo/bootstrap', async (req, res) => {
|
||
try {
|
||
const uid = String(req.query.uid || '7716940453')
|
||
let cookie = ''
|
||
try { cookie = await getCookiesFromDevTools(['https://m.weibo.cn','https://weibo.com']) } catch {}
|
||
if (!cookie) cookie = await bootstrapCookies(uid)
|
||
const cfg = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
||
cfg.headers = cfg.headers || {}
|
||
cfg.headers.Cookie = cookie
|
||
fs.writeFileSync(settingsPath, JSON.stringify(cfg, null, 2))
|
||
logJSON('weibo.cookie.bootstrap', { uid, source: cookie ? 'devtools_or_anon' : 'empty', length: (cookie || '').length }, 'weibo')
|
||
res.json({ ok: true, cookie })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
const proxyMarketsAutomationFetch = async req => {
|
||
const cfg = getMarketsCfg()
|
||
const currentPort = Number(process.env.PORT || '8976') || 8976
|
||
const automationPort = Number(cfg.automation_port || 8977) || 8977
|
||
if (currentPort === automationPort) return null
|
||
const url = `http://127.0.0.1:${automationPort}/api/markets/fetch`
|
||
const proxied = await axios.get(url, {
|
||
params: req.query || {},
|
||
headers: {
|
||
Cookie: req.headers.cookie || ''
|
||
},
|
||
timeout: 45000,
|
||
validateStatus: () => true
|
||
})
|
||
return {
|
||
status: proxied.status || 200,
|
||
body: Object.assign({}, proxied.data || {}, {
|
||
proxied_to_automation: true,
|
||
current_port: currentPort,
|
||
automation_port: automationPort
|
||
})
|
||
}
|
||
}
|
||
|
||
app.get('/api/markets/fetch', async (req, res) => {
|
||
try {
|
||
const forwarded = await proxyMarketsAutomationFetch(req)
|
||
if (forwarded) return res.status(forwarded.status).json(forwarded.body)
|
||
const payload = await fetchAll()
|
||
const quotes = Array.isArray(payload && payload.quotes) ? payload.quotes : []
|
||
// #region debug-point B:manual-fetch-route
|
||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} const overseas = quotes.filter(item => String(item && item.card_type || '').toLowerCase() === 'overseas'); fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'B', location: 'index.js:/api/markets/fetch', msg: '[DEBUG] markets manual fetch route result', data: { notify: String(req.query.notify || '') === '1', quotes_count: quotes.length, overseas_count: overseas.length, overseas_symbols: overseas.map(item => item.symbol) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||
// #endregion
|
||
logJSON('markets.fetch', { count: quotes.length }, 'markets')
|
||
const notify = String(req.query.notify || '') === '1'
|
||
if (notify) { try { await maybeNotify(quotes) } catch (e) { logJSON('markets.fetch.notify.error', { error: String(e.message || e) }, 'markets') } }
|
||
const cfg = getMarketsCfg()
|
||
res.json({ ok: true, quotes, threshold_percent: cfg.threshold_percent, trace: payload && payload.trace ? payload.trace : null })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/markets/day', (req, res) => {
|
||
const date = String(req.query.date || '') || new Date().toISOString().slice(0, 10)
|
||
const rows = queryByDate.all(date)
|
||
res.json({ ok: true, date, rows })
|
||
})
|
||
|
||
app.get('/api/markets/config', (req, res) => {
|
||
res.json({ ok: true, config: getMarketsCfg() })
|
||
})
|
||
|
||
app.get('/api/markets/security_calendar', async (req, res) => {
|
||
try {
|
||
const month = String(req.query.month || '')
|
||
const forceRefresh = String(req.query.refresh || '') === '1'
|
||
const payload = await securityCalendar.getMonthCalendar(month, { force_refresh: forceRefresh })
|
||
res.json(payload)
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/markets/security_calendar/ai_verify', async (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const forceRefresh = String(body.force_refresh || '') === '1' || body.force_refresh === true
|
||
const result = await securityCalendar.runAiVerification({ trigger: 'manual', force_refresh: forceRefresh })
|
||
res.json({ ok: !!result.ok, result })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/markets/security_calendar/ipo_calendar_sync', async (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
let startDate = String(body.start_date || '').trim()
|
||
let endDate = String(body.end_date || '').trim()
|
||
if ((!startDate || !endDate) && body.month) {
|
||
const range = securityCalendar.getMonthRange(String(body.month || '').trim())
|
||
startDate = range.start_date
|
||
endDate = range.end_date
|
||
}
|
||
if (!startDate || !endDate) {
|
||
return res.status(400).json({ ok: false, error: 'missing month or start_date/end_date' })
|
||
}
|
||
await securityCalendar.ensureDateRangeData(startDate, endDate, { force_refresh: body.force_refresh === true || String(body.force_refresh || '') === '1' })
|
||
const result = await securityCalendarBridge.syncRange(startDate, endDate)
|
||
res.json({ ok: !!result.ok, result })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/markets/weekly_report', async (req, res) => {
|
||
try {
|
||
const week = String(req.query.week || '')
|
||
const forceRefresh = String(req.query.refresh || '') === '1'
|
||
const currentPort = Number(process.env.PORT || '8976')
|
||
const currentWeek = marketsWeeklyReport.getWeekRange('').week_start
|
||
const targetWeek = marketsWeeklyReport.getWeekRange(week).week_start
|
||
const shouldPreferAutomation = currentPort !== 8977 && (forceRefresh || targetWeek === currentWeek)
|
||
if (shouldPreferAutomation) {
|
||
try {
|
||
const automationTarget = (process.env.AUTOMATION_TARGET || 'http://127.0.0.1:8977').replace(/\/$/, '')
|
||
const r = await axios.get(`${automationTarget}/api/markets/weekly_report`, {
|
||
params: req.query,
|
||
timeout: 180000,
|
||
validateStatus: () => true
|
||
})
|
||
if (r.status >= 200 && r.status < 300 && r.data && r.data.ok) {
|
||
return res.status(r.status).json(r.data)
|
||
}
|
||
} catch (proxyError) {
|
||
try {
|
||
logJSON('markets.weekly_report.proxy_8977.error', {
|
||
error: String(proxyError && proxyError.message ? proxyError.message : proxyError),
|
||
target_week: targetWeek
|
||
}, 'markets')
|
||
} catch {}
|
||
}
|
||
}
|
||
const payload = await marketsWeeklyReport.getWeeklyReport(week, { force_refresh: forceRefresh })
|
||
res.json(payload)
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/markets/config', (req, res) => {
|
||
const body = req.body || {}
|
||
const cfg = getMarketsCfg()
|
||
if (Array.isArray(body.watchlist)) cfg.watchlist = body.watchlist
|
||
if (typeof body.threshold_percent === 'number') cfg.threshold_percent = body.threshold_percent
|
||
if (body.email && typeof body.email === 'object') cfg.email = body.email
|
||
if (typeof body.ui_mode === 'string' && body.ui_mode.trim()) cfg.ui_mode = String(body.ui_mode).trim()
|
||
if (body.yahoo_probe && typeof body.yahoo_probe === 'object') cfg.yahoo_probe = body.yahoo_probe
|
||
setMarketsCfg(cfg)
|
||
try { insertSettingHistory.run({ tool: 'markets', payload: JSON.stringify(cfg) }) } catch {}
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
app.post('/api/markets/test-email', async (req, res) => {
|
||
try {
|
||
await sendTestEmail()
|
||
logJSON('markets.email.test', { ok: true }, 'markets')
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
logJSON('markets.email.test.error', { error: String(e.message || e) }, 'markets')
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/markets/test-email', async (req, res) => {
|
||
try {
|
||
await sendTestEmail()
|
||
logJSON('markets.email.test', { ok: true, method: 'GET' }, 'markets')
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
logJSON('markets.email.test.error', { error: String(e.message || e), method: 'GET' }, 'markets')
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/markets/skills-meta', async (req, res) => {
|
||
try {
|
||
const skillsDir = path.join(process.cwd(), 'src', 'server', 'market_skills')
|
||
const logFile = path.join(process.cwd(), 'logs', 'market_skills.log')
|
||
|
||
const readDirConfigs = () => {
|
||
const list = []
|
||
if (!fs.existsSync(skillsDir)) return list
|
||
const dirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||
for (const dirent of dirs) {
|
||
if (!dirent.isDirectory()) continue
|
||
const id = dirent.name
|
||
const configPath = path.join(skillsDir, id, 'config.json')
|
||
const indexPath = path.join(skillsDir, id, 'index.js')
|
||
if (!fs.existsSync(configPath) || !fs.existsSync(indexPath)) continue
|
||
try {
|
||
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||
if (cfg.enabled === false) continue
|
||
list.push({ id, config: cfg, indexPath })
|
||
} catch {}
|
||
}
|
||
list.sort((a, b) => ((a.config.card_order ?? 9999) - (b.config.card_order ?? 9999)))
|
||
return list
|
||
}
|
||
|
||
const analyzeSkillCode = (id, indexPath) => {
|
||
const code = fs.readFileSync(indexPath, 'utf-8')
|
||
const isShared = /^module\.exports\s*=\s*require\(/.test(code)
|
||
const sharedMatch = code.match(/require\(['"]\.\.\/?([\w_-]+)\/index\.js['"]\)/)
|
||
const sharedFrom = sharedMatch ? sharedMatch[1] : null
|
||
let backupSource = ''
|
||
if (/fetchEastmoneyQuote|fetchPizzint|fetchFredVix|fetchSina|fetchYahoo/.test(code)) {
|
||
const candidates = []
|
||
if (/fetchTencentQuote|fetchTencent/.test(code)) candidates.push('腾讯')
|
||
if (/fetchEastmoneyQuote|push2his\.eastmoney/.test(code)) candidates.push('东方财富K线')
|
||
if (/fetchSina/.test(code)) candidates.push('新浪')
|
||
if (/fetchYahoo/.test(code)) candidates.push('雅虎')
|
||
if (/fetchPizzint/.test(code)) candidates.push('PizzINT')
|
||
if (/fetchFredVix/.test(code)) candidates.push('FRED VIX')
|
||
if (/fetchFredSeries/.test(code) && /SP500/.test(code)) candidates.push('FRED SP500')
|
||
if (/fetchFredSeries/.test(code) && /GDP/.test(code)) candidates.push('FRED GDP')
|
||
backupSource = candidates.join(', ')
|
||
}
|
||
return { isShared, sharedFrom, backupSource, hasBackup: backupSource.length > 0 }
|
||
}
|
||
|
||
const parseLogStatus = () => {
|
||
const state = new Map()
|
||
if (!fs.existsSync(logFile)) return state
|
||
try {
|
||
const raw = fs.readFileSync(logFile, 'utf-8')
|
||
const lines = raw.split(/\r?\n/).filter(Boolean)
|
||
const recent = lines.slice(Math.max(0, lines.length - 800))
|
||
for (const line of recent) {
|
||
try {
|
||
const m = line.match(/^([^Z]+Z)\s+(\S+)\s+(.+)$/)
|
||
if (!m) continue
|
||
const evt = m[2]
|
||
const payload = JSON.parse(m[3])
|
||
const skillId = payload.skill
|
||
if (!skillId) continue
|
||
if (!state.has(skillId)) {
|
||
state.set(skillId, { lastSavedAt: null, lastErrorAt: null, lastErrorMsg: '', errors: 0 })
|
||
}
|
||
const s = state.get(skillId)
|
||
if (evt === 'skills.data.saved') {
|
||
s.lastSavedAt = m[1]
|
||
}
|
||
if (evt === 'skill.fetch.error') {
|
||
s.lastErrorAt = m[1]
|
||
s.lastErrorMsg = payload.message || ''
|
||
s.errors++
|
||
}
|
||
if (evt === 'skills.run.error') {
|
||
s.lastErrorAt = m[1]
|
||
s.lastErrorMsg = payload.error || ''
|
||
s.errors++
|
||
}
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
return state
|
||
}
|
||
|
||
const doHeartbeat = async (label, url, opts = {}) => {
|
||
try {
|
||
const res = await axios.get(url, { timeout: 8000, validateStatus: () => true, ...opts })
|
||
if (res.status >= 500) return { label, ok: false, status: res.status, latency: '—' }
|
||
return { label, ok: true, status: res.status, latency: 'OK' }
|
||
} catch (e) {
|
||
return { label, ok: false, status: e.code || 'ERR', latency: '—' }
|
||
}
|
||
}
|
||
|
||
const items = readDirConfigs()
|
||
const logState = parseLogStatus()
|
||
|
||
const skills = items.map(({ id, config, indexPath }) => {
|
||
const codeInfo = analyzeSkillCode(id, indexPath)
|
||
const log = logState.get(id) || { lastSavedAt: null, lastErrorAt: null, lastErrorMsg: '', errors: 0 }
|
||
|
||
const sourceLabels = { tencent: '腾讯财经', eastmoney: '东方财富', sina: '新浪', yahoo: '雅虎', fred: 'FRED', fred_vix: 'FRED VIX' }
|
||
const mainSource = sourceLabels[config.source] || config.source || '—'
|
||
|
||
let health = '✅'
|
||
let healthNote = ''
|
||
if (log.errors > 0) {
|
||
health = log.errors >= 5 ? '❌' : '⚠️'
|
||
healthNote = `${log.errors} 次错误:${log.lastErrorMsg}`
|
||
}
|
||
if (!log.lastSavedAt) {
|
||
health = '❌'
|
||
healthNote = '从未收到数据'
|
||
}
|
||
|
||
return {
|
||
id,
|
||
name: config.name,
|
||
symbol: config.symbol,
|
||
card_order: config.card_order ?? 9999,
|
||
card_type: config.card_type || '',
|
||
schedule: config.schedule || '',
|
||
main_source: mainSource,
|
||
source_raw: config.source || '',
|
||
backup_source: codeInfo.backupSource || '—',
|
||
is_shared: codeInfo.isShared,
|
||
shared_from: codeInfo.sharedFrom || null,
|
||
has_backup: codeInfo.hasBackup,
|
||
health,
|
||
health_note: healthNote,
|
||
last_data_at: log.lastSavedAt || '—',
|
||
last_error_at: log.lastErrorAt || '—',
|
||
errors: log.errors
|
||
}
|
||
})
|
||
|
||
const heartbeats = await Promise.all([
|
||
doHeartbeat('腾讯 qt.gtimg.cn', 'http://qt.gtimg.cn/q=sh000001', { responseType: 'arraybuffer' }),
|
||
doHeartbeat('东方财富 push2his', 'https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=1.000001&lmt=1', { headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' } }),
|
||
doHeartbeat('FRED', 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=SP500', { responseType: 'text', transformResponse: [d => d] }),
|
||
doHeartbeat('新浪 hq.sinajs.cn', 'http://hq.sinajs.cn/list=sh000001', { headers: { 'Referer': 'https://finance.sina.com.cn/', 'User-Agent': 'Mozilla/5.0' }, responseType: 'arraybuffer' })
|
||
])
|
||
|
||
const anomalies = []
|
||
for (const hb of heartbeats) {
|
||
if (!hb.ok) anomalies.push({ source: hb.label, detail: `状态码 ${hb.status},不可达`, level: 'critical' })
|
||
}
|
||
|
||
const eastmoneySkills = skills.filter(s => s.source_raw === 'eastmoney')
|
||
if (eastmoneySkills.length > 0) {
|
||
anomalies.push({
|
||
source: '东方财富残留',
|
||
detail: `仍有 ${eastmoneySkills.length} 个卡使用 eastmoney 源(${eastmoneySkills.map(s => s.name).join(', ')}),可能存在拉取失败`,
|
||
level: 'warning'
|
||
})
|
||
}
|
||
|
||
const errorSkills = skills.filter(s => s.errors > 0)
|
||
for (const s of errorSkills) {
|
||
anomalies.push({
|
||
source: s.name,
|
||
detail: s.health_note,
|
||
level: s.errors >= 5 ? 'critical' : 'warning'
|
||
})
|
||
}
|
||
|
||
const neverDataSkills = skills.filter(s => s.last_data_at === '—')
|
||
for (const s of neverDataSkills) {
|
||
if (!anomalies.some(a => a.source === s.name)) {
|
||
anomalies.push({
|
||
source: s.name,
|
||
detail: `从未收到数据,数据源 ${s.main_source} 可能异常`,
|
||
level: 'critical'
|
||
})
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
ok: true,
|
||
skills,
|
||
heartbeats,
|
||
anomalies,
|
||
total_skills: skills.length,
|
||
healthy_count: skills.filter(s => s.health === '✅').length,
|
||
generated_at: new Date().toISOString()
|
||
})
|
||
} catch (e) {
|
||
logJSON('markets.skills-meta.error', { error: String(e.message || e) }, 'markets')
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/markets/email-log', async (req, res) => {
|
||
try {
|
||
const limit = Math.max(1, Math.min(500, parseInt(String(req.query.limit || '100'), 10) || 100))
|
||
const { db } = require('./db')
|
||
const rows = db.prepare(`SELECT * FROM alert_email_log ORDER BY id DESC LIMIT ?`).all(limit)
|
||
res.json({ ok: true, rows, total: rows.length })
|
||
} catch (e) {
|
||
logJSON('markets.email-log.error', { error: String(e.message || e) }, 'markets')
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// 市场工具 TTS 语音合成(阿里云)
|
||
const marketTts = require('./market_tts')
|
||
app.get('/api/markets/tts', async (req, res) => {
|
||
try {
|
||
const text = String(req.query.text || '').trim()
|
||
if (!text) return res.status(400).json({ ok: false, error: 'Missing text' })
|
||
if (text.length > 500) return res.status(400).json({ ok: false, error: 'Text too long (max 500)' })
|
||
|
||
const voice = String(req.query.voice || 'Zhixuan').trim()
|
||
const audioBuf = await marketTts.synthesize(text, { voice })
|
||
const ct = audioBuf[0] === 0x52 ? 'audio/wav' : 'audio/mpeg'
|
||
res.set({ 'Content-Type': ct, 'Content-Length': audioBuf.length })
|
||
res.send(audioBuf)
|
||
} catch (e) {
|
||
logJSON('markets.tts.error', { error: String(e.message || e) }, 'markets')
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// 整章 TTS 生成(POST JSON,返回所有日志)
|
||
app.post('/api/markets/tts-generate', express.json(), async (req, res) => {
|
||
const { text, voice, ch, meta } = req.body || {}
|
||
if (!text || !text.trim()) return res.status(400).json({ ok: false, error: 'Missing text' })
|
||
|
||
const logs = []
|
||
const log = (msg, type = 'info') => {
|
||
logs.push({ msg, type })
|
||
console.log('[MarketTTS]', msg)
|
||
}
|
||
|
||
try {
|
||
log(`收到文本:${text.length} 字${meta ? '(' + meta + ')' : ''}`)
|
||
|
||
const result = await marketTts.generateChapter(text.trim(), voice || 'Zhixuan', (msg, type) => {
|
||
log(msg, type)
|
||
})
|
||
|
||
const chIdx = parseInt(String(ch || ''), 10)
|
||
const fileName = !isNaN(chIdx) && chIdx >= 0
|
||
? `ch_${String(chIdx).padStart(2, '0')}.wav`
|
||
: `ch_${Date.now()}.wav`
|
||
const urlPath = marketTts.saveAudioFile(result.buffer, fileName)
|
||
log(`语音文件已保存:${fileName}`, 'success')
|
||
|
||
res.json({ ok: true, logs, result: { url: urlPath, duration: result.duration, fileName } })
|
||
} catch (e) {
|
||
log(`生成失败:${e.message}`, 'error')
|
||
res.json({ ok: false, logs, error: e.message })
|
||
}
|
||
})
|
||
|
||
// 检查某章节是否已生成语音(支持 .mp3 和 .wav)
|
||
app.get('/api/markets/tts-check', async (req, res) => {
|
||
try {
|
||
const chIdx = parseInt(String(req.query.ch || ''), 10)
|
||
if (isNaN(chIdx) || chIdx < 0) return res.status(400).json({ ok: false, error: 'Invalid ch' })
|
||
const base = `ch_${String(chIdx).padStart(2, '0')}`
|
||
const basePath = path.join(process.cwd(), 'public', 'tools', 'markets', 'audio')
|
||
|
||
// 优先 .mp3,其次 .wav
|
||
let ext = 'mp3'
|
||
let filePath = path.join(basePath, base + '.mp3')
|
||
if (!fs.existsSync(filePath)) {
|
||
ext = 'wav'
|
||
filePath = path.join(basePath, base + '.wav')
|
||
}
|
||
|
||
const exists = fs.existsSync(filePath)
|
||
let duration = 0
|
||
if (exists) {
|
||
const stat = fs.statSync(filePath)
|
||
// .wav 可以算时长(44字节头,16-bit mono 16kHz)
|
||
if (ext === 'wav') {
|
||
const dataSize = stat.size - 44
|
||
duration = dataSize > 0 ? parseFloat((dataSize / 16000 / 2).toFixed(1)) : 0
|
||
}
|
||
// .mp3 让浏览器自己报时长,这里填 0
|
||
}
|
||
res.json({ ok: true, exists, url: exists ? '/tools/markets/audio/' + base + '.' + ext : null, duration })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/markets/weekly_report_health', async (req, res) => {
|
||
try {
|
||
const cfgPath = path.join(process.cwd(), 'config', 'markets_weekly_report.json')
|
||
if (!fs.existsSync(cfgPath)) return res.json({ ok: true, items: [], heartbeats: [], diagnosis: [] })
|
||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||
const items = Array.isArray(cfg.items) ? cfg.items : []
|
||
|
||
const sourceLabels = {
|
||
tencent: '腾讯K线',
|
||
eastmoney: '东财K线',
|
||
eastmoney_browser_realtime: '东财实时',
|
||
nasdaq_index: 'Nasdaq API',
|
||
sina_global_futures: '新浪期货',
|
||
globalstockdata: 'GlobalStockData',
|
||
yahoo_probe: 'Yahoo 探针'
|
||
}
|
||
|
||
const reliabilityLabel = (item) => {
|
||
const sources = Array.isArray(item.history_sources) ? item.history_sources : []
|
||
const hasEastmoney = sources.includes('eastmoney')
|
||
const hasBrowser = sources.includes('eastmoney_browser_realtime')
|
||
const hasTencent = sources.includes('tencent')
|
||
const hasNasdaq = sources.includes('nasdaq_index')
|
||
const hasSina = sources.includes('sina_global_futures')
|
||
const hasGsd = sources.includes('globalstockdata')
|
||
const hasYahoo = sources.includes('yahoo_probe')
|
||
|
||
if ((hasTencent && !hasEastmoney) || hasNasdaq || hasSina || hasYahoo) {
|
||
if (sources.every(s => s !== 'globalstockdata' && s !== 'eastmoney')) return { level: '✅ 可靠', color: '#16a34a' }
|
||
}
|
||
if (hasEastmoney && sources.length <= 2) return { level: '❌ 脆弱', color: '#dc2626' }
|
||
if (hasGsd || (hasEastmoney && sources.length > 2)) return { level: '⚠️ 有风险', color: '#ea580c' }
|
||
return { level: '⚠️ 未知', color: '#8c8c8c' }
|
||
}
|
||
|
||
const doHeartbeat = async (label, url, opts = {}) => {
|
||
try {
|
||
const res = await axios.get(url, { timeout: 8000, validateStatus: () => true, ...opts })
|
||
if (res.status >= 500) return { label, ok: false, status: res.status }
|
||
return { label, ok: true, status: res.status }
|
||
} catch (e) {
|
||
return { label, ok: false, status: e.code || 'ERR' }
|
||
}
|
||
}
|
||
|
||
const heartbeats = await Promise.all([
|
||
doHeartbeat('腾讯K线 web.ifzq', 'http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh000001,day,,,5,qfq', { timeout: 8000 }),
|
||
doHeartbeat('东财K线 push2his', 'https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=1.000001&lmt=1', { headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' } }),
|
||
doHeartbeat('东财实时 push2', 'https://push2.eastmoney.com/api/qt/ulist/get?fltt=1&invt=2&cb=x&fields=f2&secids=1.000001&ut=fa5fd1943c7b386f172d6893dbfba10b&pn=1&np=1&pz=1&dect=1&_=1', { timeout: 8000 }),
|
||
doHeartbeat('Nasdaq API', 'https://api.nasdaq.com/api/quote/COMP/historical?assetclass=index&limit=1', { headers: { 'User-Agent': 'Mozilla/5.0', 'Origin': 'https://www.nasdaq.com', 'Referer': 'https://www.nasdaq.com/' } }),
|
||
doHeartbeat('新浪期货', 'https://stock2.finance.sina.com.cn/futures/api/jsonp.php/var%20x=/GlobalFuturesService.getGlobalFuturesDailyKLine?symbol=GC', { headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://finance.sina.com.cn/' } })
|
||
])
|
||
|
||
const itemsOut = items.map(item => {
|
||
const rel = reliabilityLabel(item)
|
||
const sources = Array.isArray(item.history_sources) ? item.history_sources : []
|
||
return {
|
||
symbol: item.symbol,
|
||
label: item.label,
|
||
group: item.market_group || '',
|
||
main_source: sourceLabels[sources[0]] || sources[0] || '—',
|
||
backup_source: sources.slice(1).map(s => sourceLabels[s] || s).join(' + ') || '—',
|
||
reliability: rel.level,
|
||
reliability_color: rel.color,
|
||
source_count: sources.length,
|
||
sources_raw: sources
|
||
}
|
||
})
|
||
|
||
const eastmoneyCards = itemsOut.filter(it => it.sources_raw.includes('eastmoney'))
|
||
const diagnosis = []
|
||
if (eastmoneyCards.length > 0) {
|
||
diagnosis.push({
|
||
level: 'critical',
|
||
source: '东财K线 push2his 不可用',
|
||
detail: `影响 ${eastmoneyCards.length} 张卡:${eastmoneyCards.map(it => it.label).join('、')}。历史周数据无法通过东财K线获取,已自动降级为东财实时或腾讯源。`
|
||
})
|
||
}
|
||
|
||
const tencentOnlyCards = itemsOut.filter(it =>
|
||
it.sources_raw.includes('tencent') && !it.sources_raw.includes('eastmoney') &&
|
||
!it.sources_raw.includes('nasdaq_index') && !it.sources_raw.includes('sina_global_futures') &&
|
||
!it.sources_raw.includes('globalstockdata')
|
||
)
|
||
if (tencentOnlyCards.some(it => ['usINX', 'usDJI', 'usIXIC', 'usFLSA', 'usNFTY'].includes(it.symbol))) {
|
||
diagnosis.push({
|
||
level: 'warning',
|
||
source: '腾讯外盘K线仅1天',
|
||
detail: `标普500、道琼斯、沙特、印度等美股权重卡,腾讯K线仅返回最近1个交易日,历史周靠备源globalstockdata补全。`
|
||
})
|
||
}
|
||
|
||
const gsdCards = itemsOut.filter(it => it.sources_raw.includes('globalstockdata'))
|
||
if (gsdCards.length > 0) {
|
||
diagnosis.push({
|
||
level: 'warning',
|
||
source: 'GlobalStockData HTML抓取',
|
||
detail: `日经225、德国DAX30 等 ${gsdCards.length} 张卡依赖 GlobalStockData HTML 页面抓取,该源不稳定,随时可能因页面结构变更失效。`
|
||
})
|
||
}
|
||
|
||
for (const hb of heartbeats) {
|
||
if (!hb.ok) {
|
||
diagnosis.push({
|
||
level: 'critical',
|
||
source: hb.label,
|
||
detail: `状态码 ${hb.status},不可达`
|
||
})
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
ok: true,
|
||
items: itemsOut,
|
||
heartbeats,
|
||
diagnosis,
|
||
total: itemsOut.length,
|
||
eastmoney_count: eastmoneyCards.length
|
||
})
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/wall/backgrounds', (req, res) => {
|
||
try {
|
||
const dir = path.join(process.cwd(), 'public', 'tools', 'wall')
|
||
const files = fs.readdirSync(dir)
|
||
const images = files.filter(f => /^bjt_.*\.(?:jpg|jpeg|png|webp)$/i.test(f)).map(f => `/tools/wall/${f}`)
|
||
res.json({ ok: true, images })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/wall/config', (req, res) => {
|
||
try {
|
||
const cfg = getWallCfg()
|
||
res.json({ ok: true, config: cfg })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/wall/cards', (req, res) => {
|
||
try {
|
||
res.set('Cache-Control', 'no-store')
|
||
const rows = listWallCards()
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// Funds tool
|
||
app.get('/api/funds/config', (req, res) => {
|
||
try { res.json({ ok: true, config: funds.getConfig() }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message||e) }) }
|
||
})
|
||
app.post('/api/funds/config', (req, res) => {
|
||
try { const cfg = funds.getConfig(); const body = req.body || {}; const next = Object.assign({}, cfg, body); funds.setConfig(next); res.json({ ok: true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
|
||
})
|
||
app.get('/api/funds/flows', (req, res) => {
|
||
try { res.set('Cache-Control', 'no-store'); const start = String(req.query.start||''); const end = String(req.query.end||''); const type = String(req.query.type||'all'); const kw = String(req.query.kw||''); const rows = funds.listFlows(start, end, type, kw); res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
|
||
})
|
||
app.post('/api/funds/flows', (req, res) => {
|
||
try { funds.createFlow(req.body||{}); res.json({ ok: true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
|
||
})
|
||
app.put('/api/funds/flows/:id', (req, res) => {
|
||
try { const id = parseInt(req.params.id,10); funds.updateFlowById(id, req.body||{}); res.json({ ok: true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
|
||
})
|
||
app.delete('/api/funds/flows/:id', (req, res) => {
|
||
try { const id = parseInt(req.params.id,10); funds.deleteFlow(id); res.json({ ok: true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
|
||
})
|
||
app.get('/api/funds/export.csv', (req, res) => {
|
||
try { const start = String(req.query.start||''); const end = String(req.query.end||''); const rows = funds.listFlows(start, end, 'all', ''); const header = '日期,类型,金额(人民币),银行方/状态,期末金额,备注\n'; const content = rows.map(r => `${r.date},${r.type==='in'?'汇入':'汇出'},${(r.amount_cents/100).toFixed(2)},${(r.bank_status||'').replace(/,/g,' ')},${(r.ending_cents/100).toFixed(2)},${(r.remark||'').replace(/,/g,' ')}`).join('\n'); res.setHeader('Content-Type','text/csv; charset=utf-8'); res.setHeader('Content-Disposition','attachment; filename="funds.csv"'); res.send(header+content) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
|
||
})
|
||
|
||
app.get('/api/ccb_private_funds/config', (req, res) => {
|
||
try { res.json({ ok: true, config: ccbPrivateFunds.getConfig() }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/ccb_private_funds/config', (req, res) => {
|
||
try { const cfg = ccbPrivateFunds.getConfig(); const body = req.body || {}; const next = Object.assign({}, cfg, body); ccbPrivateFunds.setConfig(next); res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/ccb_private_funds/flows', (req, res) => {
|
||
try { res.set('Cache-Control', 'no-store'); const start = String(req.query.start || ''); const end = String(req.query.end || ''); const type = String(req.query.type || 'all'); const kw = String(req.query.kw || ''); const rows = ccbPrivateFunds.listFlows(start, end, type, kw); res.json({ ok: true, rows }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/ccb_private_funds/flows', (req, res) => {
|
||
try { const id = ccbPrivateFunds.createFlow(req.body || {}); res.json({ ok: true, id }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.put('/api/ccb_private_funds/flows/:id', (req, res) => {
|
||
try { const id = parseInt(req.params.id, 10); ccbPrivateFunds.updateFlowById(id, req.body || {}); res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.delete('/api/ccb_private_funds/flows/:id', (req, res) => {
|
||
try { const id = parseInt(req.params.id, 10); ccbPrivateFunds.deleteFlow(id); res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/ccb_private_funds/export.csv', (req, res) => {
|
||
try { const start = String(req.query.start || ''); const end = String(req.query.end || ''); const rows = ccbPrivateFunds.listFlows(start, end, 'all', ''); const header = '日期,类型,金额(人民币),银行方/状态,期末金额,备注\n'; const content = rows.map(r => `${r.date},${r.type === 'in' ? '汇入' : '汇出'},${(r.amount_cents / 100).toFixed(2)},${(r.bank_status || '').replace(/,/g, ' ')},${(r.ending_cents / 100).toFixed(2)},${(r.remark || '').replace(/,/g, ' ')}`).join('\n'); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', 'attachment; filename="ccb_private_funds.csv"'); res.send(header + content) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/ccb_private_funds/attachments/:flow_id', (req, res) => {
|
||
try {
|
||
const flowId = parseInt(req.params.flow_id, 10)
|
||
if (!flowId) return res.status(400).json({ ok: false, error: 'bad_flow_id' })
|
||
const rows = ccbPrivateFunds.listAttachmentsByFlowId(flowId)
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.post('/api/ccb_private_funds/attachments/upload', express.raw({ type: 'application/octet-stream', limit: '200mb' }), (req, res) => {
|
||
try {
|
||
const flow_id = parseInt(req.query.flow_id || '0', 10)
|
||
if (!flow_id) return res.status(400).json({ ok: false, error: 'bad_flow_id' })
|
||
const raw = req.body || Buffer.alloc(0)
|
||
if (!raw.length) return res.status(400).json({ ok: false, error: 'empty_file' })
|
||
const filename = String(req.query.filename || 'file.bin').replace(/[<>:"/\\|?*]/g, '_')
|
||
const mime = String(req.query.mime || 'application/octet-stream')
|
||
const ext = path.extname(filename || '').slice(0, 12)
|
||
const outName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext || '.bin'}`
|
||
const rel = path.join('ccb_private_funds', 'attachments', outName).replace(/\\/g, '/')
|
||
const abs = path.join(process.cwd(), 'uploads', rel)
|
||
if (!fs.existsSync(path.dirname(abs))) fs.mkdirSync(path.dirname(abs), { recursive: true })
|
||
fs.writeFileSync(abs, raw)
|
||
const id = ccbPrivateFunds.addAttachment({ flow_id, filename, path: rel, size_bytes: raw.length, mime })
|
||
res.json({ ok: true, id, url: `/uploads/${rel}` })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.delete('/api/ccb_private_funds/attachments/:id', (req, res) => {
|
||
try {
|
||
const id = parseInt(req.params.id, 10)
|
||
if (!id) return res.status(400).json({ ok: false, error: 'bad_id' })
|
||
ccbPrivateFunds.deleteAttachmentById(id)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// Nav Bookmarks API
|
||
app.get('/api/nav_bookmarks/categories', (req, res) => {
|
||
try {
|
||
const rows = NAV_BOOKMARKS_DB.prepare('SELECT code, name, sort_order, created_at, updated_at FROM nav_categories ORDER BY sort_order ASC, code ASC').all()
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.post('/api/nav_bookmarks/categories', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const code = String(body.code || '').trim()
|
||
const name = String(body.name || '').trim()
|
||
const sortOrder = Number.isFinite(body.sort_order) ? Number(body.sort_order) : 0
|
||
if (!code || !name) return res.status(400).json({ ok: false, error: 'missing_code_or_name' })
|
||
const now = navNow()
|
||
const stmt = NAV_BOOKMARKS_DB.prepare('INSERT INTO nav_categories (code, name, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
|
||
stmt.run(code, name, sortOrder, now, now)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.put('/api/nav_bookmarks/categories/:code', (req, res) => {
|
||
try {
|
||
const code = String(req.params.code || '').trim()
|
||
if (!code) return res.status(400).json({ ok: false, error: 'missing_code' })
|
||
const body = req.body || {}
|
||
const name = String(body.name || '').trim()
|
||
const sortOrder = Number.isFinite(body.sort_order) ? Number(body.sort_order) : 0
|
||
const now = navNow()
|
||
const stmt = NAV_BOOKMARKS_DB.prepare('UPDATE nav_categories SET name = ?, sort_order = ?, updated_at = ? WHERE code = ?')
|
||
stmt.run(name, sortOrder, now, code)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.delete('/api/nav_bookmarks/categories/:code', (req, res) => {
|
||
try {
|
||
const code = String(req.params.code || '').trim()
|
||
if (!code) return res.status(400).json({ ok: false, error: 'missing_code' })
|
||
const inUse = NAV_BOOKMARKS_DB.prepare('SELECT COUNT(1) AS c FROM nav_bookmarks WHERE category_code = ?').get(code)
|
||
if (inUse && inUse.c > 0) return res.status(400).json({ ok: false, error: 'category_in_use' })
|
||
NAV_BOOKMARKS_DB.prepare('DELETE FROM nav_categories WHERE code = ?').run(code)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.get('/api/nav_bookmarks/bookmarks', (req, res) => {
|
||
try {
|
||
const category = String(req.query.category_code || '').trim()
|
||
const q = String(req.query.q || '').trim().toLowerCase()
|
||
let rows = NAV_BOOKMARKS_DB.prepare('SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at FROM nav_bookmarks ORDER BY sort_order ASC, created_at ASC').all()
|
||
if (category) rows = rows.filter(r => String(r.category_code || '') === category)
|
||
if (q) {
|
||
rows = rows.filter(r => {
|
||
const name = String(r.name || '').toLowerCase()
|
||
const url = String(r.url || '').toLowerCase()
|
||
const remark = String(r.remark || '').toLowerCase()
|
||
return name.includes(q) || url.includes(q) || remark.includes(q)
|
||
})
|
||
}
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.post('/api/nav_bookmarks/bookmarks', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const name = String(body.name || '').trim()
|
||
const url = String(body.url || '').trim()
|
||
if (!name || !url) return res.status(400).json({ ok: false, error: 'missing_name_or_url' })
|
||
const icon = String(body.icon || '').trim()
|
||
const categoryCode = String(body.category_code || '').trim()
|
||
const remark = String(body.remark || '').trim()
|
||
const sortOrder = Number.isFinite(body.sort_order) ? Number(body.sort_order) : 0
|
||
const id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||
const now = navNow()
|
||
const stmt = NAV_BOOKMARKS_DB.prepare(`INSERT INTO nav_bookmarks (id, name, url, icon, category_code, remark, sort_order, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||
stmt.run(id, name, url, icon, categoryCode, remark, sortOrder, now, now)
|
||
res.json({ ok: true, id })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.put('/api/nav_bookmarks/bookmarks/:id', (req, res) => {
|
||
try {
|
||
const id = String(req.params.id || '').trim()
|
||
if (!id) return res.status(400).json({ ok: false, error: 'missing_id' })
|
||
const body = req.body || {}
|
||
const name = String(body.name || '').trim()
|
||
const url = String(body.url || '').trim()
|
||
const icon = String(body.icon || '').trim()
|
||
const categoryCode = String(body.category_code || '').trim()
|
||
const remark = String(body.remark || '').trim()
|
||
const sortOrder = Number.isFinite(body.sort_order) ? Number(body.sort_order) : 0
|
||
const now = navNow()
|
||
const stmt = NAV_BOOKMARKS_DB.prepare(`UPDATE nav_bookmarks SET name = ?, url = ?, icon = ?, category_code = ?, remark = ?, sort_order = ?, updated_at = ? WHERE id = ?`)
|
||
stmt.run(name, url, icon, categoryCode, remark, sortOrder, now, id)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.delete('/api/nav_bookmarks/bookmarks/:id', (req, res) => {
|
||
try {
|
||
const id = String(req.params.id || '').trim()
|
||
if (!id) return res.status(400).json({ ok: false, error: 'missing_id' })
|
||
NAV_BOOKMARKS_DB.prepare('DELETE FROM nav_bookmarks WHERE id = ?').run(id)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
app.get('/api/nav_bookmarks/state', (req, res) => {
|
||
try {
|
||
const categories = NAV_BOOKMARKS_DB.prepare('SELECT code, name, sort_order, created_at, updated_at FROM nav_categories ORDER BY sort_order ASC, code ASC').all()
|
||
const bookmarks = NAV_BOOKMARKS_DB.prepare('SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at FROM nav_bookmarks ORDER BY sort_order ASC, created_at ASC').all()
|
||
res.json({ ok: true, categories, bookmarks })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// Cloud tool
|
||
app.get('/api/cloud/operators', (req, res) => { try { res.json({ ok: true, rows: cloud.listOperatorsFn() }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.post('/api/cloud/operators', (req, res) => { try { cloud.createOperator(req.body||{}); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.put('/api/cloud/operators/:id', (req, res) => { try { const body = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) }); cloud.updateOperator(body); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.delete('/api/cloud/operators/:id', (req, res) => { try { cloud.deleteOperator(parseInt(req.params.id,10)); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
|
||
app.get('/api/cloud/ecs', (req, res) => { try { const op = req.query.operator_id ? parseInt(req.query.operator_id,10) : null; res.json({ ok:true, rows: cloud.listEcsFn(op) }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.post('/api/cloud/ecs', (req, res) => { try { cloud.createEcs(req.body||{}); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.put('/api/cloud/ecs/:id', (req, res) => { try { const body = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) }); cloud.updateEcs(body); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.delete('/api/cloud/ecs/:id', (req, res) => { try { cloud.deleteEcs(parseInt(req.params.id,10)); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
|
||
app.get('/api/cloud/vas', (req, res) => { try { const op = req.query.operator_id ? parseInt(req.query.operator_id,10) : null; res.json({ ok:true, rows: cloud.listVasFn(op) }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.post('/api/cloud/vas', (req, res) => { try { cloud.createVas(req.body||{}); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.put('/api/cloud/vas/:id', (req, res) => { try { const body = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) }); cloud.updateVas(body); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.delete('/api/cloud/vas/:id', (req, res) => { try { cloud.deleteVas(parseInt(req.params.id,10)); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
|
||
app.get('/api/cloud/renew', (req, res) => { try { const st = String(req.query.subject_type||''); const sid = req.query.subject_id ? parseInt(req.query.subject_id,10) : null; res.json({ ok:true, rows: cloud.listRenewFn(st || null, sid) }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.post('/api/cloud/renew', (req, res) => { try { cloud.createRenew(req.body||{}); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.put('/api/cloud/renew/:id', (req, res) => { try { const body = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) }); cloud.updateRenew(body); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.delete('/api/cloud/renew/:id', (req, res) => { try { cloud.deleteRenew(parseInt(req.params.id,10)); res.json({ ok:true }) } catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) } })
|
||
app.post('/api/cloud/renew/invoice', express.raw({ type: 'application/octet-stream', limit: '50mb' }), (req, res) => {
|
||
try {
|
||
const raw = req.body || Buffer.alloc(0)
|
||
const name = String(req.query.filename || 'invoice.bin').replace(/[^a-zA-Z0-9._-]/g, '_')
|
||
const outName = `${Date.now()}-${name}`
|
||
const full = path.join(invoicesDir, outName)
|
||
fs.writeFileSync(full, raw)
|
||
res.json({ ok: true, path: `/uploads/cloud/invoices/${outName}` })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// Learning Notes API
|
||
const notesDir = path.join(process.cwd(), 'uploads', 'cloud', 'notes')
|
||
if (!fs.existsSync(notesDir)) fs.mkdirSync(notesDir, { recursive: true })
|
||
|
||
app.get('/api/cloud/notes', (req, res) => {
|
||
const st = String(req.query.subject_type || '')
|
||
const sid = parseInt(req.query.subject_id || '0', 10)
|
||
const rows = cloud.listNotesFn(st, sid)
|
||
res.json({ ok: true, rows })
|
||
})
|
||
app.post('/api/cloud/notes', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const id = cloud.createNote(body)
|
||
res.json({ ok: true, id })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.put('/api/cloud/notes/:id', (req, res) => {
|
||
try {
|
||
const body = Object.assign({}, req.body || {}, { id: parseInt(req.params.id, 10) })
|
||
cloud.updateNote(body)
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.delete('/api/cloud/notes/:id', (req, res) => {
|
||
try {
|
||
cloud.deleteNote(parseInt(req.params.id, 10))
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud/notes/attachment', express.raw({ type: 'application/octet-stream', limit: '200mb' }), (req, res) => {
|
||
try {
|
||
const note_id = parseInt(req.query.note_id || '0', 10)
|
||
if (!note_id) return res.status(400).json({ ok: false, error: 'missing_note_id' })
|
||
const raw = req.body || Buffer.alloc(0)
|
||
const name = String(req.query.filename || 'file.bin').replace(/[<>:"/\\|?*]/g, '_')
|
||
|
||
// Chunked upload support
|
||
const chunkIdx = parseInt(req.query.chunk_idx || '-1', 10)
|
||
const totalChunks = parseInt(req.query.total_chunks || '1', 10)
|
||
const uploadId = String(req.query.upload_id || Date.now())
|
||
|
||
const outName = `${uploadId}-${name}`
|
||
const full = path.join(notesDir, outName)
|
||
|
||
if (chunkIdx >= 0) {
|
||
if (chunkIdx === 0) {
|
||
fs.writeFileSync(full, raw)
|
||
} else {
|
||
fs.appendFileSync(full, raw)
|
||
}
|
||
|
||
if (chunkIdx === totalChunks - 1) {
|
||
const stats = fs.statSync(full)
|
||
const attId = cloud.createAttachment({ note_id, filename: name, file_path: `/uploads/cloud/notes/${outName}`, file_size: stats.size })
|
||
return res.json({ ok: true, id: attId, path: `/uploads/cloud/notes/${outName}` })
|
||
} else {
|
||
return res.json({ ok: true, chunk: chunkIdx })
|
||
}
|
||
} else {
|
||
fs.writeFileSync(full, raw)
|
||
const attId = cloud.createAttachment({ note_id, filename: name, file_path: `/uploads/cloud/notes/${outName}`, file_size: raw.length })
|
||
res.json({ ok: true, id: attId, path: `/uploads/cloud/notes/${outName}` })
|
||
}
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.delete('/api/cloud/notes/attachment/:id', (req, res) => {
|
||
try {
|
||
cloud.deleteAttachment(parseInt(req.params.id, 10))
|
||
res.json({ ok: true })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud/balance_alerts', (req, res) => {
|
||
try {
|
||
const page = parseInt(String(req.query.page || '1'), 10) || 1
|
||
const pageSize = parseInt(String(req.query.page_size || '20'), 10) || 20
|
||
const keyword = String(req.query.keyword || '').trim()
|
||
const result = cloudBalanceWatch.listAlerts({ page, pageSize, keyword })
|
||
res.json(Object.assign({ ok: true }, result))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/cloud/balance_alerts/status', (req, res) => {
|
||
;(async () => {
|
||
try {
|
||
const forwarded = await proxyCloudBalanceAutomationStatus('/api/cloud/balance_alerts/status')
|
||
if (forwarded) return res.status(forwarded.status).json(forwarded.body)
|
||
res.json({ ok: true, data: cloudBalanceWatch.getStatus() })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})()
|
||
})
|
||
app.get('/api/cloud/balance_alerts/:id', (req, res) => {
|
||
try {
|
||
const row = cloudBalanceWatch.getAlert(parseInt(String(req.params.id || '0'), 10) || 0)
|
||
if (!row) return res.status(404).json({ ok: false, error: 'not_found' })
|
||
res.json({ ok: true, row })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
const proxyCloudBalanceAutomationRequest = async (req, routePath) => {
|
||
const cfg = cloudBalanceWatch.readConfig()
|
||
const currentPort = Number(process.env.PORT || '8976') || 8976
|
||
const automationPort = Number(cfg.automation_port || 8977) || 8977
|
||
if (currentPort === automationPort) return null
|
||
const url = `http://127.0.0.1:${automationPort}${routePath}`
|
||
const proxied = await axios({
|
||
url,
|
||
method: req.method || 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Cookie: req.headers.cookie || ''
|
||
},
|
||
data: req.body || {},
|
||
timeout: 60000,
|
||
validateStatus: () => true
|
||
})
|
||
return {
|
||
status: proxied.status || 200,
|
||
body: Object.assign({}, proxied.data || {}, {
|
||
proxied_to_automation: true,
|
||
current_port: currentPort,
|
||
automation_port: automationPort
|
||
})
|
||
}
|
||
}
|
||
const proxyCloudBalanceAutomationStatus = async routePath => {
|
||
const cfg = cloudBalanceWatch.readConfig()
|
||
const currentPort = Number(process.env.PORT || '8976') || 8976
|
||
const automationPort = Number(cfg.automation_port || 8977) || 8977
|
||
if (currentPort === automationPort) return null
|
||
const url = `http://127.0.0.1:${automationPort}${routePath}`
|
||
const proxied = await axios.get(url, {
|
||
timeout: 15000,
|
||
validateStatus: () => true
|
||
})
|
||
return {
|
||
status: proxied.status || 200,
|
||
body: Object.assign({}, proxied.data || {}, {
|
||
proxied_to_automation: true,
|
||
current_port: currentPort,
|
||
automation_port: automationPort
|
||
})
|
||
}
|
||
}
|
||
app.post('/api/cloud/balance_alerts/scan', async (req, res) => {
|
||
try {
|
||
const forwarded = await proxyCloudBalanceAutomationRequest(req, '/api/cloud/balance_alerts/scan')
|
||
if (forwarded) return res.status(forwarded.status).json(forwarded.body)
|
||
const result = cloudBalanceWatch.requestScan('manual')
|
||
res.json(Object.assign({ ok: true }, result || {}))
|
||
} catch (e) {
|
||
res.status(500).json({
|
||
ok: false,
|
||
error: String(e && e.message ? e.message : e),
|
||
detail: String(e && e.stack ? e.stack : (e && e.message ? e.message : e)),
|
||
route: '/api/cloud/balance_alerts/scan'
|
||
})
|
||
}
|
||
})
|
||
app.post('/api/cloud/balance_alerts/:id/retry_calendar', async (req, res) => {
|
||
try {
|
||
const forwarded = await proxyCloudBalanceAutomationRequest(req, `/api/cloud/balance_alerts/${encodeURIComponent(String(req.params.id || '0'))}/retry_calendar`)
|
||
if (forwarded) return res.status(forwarded.status).json(forwarded.body)
|
||
const result = await cloudBalanceWatch.retryCalendarById(parseInt(String(req.params.id || '0'), 10) || 0)
|
||
res.json(Object.assign({ ok: true }, result || {}))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/cloud/balance_alerts/:id/retry_weekly', async (req, res) => {
|
||
try {
|
||
const forwarded = await proxyCloudBalanceAutomationRequest(req, `/api/cloud/balance_alerts/${encodeURIComponent(String(req.params.id || '0'))}/retry_weekly`)
|
||
if (forwarded) return res.status(forwarded.status).json(forwarded.body)
|
||
const result = await cloudBalanceWatch.retryWeeklyById(parseInt(String(req.params.id || '0'), 10) || 0)
|
||
res.json(Object.assign({ ok: true }, result || {}))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
|
||
app.post('/api/wall/config', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const cfg = getWallCfg()
|
||
if (Array.isArray(body.cards)) cfg.cards = body.cards
|
||
if (typeof body.autoplay_ms === 'number') cfg.autoplay_ms = body.autoplay_ms
|
||
if (typeof body.swipe_pause_ms === 'number') cfg.swipe_pause_ms = body.swipe_pause_ms
|
||
if (typeof body.max_cards_on_screen === 'number') cfg.max_cards_on_screen = body.max_cards_on_screen
|
||
if (body.background && typeof body.background === 'object') cfg.background = body.background
|
||
setWallCfg(cfg)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/wall/cards', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
createWallCard(body)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.put('/api/wall/cards/:id', (req, res) => {
|
||
try {
|
||
const id = parseInt(req.params.id, 10)
|
||
const body = req.body || {}
|
||
updateWallCardById(id, body)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.delete('/api/wall/cards/:id', (req, res) => {
|
||
try {
|
||
const id = parseInt(req.params.id, 10)
|
||
deleteWallCardById(id)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/markets/archive/get', (req, res) => {
|
||
const day = String(req.query.day || '') || new Date().toISOString().slice(0,10)
|
||
const row = getMarketArchive.get(day)
|
||
res.json({ ok: true, day, html: row ? row.html : '' })
|
||
})
|
||
|
||
app.post('/api/markets/archive/save', (req, res) => {
|
||
const body = req.body || {}
|
||
const day = String(body.day || '') || new Date().toISOString().slice(0,10)
|
||
const html = String(body.html || '')
|
||
upsertMarketArchive.run({ day, html })
|
||
logJSON('markets.archive.save', { day, html_len: html.length }, 'markets')
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
const normalizeDate = s => {
|
||
const d = new Date(s || Date.now())
|
||
const y = d.getFullYear()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${day}`
|
||
}
|
||
|
||
const getWeekRangeFromDate = s => {
|
||
const d = new Date(s || Date.now())
|
||
const dow = d.getDay()
|
||
const shiftToMonday = dow === 0 ? -6 : 1 - dow
|
||
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate() + shiftToMonday)
|
||
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 6)
|
||
return { start: normalizeDate(start), end: normalizeDate(end) }
|
||
}
|
||
|
||
app.post('/api/archives/save', (req, res) => {
|
||
try {
|
||
const body = req.body || {}
|
||
const uid = String(body.uid || '7716940453')
|
||
const start = normalizeDate(body.start || Date.now())
|
||
const end = normalizeDate(body.end || Date.now())
|
||
const html = String(body.html || '')
|
||
upsertArchive.run({ uid, week_start: start, week_end: end, html })
|
||
logJSON('archive.save', { uid, start, end, html_len: html.length }, 'weibo')
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/archives/get', (req, res) => {
|
||
try {
|
||
const uid = String(req.query.uid || '7716940453')
|
||
const date = String(req.query.date || '')
|
||
const startParam = String(req.query.start || '')
|
||
const range = startParam ? { start: startParam, end: String(req.query.end || '') } : getWeekRangeFromDate(date || Date.now())
|
||
const row = getArchive.get(uid, range.start)
|
||
logJSON('archive.get', { uid, start: range.start, found: !!row }, 'weibo')
|
||
res.json({ ok: true, start: range.start, end: range.end, html: row ? row.html : '' })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
const NAV_AUTH_COOKIE = 'nav_gate'
|
||
const hasNavAuthRoot = (req) => {
|
||
try {
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
return cookies[NAV_AUTH_COOKIE] === '1'
|
||
} catch { return false }
|
||
}
|
||
|
||
app.get('/index.html', (req, res) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
const hasCookie = hasNavAuthRoot(req)
|
||
if (token) {
|
||
const dbg = debugVerifyJwt(token, [], { issStrict: computeNavIssStrict() })
|
||
if (!dbg.ok) {
|
||
try { logJSON('nav.handshake.fail', { has_cookie: hasCookie, dbg }, 'nav') } catch {}
|
||
if (hasCookie) return res.sendFile(path.join(process.cwd(), 'public', 'index.html'))
|
||
return res.status(401).send('未授权')
|
||
}
|
||
res.cookie(NAV_AUTH_COOKIE, '1', { httpOnly: true, sameSite: 'lax' })
|
||
return res.redirect('/index.html')
|
||
}
|
||
if (!computeNavAuthEnabled()) return res.sendFile(path.join(process.cwd(), 'public', 'index.html'))
|
||
if (!hasCookie) return res.status(401).send('未授权')
|
||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'))
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
|
||
const getQrLib = (() => {
|
||
let mod = null
|
||
return () => {
|
||
if (mod) return mod
|
||
try { mod = require('qrcode') } catch {}
|
||
return mod
|
||
}
|
||
})()
|
||
|
||
app.get('/share/qr', async (req, res) => {
|
||
try {
|
||
const url = String(req.query.url || '').trim()
|
||
const sizeRaw = parseInt(String(req.query.size || ''), 10)
|
||
const width = Number.isFinite(sizeRaw) ? Math.min(Math.max(sizeRaw, 80), 1024) : 220
|
||
if (!url) return res.status(400).send('missing_url')
|
||
|
||
const QRCode = getQrLib()
|
||
if (!QRCode || typeof QRCode.toFileStream !== 'function') return res.status(500).send('qrcode_unavailable')
|
||
|
||
res.set('Cache-Control', 'no-store')
|
||
res.set('Content-Type', 'image/png')
|
||
await QRCode.toFileStream(res, url, { width, margin: 0 })
|
||
} catch (e) {
|
||
res.status(500).send('qrcode_error')
|
||
}
|
||
})
|
||
|
||
app.get('/s/:id', (req, res) => {
|
||
res.set('Cache-Control', 'no-store')
|
||
res.sendFile(path.join(process.cwd(), 'public', 'tools', 'psc', 'share.html'))
|
||
})
|
||
|
||
const readPscJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'psc.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
app.get('/tools/psc', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readPscJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'TRAE-PSC')) return res.status(401).send('未授权')
|
||
const maxAgePsc = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'psc', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||
}
|
||
} catch {}
|
||
return 90 * 24 * 3600 * 1000
|
||
})()
|
||
res.cookie(NAV_AUTH_COOKIE, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgePsc })
|
||
return res.sendFile(path.join(process.cwd(), 'public', 'tools', 'psc', 'index.html'))
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/psc', (req, res, next) => {
|
||
const hasPscCookie = (() => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'psc', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
const name = String(cfg.cookieName || '')
|
||
if (name) {
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
return cookies[name] && cookies[name].length > 0
|
||
}
|
||
}
|
||
} catch {}
|
||
return false
|
||
})()
|
||
if (hasNavAuthRoot(req) || hasPscCookie) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
|
||
const readRebalanceJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'rebalance.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
app.get('/tools/rebalance', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readRebalanceJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-rebalance')) return res.status(401).send('未授权')
|
||
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'rebalance', 'assets', 'auth_config.json')
|
||
const { maxAgeRb, cookieNameRb } = (() => {
|
||
let cookieName = 'rebalance_gate'
|
||
let maxAge = 90 * 24 * 3600 * 1000
|
||
try {
|
||
if (fs.existsSync(cfgPath)) {
|
||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
|
||
cookieName = String(cfg.cookieName || cookieName)
|
||
}
|
||
} catch {}
|
||
return { maxAgeRb: maxAge, cookieNameRb: cookieName }
|
||
})()
|
||
res.cookie(cookieNameRb, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeRb })
|
||
return res.redirect('/tools/rebalance')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/rebalance', (req, res, next) => {
|
||
if (hasNavAuthRoot(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
|
||
const readDltJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'dlt_draws.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
app.get('/tools/dlt_draws', (req, res, next) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
if (!token) return next()
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readDltJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-dlt_draws')) return res.status(401).send('未授权')
|
||
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'dlt_draws', 'assets', 'auth_config.json')
|
||
const { maxAgeDlt, cookieNameDlt } = (() => {
|
||
let cookieName = 'dlt_draws_gate'
|
||
let maxAge = 90 * 24 * 3600 * 1000
|
||
try {
|
||
if (fs.existsSync(cfgPath)) {
|
||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
|
||
cookieName = String(cfg.cookieName || cookieName)
|
||
}
|
||
} catch {}
|
||
return { maxAgeDlt: maxAge, cookieNameDlt: cookieName }
|
||
})()
|
||
res.cookie(cookieNameDlt, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeDlt })
|
||
return res.redirect('/tools/dlt_draws')
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
app.use('/tools/dlt_draws', (req, res, next) => {
|
||
if (hasNavAuthRoot(req)) return next()
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '')
|
||
if (isHandshake && String(req.query.token || '')) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
|
||
// Yuanzhupai: JWT handshake MUST be before express.static (otherwise directory index intercepts it)
|
||
const YUANZHUPAI_AUTH_COOKIE = 'yuanzhupai_gate'
|
||
const readYuanzhupaiAuthConfig = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'yuanzhupai', 'auth_config.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readYuanzhupaiJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'yuanzhupai.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getYuanzhupaiCookieName = () => {
|
||
try { const n = String(readYuanzhupaiAuthConfig().cookieName || ''); if (n) return n } catch {}
|
||
return YUANZHUPAI_AUTH_COOKIE
|
||
}
|
||
const getYuanzhupaiMaxAge = () => {
|
||
try { const days = Number(readYuanzhupaiAuthConfig().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 getYuanzhupaiGuidConfig = () => {
|
||
try {
|
||
const cfg = readYuanzhupaiAuthConfig()
|
||
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 hasValidYuanzhupaiGuid = req => {
|
||
try {
|
||
const cfg = readYuanzhupaiAuthConfig()
|
||
if (cfg.enable_auth === false) return false
|
||
const { key, value } = getYuanzhupaiGuidConfig()
|
||
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 isYuanzhupaiForceGuid = () => {
|
||
try {
|
||
const cfg = readYuanzhupaiAuthConfig()
|
||
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 hasYuanzhupaiAuth = req => {
|
||
try {
|
||
const config = readYuanzhupaiAuthConfig()
|
||
if (config.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
return cookies[getYuanzhupaiCookieName()] === '1'
|
||
} catch { return false }
|
||
}
|
||
app.get('/tools/yuanzhupai', (req, res, next) => {
|
||
setNoCache(res)
|
||
try {
|
||
const forceGuid = isYuanzhupaiForceGuid()
|
||
if (hasYuanzhupaiAuth(req)) return next()
|
||
const guidKey = getYuanzhupaiGuidConfig().key
|
||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||
if (hasValidYuanzhupaiGuid(req)) {
|
||
const maxAge = getYuanzhupaiMaxAge()
|
||
const cookieName = getYuanzhupaiCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/yuanzhupai/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(readYuanzhupaiJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
{
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-yuanzhupai')) return res.status(401).send('未授权')
|
||
}
|
||
const maxAge = getYuanzhupaiMaxAge()
|
||
const cookieName = getYuanzhupaiCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/yuanzhupai/index.html')
|
||
} catch (e) { return res.status(401).send('未授权') }
|
||
})
|
||
|
||
app.use('/tools/yuanzhupai', (req, res, next) => {
|
||
const forceGuid = isYuanzhupaiForceGuid()
|
||
if (hasYuanzhupaiAuth(req)) return next()
|
||
const guidKey = getYuanzhupaiGuidConfig().key
|
||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||
if (isHandshake && hasValidYuanzhupaiGuid(req)) {
|
||
const maxAge = getYuanzhupaiMaxAge()
|
||
const cookieName = getYuanzhupaiCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/yuanzhupai/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(readYuanzhupaiJwk()))
|
||
if (payload) {
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-yuanzhupai')) return res.status(401).send('未授权')
|
||
const maxAge = getYuanzhupaiMaxAge()
|
||
const cookieName = getYuanzhupaiCookieName()
|
||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||
return res.redirect('/tools/yuanzhupai/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 {
|
||
const p = path.join(process.cwd(), 'public', 'tools', 'box_disaster_sentinel', 'auth_config.json')
|
||
if (fs.existsSync(p)) {
|
||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
if (cfg && typeof cfg === 'object') return cfg
|
||
}
|
||
} catch {}
|
||
return {}
|
||
}
|
||
const readBoxBackupJwk = () => {
|
||
try {
|
||
const p = path.join(process.cwd(), 'config', 'box_disaster_sentinel.jwk.json')
|
||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
} catch {}
|
||
return { kty: 'RSA', n: '', e: '' }
|
||
}
|
||
const getBoxBackupCookieName = () => {
|
||
try {
|
||
const cfg = readBoxBackupAuthConfig()
|
||
const n = String(cfg.cookieName || '')
|
||
if (n) return n
|
||
} catch {}
|
||
return BOX_BACKUP_AUTH_COOKIE
|
||
}
|
||
const getBoxBackupMaxAge = () => {
|
||
try {
|
||
const cfg = readBoxBackupAuthConfig()
|
||
const days = Number(cfg.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 hasBoxBackupAuth = req => {
|
||
try {
|
||
const cfg = readBoxBackupAuthConfig()
|
||
if (cfg.enable_auth === false) return true
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
return cookies[getBoxBackupCookieName()] === '1'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
app.get('/tools/box_disaster_sentinel', (req, res) => {
|
||
try {
|
||
if (hasBoxBackupAuth(req)) {
|
||
return res.sendFile(path.join(process.cwd(), 'public', 'tools', 'box_disaster_sentinel', 'index.html'))
|
||
}
|
||
const token = String(req.query.token || '').trim()
|
||
if (!token) return res.status(401).send('未授权')
|
||
const payload = verifyJwtWithKeys(token, jwkKeys(readBoxBackupJwk()))
|
||
if (!payload) return res.status(401).send('未授权')
|
||
const iss = String(payload.iss || '')
|
||
const navIss = getNavIssFromFlags()
|
||
if (iss === navIss && !audMatch(payload, 'Tools-box_disaster_sentinel')) return res.status(401).send('未授权')
|
||
res.cookie(getBoxBackupCookieName(), '1', { httpOnly: true, sameSite: 'lax', maxAge: getBoxBackupMaxAge(), path: '/' })
|
||
return res.redirect('/tools/box_disaster_sentinel/index.html')
|
||
} catch {
|
||
return res.status(401).send('未授权')
|
||
}
|
||
})
|
||
app.use('/tools/box_disaster_sentinel', (req, res, next) => {
|
||
if (hasBoxBackupAuth(req)) return next()
|
||
return res.status(401).send('未授权')
|
||
})
|
||
app.use('/api/box_backup', (req, res, next) => {
|
||
if (hasBoxBackupAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
app.get('/api/box_backup/overview', (req, res) => {
|
||
try {
|
||
res.json(boxBackup.getOverview())
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/current_run', (req, res) => {
|
||
try {
|
||
res.json(boxBackup.getCurrentRun())
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/runs', (req, res) => {
|
||
try {
|
||
const limit = parseInt(String(req.query.limit || '50'), 10) || 50
|
||
res.json({ ok: true, rows: boxBackup.listRuns(limit) })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/events', (req, res) => {
|
||
try {
|
||
const limit = parseInt(String(req.query.limit || '200'), 10) || 200
|
||
const failuresOnly = String(req.query.failures_only || '') === '1'
|
||
res.json({ ok: true, rows: boxBackup.listEvents({ limit, failuresOnly }) })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/alerts', (req, res) => {
|
||
try {
|
||
const limit = parseInt(String(req.query.limit || '100'), 10) || 100
|
||
res.json({ ok: true, rows: boxBackup.listAlerts(limit) })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/catalog', (req, res) => {
|
||
try {
|
||
const runLimit = parseInt(String(req.query.run_limit || '30'), 10) || 30
|
||
const objectLimit = parseInt(String(req.query.object_limit || '200'), 10) || 200
|
||
const status = String(req.query.status || '').trim()
|
||
res.json(boxBackup.getBackupCatalog({ runLimit, objectLimit, status }))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/config', (req, res) => {
|
||
try {
|
||
res.json(boxBackup.getConfig())
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/box_backup/config/save', (req, res) => {
|
||
try {
|
||
res.json(boxBackup.saveConfig((req.body || {}).config || {}))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/box_backup/manual_baidu_backup', (req, res) => {
|
||
try {
|
||
const row = boxBackup.recordManualBaiduBackup((req.body || {}).note || '')
|
||
res.json({ ok: true, row })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/box_backup/run/high', async (req, res) => {
|
||
try {
|
||
res.json(await boxBackup.triggerRun('high', 'manual'))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/box_backup/run/low', async (req, res) => {
|
||
try {
|
||
res.json(await boxBackup.triggerRun('low', 'manual'))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/box_backup/run/all', async (req, res) => {
|
||
try {
|
||
res.json(await boxBackup.triggerRun('all', 'manual'))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/box_backup/run/verify', async (req, res) => {
|
||
try {
|
||
res.json(await boxBackup.triggerRun('verify', 'manual'))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.post('/api/box_backup/run/restore', async (req, res) => {
|
||
try {
|
||
res.json(await boxBackup.triggerRun('restore', 'manual'))
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/download/highfreq', async (req, res) => {
|
||
try {
|
||
const row = await boxBackup.createHighfreqDownload(req.query.id || '')
|
||
const safe = String(row.file_name || 'box_backup_highfreq.zip').replace(/[\r\n"]/g, '_')
|
||
const fallback = safe.replace(/[^\x20-\x7E]/g, '_')
|
||
const utf8 = encodeURIComponent(safe)
|
||
res.setHeader('Content-Type', row.content_type || 'application/octet-stream')
|
||
if (row.content_length) res.setHeader('Content-Length', String(row.content_length))
|
||
res.setHeader('Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${utf8}`)
|
||
row.stream.on('error', () => { try { res.destroy() } catch {} })
|
||
row.stream.pipe(res)
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/download/lowfreq', async (req, res) => {
|
||
try {
|
||
const row = await boxBackup.createLowfreqDownload(req.query.id || '')
|
||
const safe = String(row.file_name || 'box_backup_lowfreq.bin').replace(/[\r\n"]/g, '_')
|
||
const fallback = safe.replace(/[^\x20-\x7E]/g, '_')
|
||
const utf8 = encodeURIComponent(safe)
|
||
res.setHeader('Content-Type', row.content_type || 'application/octet-stream')
|
||
if (row.content_length) res.setHeader('Content-Length', String(row.content_length))
|
||
res.setHeader('Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${utf8}`)
|
||
row.stream.on('error', () => { try { res.destroy() } catch {} })
|
||
row.stream.pipe(res)
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
})
|
||
app.get('/api/box_backup/download/lowfreq_decrypted', async (req, res) => {
|
||
try {
|
||
const row = await boxBackup.createLowfreqDecryptedDownload(req.query.id || '')
|
||
const safe = String(row.file_name || 'box_backup_lowfreq.bin').replace(/[\r\n"]/g, '_')
|
||
const fallback = safe.replace(/[^\x20-\x7E]/g, '_')
|
||
const utf8 = encodeURIComponent(safe)
|
||
res.setHeader('Content-Type', row.content_type || 'application/octet-stream')
|
||
if (row.content_length) res.setHeader('Content-Length', String(row.content_length))
|
||
res.setHeader('Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${utf8}`)
|
||
res.send(row.data)
|
||
} catch (e) { 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) => {
|
||
try {
|
||
const weeklyRoot = path.join(process.cwd(), 'public', 'tools', 'weekly')
|
||
const docCloudKeeperRoot = path.join(process.cwd(), 'public', 'tools', 'doc_cloud_keeper')
|
||
const boxBackupRoot = path.join(process.cwd(), 'public', 'tools', 'box_disaster_sentinel')
|
||
if (String(filePath || '').startsWith(weeklyRoot) || String(filePath || '').startsWith(docCloudKeeperRoot) || String(filePath || '').startsWith(boxBackupRoot)) {
|
||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
|
||
res.set('Pragma', 'no-cache')
|
||
res.set('Expires', '0')
|
||
res.set('Surrogate-Control', 'no-store')
|
||
try { res.removeHeader('ETag') } catch {}
|
||
try { res.removeHeader('Last-Modified') } catch {}
|
||
}
|
||
} catch {}
|
||
}
|
||
}))
|
||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')))
|
||
|
||
app.get(['/', '/tools', '/tools/'], (req, res) => {
|
||
try {
|
||
const token = String(req.query.token || '')
|
||
const hasCookie = hasNavAuthRoot(req)
|
||
if (token) {
|
||
const dbg = debugVerifyJwt(token, [], { issStrict: computeNavIssStrict() })
|
||
if (!dbg.ok) {
|
||
try { logJSON('nav.handshake.fail', { has_cookie: hasCookie, dbg }, 'nav') } catch {}
|
||
if (hasCookie) return res.sendFile(path.join(process.cwd(), 'public', 'index.html'))
|
||
return res.status(401).send('未授权')
|
||
}
|
||
res.cookie(NAV_AUTH_COOKIE, '1', { httpOnly: true, sameSite: 'lax' })
|
||
return res.redirect(req.path)
|
||
}
|
||
if (!computeNavAuthEnabled()) return res.sendFile(path.join(process.cwd(), 'public', 'index.html'))
|
||
if (!hasCookie) return res.status(401).send('未授权')
|
||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'))
|
||
} catch { return res.status(401).send('未授权') }
|
||
})
|
||
|
||
app.get('/api/tools', (req, res) => {
|
||
const toolsPath = path.join(process.cwd(), 'config', 'tools.json')
|
||
res.sendFile(toolsPath)
|
||
})
|
||
|
||
app.get('/api/home/bg', async (req, res) => {
|
||
try {
|
||
const theme = String(req.query.theme || 'default')
|
||
const url = String(req.query.url || '').trim()
|
||
const preferLocal = String(req.query.prefer_local || '') === '1'
|
||
const allPools = String(req.query.all || '') === '1'
|
||
if (allPools) {
|
||
const baseDir = path.join(process.cwd(), 'public', 'home-bg')
|
||
let entries = []
|
||
try {
|
||
const dirs = fs.readdirSync(baseDir)
|
||
for (const name of dirs) {
|
||
try {
|
||
const full = path.join(baseDir, name)
|
||
const st = fs.statSync(full)
|
||
if (!st.isDirectory()) continue
|
||
const files = fs.readdirSync(full).filter(n => /^pool-\d+(?:-\d+)?\.(?:jpg|jpeg|png|webp)$/i.test(n))
|
||
for (const f of files) entries.push({ dir: name, file: f })
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
if (preferLocal) {
|
||
if (entries.length) {
|
||
const pick = entries[Math.floor(Math.random() * entries.length)]
|
||
const p = `/home-bg/${pick.dir}/${pick.file}?v=${Date.now()}`
|
||
return res.json({ ok: true, url: p })
|
||
}
|
||
return res.json({ ok: true, url: url || '' })
|
||
}
|
||
if (entries.length) {
|
||
const pick = entries[Math.floor(Math.random() * entries.length)]
|
||
const p = `/home-bg/${pick.dir}/${pick.file}?v=${Date.now()}`
|
||
return res.json({ ok: true, url: p })
|
||
}
|
||
return res.json({ ok: true, url: url || '' })
|
||
}
|
||
const themeSlug = (name) => {
|
||
const map = {
|
||
'夜空星轨': 'star-trails',
|
||
'蓝天白云': 'blue-sky',
|
||
'治愈森林': 'healing-forest',
|
||
'运动风': 'sport-style',
|
||
'科技霓虹': 'neon-tech',
|
||
'商务简约': 'business-minimal',
|
||
'暖色日落': 'warm-sunset',
|
||
'冷色极夜': 'polar-night'
|
||
}
|
||
const base = String(map[name] || name).toLowerCase()
|
||
const sanitized = base.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g,'') || 'default'
|
||
const suffix = crypto.createHash('md5').update(name).digest('hex').slice(0,8)
|
||
return `${sanitized}-${suffix}`
|
||
}
|
||
const safe = themeSlug(theme)
|
||
const legacySafe = `${(theme.replace(/[^a-zA-Z0-9._-]/g, '_') || 'default')}-${crypto.createHash('md5').update(theme).digest('hex').slice(0,8)}`
|
||
const dir = path.join(process.cwd(), 'public', 'home-bg', safe)
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||
const legacyDir = path.join(process.cwd(), 'public', 'home-bg', legacySafe)
|
||
try {
|
||
const poolNew = fs.readdirSync(dir).filter(n => /^pool-\d+(?:-\d+)?\.(?:jpg|jpeg|png|webp)$/i.test(n))
|
||
const poolLegacy = fs.existsSync(legacyDir) ? fs.readdirSync(legacyDir).filter(n => /^pool-\d+(?:-\d+)?\.(?:jpg|jpeg|png|webp)$/i.test(n)) : []
|
||
if (poolNew.length === 0 && poolLegacy.length > 0) {
|
||
for (const f of poolLegacy) {
|
||
try { fs.copyFileSync(path.join(legacyDir, f), path.join(dir, f)) } catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
const latestFile = (() => { try { const files = fs.readdirSync(dir); const f = files.find(n => /^latest\.(?:jpg|jpeg|png|webp)$/i.test(n)); return f || '' } catch { return '' } })()
|
||
const poolFiles = (() => { try { const files = fs.readdirSync(dir); return files.filter(n => /^pool-\d+(?:-\d+)?\.(?:jpg|jpeg|png|webp)$/i.test(n)) } catch { return [] } })()
|
||
const seedDir = path.join(process.cwd(), 'public', 'tools', 'wall')
|
||
const seedFiles = (() => { try { return fs.readdirSync(seedDir).filter(n => /^bjt.*\.(?:jpg|jpeg|png|webp)$/i.test(n)) } catch { return [] } })()
|
||
const keywords = (() => {
|
||
const k = {
|
||
'夜空星轨': 'night,sky,stars,astronomy',
|
||
'蓝天白云': 'sky,clouds',
|
||
'治愈森林': 'forest,trees,nature',
|
||
'运动风': 'sport,fitness,run',
|
||
'科技霓虹': 'neon,technology,city,night',
|
||
'商务简约': 'minimal,business,abstract',
|
||
'暖色日落': 'sunset,orange,evening',
|
||
'冷色极夜': 'arctic,blue,night,ice'
|
||
}
|
||
return k[theme] || 'nature,landscape'
|
||
})()
|
||
const tryFetch = async () => {
|
||
if (!url) throw new Error('empty_url')
|
||
const resp = await axios.get(url, { responseType: 'arraybuffer', timeout: 2000, maxContentLength: 15728640 })
|
||
const ct = String((resp.headers && resp.headers['content-type']) || '')
|
||
if (!/^image\//i.test(ct)) throw new Error('not_image')
|
||
const ext = ct.includes('webp') ? 'webp' : (ct.includes('png') ? 'png' : 'jpg')
|
||
const out = path.join(dir, `latest.${ext}`)
|
||
fs.writeFileSync(out, Buffer.from(resp.data))
|
||
return `/home-bg/${safe}/latest.${ext}?v=${Date.now()}`
|
||
}
|
||
const tryFetchRandomUnsplash = async () => {
|
||
const sig = Math.floor(Math.random()*1000000)
|
||
const u = `https://image.pollinations.ai/prompt/${encodeURIComponent(keywords)}?width=1920&height=1080&nologo=true&seed=${sig}`
|
||
const resp = await axios.get(u, { responseType: 'arraybuffer', timeout: 2000, maxContentLength: 15728640 })
|
||
const ct = String((resp.headers && resp.headers['content-type']) || '')
|
||
if (!/^image\//i.test(ct)) throw new Error('not_image')
|
||
const ext = ct.includes('webp') ? 'webp' : (ct.includes('png') ? 'png' : 'jpg')
|
||
const out = path.join(dir, `latest.${ext}`)
|
||
fs.writeFileSync(out, Buffer.from(resp.data))
|
||
return `/home-bg/${safe}/latest.${ext}?v=${Date.now()}`
|
||
}
|
||
const pickLocal = () => {
|
||
if (poolFiles.length > 0) {
|
||
const f = poolFiles[Math.floor(Math.random()*poolFiles.length)]
|
||
return `/home-bg/${safe}/${f}?v=${Date.now()}`
|
||
}
|
||
if (latestFile) {
|
||
return `/home-bg/${safe}/${latestFile}?v=${Date.now()}`
|
||
}
|
||
if (seedFiles.length > 0) {
|
||
try {
|
||
const s = seedFiles[Math.floor(Math.random()*seedFiles.length)]
|
||
const ext = (/\.(webp|png|jpg|jpeg)$/i.test(s) ? RegExp.$1.toLowerCase() : 'jpg')
|
||
const out = path.join(dir, `pool-${Date.now()}-seed.${ext === 'jpeg' ? 'jpg' : ext}`)
|
||
fs.copyFileSync(s, out)
|
||
const name = path.basename(out)
|
||
return `/home-bg/${safe}/${name}?v=${Date.now()}`
|
||
} catch {}
|
||
}
|
||
return ''
|
||
}
|
||
const ensurePool = () => {
|
||
const need = Math.max(0, 10 - poolFiles.length)
|
||
if (need <= 0) return
|
||
const tasks = Array.from({ length: need }).map((_, i) => (async () => {
|
||
try {
|
||
const sig = Math.floor(Math.random()*1000000)
|
||
const u = `https://image.pollinations.ai/prompt/${encodeURIComponent(keywords)}?width=1920&height=1080&nologo=true&seed=${sig}`
|
||
const resp = await axios.get(u, { responseType: 'arraybuffer', timeout: 4000, maxContentLength: 15728640 })
|
||
const ct = String((resp.headers && resp.headers['content-type']) || '')
|
||
if (!/^image\//i.test(ct)) return
|
||
const ext = ct.includes('webp') ? 'webp' : (ct.includes('png') ? 'png' : 'jpg')
|
||
const out = path.join(dir, `pool-${Date.now()}-${i}.${ext}`)
|
||
fs.writeFileSync(out, Buffer.from(resp.data))
|
||
} catch {}
|
||
}))
|
||
tasks.forEach(t => { try { t.then(()=>{}).catch(()=>{}) } catch {} })
|
||
}
|
||
ensurePool()
|
||
let finalUrl = ''
|
||
if (preferLocal) {
|
||
const p = pickLocal()
|
||
if (p) return res.json({ ok: true, url: p })
|
||
return res.json({ ok: true, url: url || '' })
|
||
}
|
||
try {
|
||
finalUrl = await tryFetch()
|
||
} catch {
|
||
try {
|
||
finalUrl = await tryFetchRandomUnsplash()
|
||
} catch {
|
||
const files = poolFiles.length ? poolFiles : (latestFile ? [latestFile] : [])
|
||
if (files.length) {
|
||
const f = files[Math.floor(Math.random()*files.length)]
|
||
finalUrl = `/home-bg/${safe}/${f}?v=${Date.now()}`
|
||
} else {
|
||
finalUrl = url
|
||
}
|
||
}
|
||
}
|
||
res.json({ ok: true, url: finalUrl })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/home/bg/populate', async (req, res) => {
|
||
try {
|
||
const themes = [
|
||
'夜空星轨','蓝天白云','治愈森林','运动风','科技霓虹','商务简约','暖色日落','冷色极夜'
|
||
]
|
||
const keywordsMap = {
|
||
'夜空星轨': 'night,sky,stars,astronomy',
|
||
'蓝天白云': 'sky,clouds',
|
||
'治愈森林': 'forest,trees,nature',
|
||
'运动风': 'sport,fitness,run',
|
||
'科技霓虹': 'neon,technology,city,night',
|
||
'商务简约': 'minimal,business,abstract',
|
||
'暖色日落': 'sunset,orange,evening',
|
||
'冷色极夜': 'arctic,blue,night,ice'
|
||
}
|
||
const count = Math.max(1, Math.min(20, parseInt(String((req.body||{}).count||'10'),10) || 10))
|
||
const seedDir = path.join(process.cwd(), 'public', 'tools', 'wall')
|
||
const seedFiles = (() => { try { return fs.readdirSync(seedDir).filter(n => /^bjt.*\.(?:jpg|jpeg|png|webp)$/i.test(n)).map(n => path.join(seedDir, n)) } catch { return [] } })()
|
||
const results = []
|
||
for (const theme of themes) {
|
||
const themeSlug = (name) => {
|
||
const map = {
|
||
'夜空星轨': 'star-trails',
|
||
'蓝天白云': 'blue-sky',
|
||
'治愈森林': 'healing-forest',
|
||
'运动风': 'sport-style',
|
||
'科技霓虹': 'neon-tech',
|
||
'商务简约': 'business-minimal',
|
||
'暖色日落': 'warm-sunset',
|
||
'冷色极夜': 'polar-night'
|
||
}
|
||
const base = String(map[name] || name).toLowerCase()
|
||
const sanitized = base.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g,'') || 'default'
|
||
const suffix = crypto.createHash('md5').update(name).digest('hex').slice(0,8)
|
||
return `${sanitized}-${suffix}`
|
||
}
|
||
const safe = themeSlug(theme)
|
||
const legacySafe = `${(theme.replace(/[^a-zA-Z0-9._-]/g, '_') || 'default')}-${crypto.createHash('md5').update(theme).digest('hex').slice(0,8)}`
|
||
const dir = path.join(process.cwd(), 'public', 'home-bg', safe)
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||
const legacyDir = path.join(process.cwd(), 'public', 'home-bg', legacySafe)
|
||
try {
|
||
const poolNew = fs.readdirSync(dir).filter(n => /^pool-\d+(?:-\d+)?\.(?:jpg|jpeg|png|webp)$/i.test(n))
|
||
const poolLegacy = fs.existsSync(legacyDir) ? fs.readdirSync(legacyDir).filter(n => /^pool-\d+(?:-\d+)?\.(?:jpg|jpeg|png|webp)$/i.test(n)) : []
|
||
if (poolNew.length === 0 && poolLegacy.length > 0) {
|
||
for (const f of poolLegacy) {
|
||
try { fs.copyFileSync(path.join(legacyDir, f), path.join(dir, f)) } catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
const existing = (() => { try { return fs.readdirSync(dir).filter(n => /^pool-\d+(?:-\d+)?\.(?:jpg|jpeg|png|webp)$/i.test(n)) } catch { return [] } })()
|
||
const need = Math.max(0, count - existing.length)
|
||
const kw = keywordsMap[theme] || 'nature,landscape'
|
||
let ok = 0, fail = 0
|
||
for (let i = 0; i < need; i++) {
|
||
const sig = Math.floor(Math.random()*1000000)
|
||
const u = `https://image.pollinations.ai/prompt/${encodeURIComponent(kw)}?width=1920&height=1080&nologo=true&seed=${sig}`
|
||
try {
|
||
const resp = await axios.get(u, { responseType: 'arraybuffer', timeout: 8000, maxContentLength: 15728640 })
|
||
const ct = String((resp.headers && resp.headers['content-type']) || '')
|
||
if (!/^image\//i.test(ct)) throw new Error('not_image')
|
||
const ext = ct.includes('webp') ? 'webp' : (ct.includes('png') ? 'png' : 'jpg')
|
||
const out = path.join(dir, `pool-${Date.now()}-${i}.${ext}`)
|
||
fs.writeFileSync(out, Buffer.from(resp.data))
|
||
ok++
|
||
} catch {
|
||
try {
|
||
const picsumUrl = `https://picsum.photos/1920/1080?random=${sig}`
|
||
const resp2 = await axios.get(picsumUrl, { responseType: 'arraybuffer', timeout: 8000, maxContentLength: 15728640 })
|
||
const ct2 = String((resp2.headers && resp2.headers['content-type']) || '')
|
||
if (!/^image\//i.test(ct2)) throw new Error('not_image')
|
||
const ext2 = ct2.includes('png') ? 'png' : 'jpg'
|
||
const out2 = path.join(dir, `pool-${Date.now()}-${i}.${ext2}`)
|
||
fs.writeFileSync(out2, Buffer.from(resp2.data))
|
||
ok++
|
||
} catch {
|
||
try {
|
||
if (seedFiles.length) {
|
||
const pick = seedFiles[i % seedFiles.length]
|
||
const ext3 = (/\.(webp|png)$/i.test(pick)) ? (RegExp.$1.toLowerCase()) : 'jpg'
|
||
const out3 = path.join(dir, `pool-${Date.now()}-${i}.${ext3}`)
|
||
fs.copyFileSync(pick, out3)
|
||
ok++
|
||
} else {
|
||
fail++
|
||
}
|
||
} catch { fail++ }
|
||
}
|
||
}
|
||
}
|
||
results.push({ theme, dir: `/home-bg/${safe}/`, downloaded: ok, failed: fail, existing: existing.length, seedCount: seedFiles.length })
|
||
}
|
||
res.json({ ok: true, results })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
const ensureWeeklyLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./weekly') } catch (e) { try { logJSON('weekly.require.error', { error: String(e.message || e) }, 'weekly') } catch {}; setTimeout(ensureWeeklyLoaded, 2000); return }
|
||
weekly = mod
|
||
try {
|
||
if (!weeklyBound && weekly && weekly.bindRoutes) {
|
||
weekly.bindRoutes(app)
|
||
weeklyBound = true
|
||
try { logJSON('weekly.bind_routes.done', { ok: true }, 'weekly') } catch {}
|
||
} else {
|
||
try { logJSON('weekly.bind_routes.missing', { ok: false }, 'weekly') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('weekly.bind_routes.error', { error: String(e.message || e) }, 'weekly') } catch {}
|
||
}
|
||
try {
|
||
if (weekly && weekly.startScheduler) weekly.startScheduler()
|
||
} catch (e) {
|
||
try { logJSON('weekly.scheduler.start.error', { error: String(e.message || e) }, 'weekly') } catch {}
|
||
}
|
||
}
|
||
|
||
const ensureWeeklyEmbedLoaded = () => {
|
||
let mod = null
|
||
try { mod = require('./weekly_embed') } catch (e) { try { logJSON('weekly_embed.require.error', { error: String(e.message || e) }, 'weekly_embed') } catch {}; setTimeout(ensureWeeklyEmbedLoaded, 2000); return }
|
||
weeklyEmbed = mod
|
||
try {
|
||
if (!weeklyEmbedBound && weeklyEmbed && weeklyEmbed.bindRoutes) {
|
||
weeklyEmbed.bindRoutes(app)
|
||
weeklyEmbedBound = true
|
||
try { logJSON('weekly_embed.bind_routes.done', { ok: true }, 'weekly_embed') } catch {}
|
||
} else {
|
||
try { logJSON('weekly_embed.bind_routes.missing', { ok: false }, 'weekly_embed') } catch {}
|
||
}
|
||
} catch (e) {
|
||
try { logJSON('weekly_embed.bind_routes.error', { error: String(e.message || e) }, 'weekly_embed') } catch {}
|
||
}
|
||
}
|
||
|
||
ensureWeeklyLoaded()
|
||
ensureWeeklyEmbedLoaded()
|
||
ensurePscLoaded()
|
||
aiLib.bindRoutes(app)
|
||
expense.bindRoutes(app)
|
||
investmentLedger.bindRoutes(app)
|
||
styleCheck.bindRoutes(app)
|
||
globalNews.bindRoutes(app)
|
||
|
||
const marketsCfgAtBoot = getMarketsCfg()
|
||
const currentPort = Number(process.env.PORT || '8976') || 8976
|
||
const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) || 8977
|
||
if (currentPort === marketsAutomationPort) {
|
||
startMarketsScheduler()
|
||
marketSkills.start()
|
||
}
|
||
securityCalendar.startScheduler()
|
||
securityCalendarBridge.startScheduler(securityCalendar)
|
||
marketsWeeklyReport.startScheduler()
|
||
globalNews.startScheduler()
|
||
|
||
app.use('/api/yuanzhupai', (req, res, next) => {
|
||
if (req.path === '/auth/hint') return next()
|
||
if (hasYuanzhupaiAuth(req)) return next()
|
||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||
})
|
||
|
||
app.get('/api/yuanzhupai/auth/hint', (req, res) => {
|
||
setNoCache(res)
|
||
res.json({ ok: true, message: '请通过首页九宫格点击「圆桌派」进入,或使用正确的访问链接' })
|
||
})
|
||
|
||
app.get('/api/yuanzhupai/scenarios', (req, res) => {
|
||
setNoCache(res)
|
||
try {
|
||
const scenarios = yuanzhupai.listScenarios()
|
||
res.json({ ok: true, scenarios })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/yuanzhupai/analysts', (req, res) => {
|
||
setNoCache(res)
|
||
try {
|
||
const cfg = yuanzhupai.readConfig()
|
||
const version = String(req.query.version || 'x').trim()
|
||
const scenarioId = String(req.query.scenario || 'stock_invest').trim()
|
||
const { panelists, chairman, scenario } = yuanzhupai.getScenarioAnalysts(scenarioId, version)
|
||
const analysts = chairman ? [...panelists, chairman] : panelists
|
||
res.json({ ok: true, analysts, ui_mode: (cfg && cfg.ui_mode) || 'debug', scenario })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/yuanzhupai/analyze', async (req, res) => {
|
||
setNoCache(res)
|
||
try {
|
||
const body = req.body || {}
|
||
const code = String(body.code || '').trim()
|
||
if (!code) return res.status(400).json({ ok: false, error: '请输入会议议题' })
|
||
const version = String(body.version || 'x').trim()
|
||
const scenarioId = String(body.scenario || 'stock_invest').trim()
|
||
const imageBase64 = String(body.imageBase64 || '')
|
||
const userProfile = body.userProfile || null
|
||
const logs = []
|
||
const result = await yuanzhupai.runMeeting(scenarioId, code, version, line => logs.push(line), { imageBase64, userProfile })
|
||
res.json({ ok: true, result, logs })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/yuanzhupai/sessions', (req, res) => {
|
||
setNoCache(res)
|
||
try {
|
||
const limit = Math.min(Number(req.query.limit) || 20, 100)
|
||
const sessions = yuanzhupai.listSessions(limit)
|
||
res.json({ ok: true, sessions })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/yuanzhupai/sessions/:id', (req, res) => {
|
||
setNoCache(res)
|
||
try {
|
||
const session = yuanzhupai.getSession(req.params.id)
|
||
if (!session) return res.status(404).json({ ok: false, error: 'session_not_found' })
|
||
res.json({ ok: true, session })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
const port = process.env.PORT || 8976
|
||
app.listen(port, () => {
|
||
console.log(`Yang's Toolbox server listening on http://localhost:${port}`)
|
||
cloudBalanceWatch.start().catch(err => {
|
||
try { logJSON('cloud.balance.listen.start.error', { error: String(err && err.message ? err.message : err) }, 'cloud') } catch {}
|
||
})
|
||
})
|
||
|
||
|