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') const dataGateway = require('./data_gateway') const ossFileCabinet = require('./oss_file_cabinet') yuanzhupai.initDb() const app = express() // 安全响应头 app.use((req, res, next) => { res.set('X-Frame-Options', 'DENY') res.set('X-Content-Type-Options', 'nosniff') res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()') next() }) // 移除 X-Powered-By 响应头(避免泄露 Express 版本信息) app.disable('x-powered-by') // CORS 白名单限制 — 仅允许指定域名跨域访问 API const allowedOrigins = [ 'https://traesite.umersoft.com', 'http://traesite.umersoft.com', 'https://traesite.umersoft.com:8975', 'http://traesite.umersoft.com:8975' ] app.use(cors({ origin: function (origin, callback) { // 允许无 origin 的请求(如 curl、服务器端调用) if (!origin) return callback(null, true) if (allowedOrigins.indexOf(origin) !== -1) { callback(null, true) } else { callback(new Error('Not allowed by CORS')) } }, credentials: true })) // 强制 HTTPS:HTTP 请求返回 301 跳转到 HTTPS app.use((req, res, next) => { const proto = req.headers['x-forwarded-proto'] || '' // 非本地环境且非 HTTPS 时,强制跳转 if (proto && proto !== 'https') { const httpsUrl = `https://${req.headers.host}${req.url}` return res.redirect(301, httpsUrl) } next() }) 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 ensureAppCalendarReminderLoaded = () => { let mod = null try { mod = require('./app_calendar_reminder') } catch (e) { try { logJSON('app_calendar_reminder.require.error', { error: String(e.message || e) }, 'app_calendar_reminder') } catch {}; setTimeout(ensureAppCalendarReminderLoaded, 2000); return } try { if (mod && mod.bindRoutes) { mod.bindRoutes(app) try { logJSON('app_calendar_reminder.bind_routes.done', { ok: true }, 'app_calendar_reminder') } catch {} } } catch (e) { try { logJSON('app_calendar_reminder.bind_routes.error', { error: String(e.message || e) }, 'app_calendar_reminder') } catch {} } } ensureAppCalendarReminderLoaded() const ensureAppOrderBoxLoaded = () => { let mod = null try { mod = require('./app_order_box') } catch (e) { try { logJSON('app_order_box.require.error', { error: String(e.message || e) }, 'app_order_box') } catch {}; setTimeout(ensureAppOrderBoxLoaded, 2000); return } try { if (mod && mod.bindRoutes) { mod.bindRoutes(app) try { logJSON('app_order_box.bind_routes.done', { ok: true }, 'app_order_box') } catch {} } } catch (e) { try { logJSON('app_order_box.bind_routes.error', { error: String(e.message || e) }, 'app_order_box') } catch {} } } ensureAppOrderBoxLoaded() 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-oss_file_cabinet': '/tools/oss_file_cabinet', '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 (req.path === '/auth/login' || req.path === '/auth/logout') return 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 OSS_FILE_CABINET_AUTH_COOKIE = 'oss_file_cabinet_gate' const readOssFileCabinetAuthConfig = () => { try { const p = path.join(process.cwd(), 'public', 'tools', 'oss_file_cabinet', '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 readOssFileCabinetJwk = () => { try { const p = path.join(process.cwd(), 'config', 'oss_file_cabinet.jwk.json') if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} return { kty: 'RSA', n: '', e: '' } } const getOssFileCabinetCookieName = () => { try { const cfg = readOssFileCabinetAuthConfig() const n = String(cfg.cookieName || '') if (n) return n } catch {} return OSS_FILE_CABINET_AUTH_COOKIE } const getOssFileCabinetMaxAge = () => { try { const cfg = readOssFileCabinetAuthConfig() 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 getOssFileCabinetGuidConfig = () => { try { const cfg = readOssFileCabinetAuthConfig() 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 hasValidOssFileCabinetGuid = (req) => { try { const cfg = readOssFileCabinetAuthConfig() if (cfg.enable_auth === false) return false const { key, value } = getOssFileCabinetGuidConfig() 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 hasOssFileCabinetAuth = (req) => { try { const cfg = readOssFileCabinetAuthConfig() if (cfg.enable_auth === false) return true const cookies = parseCookie(req.headers.cookie || '') const name = getOssFileCabinetCookieName() return cookies[name] === '1' } catch { return false } } const isOssFileCabinetForceGuid = () => { try { const cfg = readOssFileCabinetAuthConfig() 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/oss_file_cabinet', (req, res, next) => { setNoCache(res) try { const forceGuid = isOssFileCabinetForceGuid() if (hasOssFileCabinetAuth(req)) return next() const guidKey = getOssFileCabinetGuidConfig().key const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey) if (hasValidOssFileCabinetGuid(req)) { const maxAgeOfc = getOssFileCabinetMaxAge() const cookieName = getOssFileCabinetCookieName() res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) return res.redirect('/tools/oss_file_cabinet/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(readOssFileCabinetJwk())) if (!payload) return res.status(401).send('未授权') { const iss = String(payload.iss || '') const navIss = getNavIssFromFlags() if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权') } const maxAgeOfc = getOssFileCabinetMaxAge() const cookieName = getOssFileCabinetCookieName() res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) return res.redirect('/tools/oss_file_cabinet/index.html') } catch { return res.status(401).send('未授权') } }) app.use('/tools/oss_file_cabinet', (req, res, next) => { const forceGuid = isOssFileCabinetForceGuid() if (hasOssFileCabinetAuth(req)) return next() const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html') const guidKey = getOssFileCabinetGuidConfig().key const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey) if (isHandshake && hasValidOssFileCabinetGuid(req)) { const maxAgeOfc = getOssFileCabinetMaxAge() const cookieName = getOssFileCabinetCookieName() res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) return res.redirect('/tools/oss_file_cabinet/index.html') } if (hasGuidInput) return res.status(401).send('未授权') if (isHandshake && String(req.query.token || '')) { const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readOssFileCabinetJwk())) if (payload) { const iss = String(payload.iss || '') const navIss = getNavIssFromFlags() if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权') const maxAgeOfc = getOssFileCabinetMaxAge() const cookieName = getOssFileCabinetCookieName() res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) return res.redirect('/tools/oss_file_cabinet/index.html') } } if (isHandshake && forceGuid) return res.status(401).send('未授权') return res.status(401).send('未授权') }) app.use('/api/oss_file_cabinet', (req, res, next) => { if (req.path.startsWith('/share/access/')) return next() if (hasOssFileCabinetAuth(req)) return next() return res.status(401).json({ ok: false, error: '未授权' }) }) 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 } // Cloud Notes 登录速率限制:同一 IP 5 次失败后锁定 15 分钟 const cnLoginRateLimit = new Map() const CN_LOGIN_MAX_FAIL = 5 const CN_LOGIN_LOCK_MS = 15 * 60 * 1000 const checkCnLoginRate = (ip) => { const rec = cnLoginRateLimit.get(ip) if (!rec) return { ok: true } if (rec.lockedUntil && Date.now() < rec.lockedUntil) return { ok: false, retryAfter: Math.ceil((rec.lockedUntil - Date.now()) / 1000) } if (rec.failCount >= CN_LOGIN_MAX_FAIL) { rec.lockedUntil = Date.now() + CN_LOGIN_LOCK_MS rec.failCount = 0 return { ok: false, retryAfter: Math.ceil(CN_LOGIN_LOCK_MS / 1000) } } return { ok: true } } const recordCnLoginFail = (ip) => { const rec = cnLoginRateLimit.get(ip) || { failCount: 0 } rec.failCount++ cnLoginRateLimit.set(ip, rec) } const recordCnLoginSuccess = (ip) => { cnLoginRateLimit.delete(ip) } // 每分钟清理一次过期记录 setInterval(() => { const now = Date.now() for (const [ip, rec] of cnLoginRateLimit.entries()) { if (rec.lockedUntil && now > rec.lockedUntil + 60000) cnLoginRateLimit.delete(ip) } }, 60000) app.post('/api/cloud_notes/login', (req, res) => { try { const clientIp = req.ip || req.connection.remoteAddress || 'unknown' // 检查速率限制 const rateCheck = checkCnLoginRate(clientIp) if (!rateCheck.ok) { return res.status(429).json({ ok: false, error: '登录尝试过于频繁,请在 ' + rateCheck.retryAfter + ' 秒后重试' }) } const body = req.body || {} const u = cloudNotes.login(String(body.username || ''), String(body.password || '')) if (!u) { recordCnLoginFail(clientIp) return res.status(401).json({ ok: false, error: 'unauthorized' }) } recordCnLoginSuccess(clientIp) 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(``) } } 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) }) } }) app.get('/api/zen_box/playlists', (req, res) => { try { const zenDir = path.join(process.cwd(), 'public', 'tools', 'zen_box') const files = fs.existsSync(zenDir) ? fs.readdirSync(zenDir, { withFileTypes: true }) : [] const playlists = files .filter(entry => entry && entry.isFile && entry.isFile()) .map(entry => String(entry.name || '')) .filter(name => /playlist\.json$/i.test(name)) .sort((a, b) => { if (a === 'playlist.json') return -1 if (b === 'playlist.json') return 1 return a.localeCompare(b, 'zh-CN') }) res.json({ ok: true, playlists }) } 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') // SSRF 防护:禁止访问内网地址 try { const parsed = new URL(url) const hostname = parsed.hostname.toLowerCase() const blocked = ['localhost', '127.', '0.', '10.', '172.16.', '172.17.', '172.18.', '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', '192.168.', '169.254.', '::1', '[::1]', 'fe80:', 'fc00:', 'fd00:'] if (blocked.some(b => hostname === b || hostname.startsWith(b))) { throw new Error('ssrf_blocked') } } catch (e) { if (e.message === 'ssrf_blocked') throw e // 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) dataGateway.bindRoutes(app) ossFileCabinet.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) }) } }) // 404 处理:覆盖 Express 默认 "Cannot GET" 错误页,避免泄露技术栈特征 app.use((req, res) => { res.status(404).json({ ok: false, error: 'not_found' }) }) 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 {} }) })