import fs from 'fs'; import express from 'express'; import crypto from 'crypto'; import path from 'path'; import { fileURLToPath } from 'url'; import logger from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PUSH_CHANNEL_TYPES = new Set([ 'post_json', 'bark', 'get', 'dingtalk', 'pushplus', 'serverchan', 'custom', 'feishu', 'telegram' ]); const PUSH_CHANNEL_TYPE_LABELS = { post_json: 'POST JSON', bark: 'Bark', get: 'GET', dingtalk: '钉钉', pushplus: 'PushPlus', serverchan: 'Server酱', custom: '自定义', feishu: '飞书', telegram: 'Telegram' }; class APIServer { constructor(config, modem, smsProcessor, pushManager) { this.config = config; this.modem = modem; this.smsProcessor = smsProcessor; this.pushManager = pushManager; this.app = express(); this.publicDir = path.join(__dirname, '../public'); this.configPath = path.join(__dirname, '../config.json'); this.setupMiddleware(); this.setupRoutes(); } setupMiddleware() { // JSON解析 this.app.use(express.json()); // Web Token认证,API保留Basic Auth兼容 this.app.use((req, res, next) => { this.authenticateRequest(req, res, next); }); // 请求日志 this.app.use((req, res, next) => { logger.info(`${req.method} ${req.path}`); next(); }); this.app.use('/assets', express.static(path.join(this.publicDir, 'assets'))); } authenticateRequest(req, res, next) { if (this.hasValidWebToken(req)) { this.persistWebToken(req, res); if (this.shouldCleanTokenFromUrl(req)) { return res.redirect(302, this.getCleanUrl(req)); } return next(); } if (this.isWebRoute(req) && this.getConfiguredWebToken()) { return this.sendTokenGate(res); } if (this.hasValidBasicAuth(req)) { return next(); } if (req.path.startsWith('/api/')) { return res.status(401).json({ success: false, error: this.getConfiguredWebToken() ? '需要有效token或Basic Auth' : '需要Basic Auth' }); } return this.sendBasicAuthChallenge(res); } getConfiguredWebToken() { return String(this.config.api.webToken || '').trim(); } hasValidWebToken(req) { const expected = this.getConfiguredWebToken(); if (!expected) { return false; } const token = this.getRequestToken(req); return this.safeEqual(token, expected); } getRequestToken(req) { const bearer = req.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]; return req.query.token || req.get('x-web-token') || bearer || this.getCookie(req, 'sms_gateway_token') || ''; } getCookie(req, name) { const cookieHeader = req.get('cookie') || ''; const cookies = cookieHeader.split(';').map(item => item.trim()); const prefix = `${name}=`; const cookie = cookies.find(item => item.startsWith(prefix)); return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : ''; } persistWebToken(req, res) { if (!req.query.token) { return; } res.cookie('sms_gateway_token', req.query.token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, path: '/' }); } shouldCleanTokenFromUrl(req) { return this.isWebRoute(req) && Boolean(req.query.token); } getCleanUrl(req) { const url = new URL(req.originalUrl, 'http://localhost'); url.searchParams.delete('token'); return `${url.pathname}${url.search}`; } hasValidBasicAuth(req) { const auth = req.get('authorization') || ''; if (!auth.startsWith('Basic ')) { return false; } const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); const separatorIndex = decoded.indexOf(':'); if (separatorIndex === -1) { return false; } const username = decoded.slice(0, separatorIndex); const password = decoded.slice(separatorIndex + 1); return this.safeEqual(username, this.config.api.auth.username) && this.safeEqual(password, this.config.api.auth.password); } safeEqual(actual, expected) { const actualBuffer = Buffer.from(String(actual)); const expectedBuffer = Buffer.from(String(expected)); if (actualBuffer.length !== expectedBuffer.length) { return false; } return crypto.timingSafeEqual(actualBuffer, expectedBuffer); } isWebRoute(req) { return req.path === '/' || req.path === '/admin' || req.path.startsWith('/assets/'); } sendBasicAuthChallenge(res) { res.set('WWW-Authenticate', 'Basic realm="SMS Gateway"'); return res.status(401).send('Authentication required'); } sendTokenGate(res) { res.set('Cache-Control', 'no-store'); return res.status(401).type('html').send(` SMS Gateway Token

输入访问 token

管理台需要有效 token 才能继续访问。

`); } async readConfigFile() { const configContent = await fs.promises.readFile(this.configPath, 'utf8'); return JSON.parse(configContent); } async savePushChannels(channels) { const diskConfig = await this.readConfigFile(); diskConfig.pushChannels = channels; await fs.promises.writeFile( this.configPath, `${JSON.stringify(diskConfig, null, 2)}\n`, 'utf8' ); this.config.pushChannels = channels; if (this.pushManager?.config) { this.pushManager.config.pushChannels = channels; } } getPushChannelsFromConfig(config = this.config) { return Array.isArray(config.pushChannels) ? config.pushChannels : []; } normalizePushChannels(channels) { if (!Array.isArray(channels)) { throw new Error('pushChannels 必须是数组'); } if (channels.length > 30) { throw new Error('推送通道数量不能超过 30 个'); } return channels.map((channel, index) => this.normalizePushChannel(channel, index)); } normalizePushChannel(channel, index = 0) { if (!channel || typeof channel !== 'object' || Array.isArray(channel)) { throw new Error(`第 ${index + 1} 个推送通道格式不正确`); } const type = this.normalizeString(channel.type, '推送类型', 32); if (!PUSH_CHANNEL_TYPES.has(type)) { throw new Error(`第 ${index + 1} 个推送通道类型不支持`); } const typeLabel = PUSH_CHANNEL_TYPE_LABELS[type] || type; const fallbackName = `${typeLabel}通知`; const name = this.normalizeString(channel.name || fallbackName, '通道名称', 80); return { enabled: Boolean(channel.enabled), type, name: name || fallbackName, url: this.normalizeString(channel.url, 'URL', 2048), secret: this.normalizeString(channel.secret, '密钥', 2048), key1: this.normalizeString(channel.key1, 'Key1', 2048), key2: this.normalizeString(channel.key2, 'Key2', 2048), customBody: this.normalizeString(channel.customBody, '自定义请求体', 10000) }; } normalizeString(value, fieldName, maxLength) { const text = String(value ?? '').trim(); if (text.length > maxLength) { throw new Error(`${fieldName}长度不能超过 ${maxLength} 个字符`); } return text; } validatePushChannels(channels) { channels.forEach((channel, index) => { this.validatePushChannel(channel, { index }); }); } validatePushChannel(channel, options = {}) { const index = options.index ?? 0; const requireDestination = Boolean(options.requireDestination || channel.enabled); const prefix = `第 ${index + 1} 个推送通道`; const urlTypes = new Set(['post_json', 'bark', 'get', 'dingtalk', 'custom', 'feishu']); if (urlTypes.has(channel.type)) { if (requireDestination && !channel.url) { throw new Error(`${prefix}缺少 URL`); } if (channel.url && !this.isHttpUrl(channel.url)) { throw new Error(`${prefix}的 URL 格式不正确`); } } if (channel.type === 'telegram') { if (requireDestination && !channel.url) { throw new Error(`${prefix}缺少 Bot Token`); } if (requireDestination && !channel.key1) { throw new Error(`${prefix}缺少 Chat ID`); } } if (channel.type === 'pushplus' && requireDestination && !channel.key1) { throw new Error(`${prefix}缺少 PushPlus Token`); } if (channel.type === 'serverchan' && requireDestination && !channel.key1) { throw new Error(`${prefix}缺少 Server酱 SendKey`); } if (channel.type === 'custom' && channel.customBody) { try { JSON.parse(channel.customBody); } catch (err) { throw new Error(`${prefix}的自定义请求体不是有效 JSON`); } } } isHttpUrl(value) { try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; } catch (err) { return false; } } setupRoutes() { this.app.get('/', (req, res) => { res.redirect('/admin'); }); this.app.get('/admin', (req, res) => { res.sendFile(path.join(this.publicDir, 'admin.html')); }); // 状态查询 this.app.get('/api/status', async (req, res) => { try { const signal = await this.modem.getSignalQuality(); const operator = await this.modem.getOperator(); const mobileData = await this.modem.getMobileDataStatus(); res.json({ success: true, data: { ready: this.modem.ready, model: this.modem.modelInfo, signal, operator, mobileData, uptime: process.uptime() } }); } catch (err) { logger.error('查询状态失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 发送短信 this.app.post('/api/sms/send', async (req, res) => { try { const { phone, message } = req.body; if (!phone || !message) { return res.status(400).json({ success: false, error: '缺少必要参数: phone, message' }); } const success = await this.modem.sendSMS(phone, message); res.json({ success, message: success ? '短信发送成功' : '短信发送失败' }); } catch (err) { logger.error('发送短信失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 查询收到的短信 this.app.get('/api/sms/received', (req, res) => { try { const messages = this.smsProcessor.getReceivedMessages(req.query.limit); res.json({ success: true, data: messages }); } catch (err) { logger.error('查询收到短信失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 查询信号强度 this.app.get('/api/modem/signal', async (req, res) => { try { const signal = await this.modem.getSignalQuality(); res.json({ success: true, data: signal }); } catch (err) { logger.error('查询信号失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 查询模组信息 this.app.get('/api/modem/info', async (req, res) => { try { const iccid = await this.modem.getICCID(); res.json({ success: true, data: { model: this.modem.modelInfo, iccid, ready: this.modem.ready } }); } catch (err) { logger.error('查询模组信息失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 查询移动数据连接状态 this.app.get('/api/modem/mobile-data', async (req, res) => { try { const mobileData = await this.modem.getMobileDataStatus(); res.json({ success: true, data: mobileData }); } catch (err) { logger.error('查询移动数据状态失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 开启或关闭移动数据连接 this.app.post('/api/modem/mobile-data', async (req, res) => { try { if (typeof req.body?.enabled !== 'boolean') { return res.status(400).json({ success: false, error: '缺少必要参数: enabled' }); } const mobileData = await this.modem.setMobileDataEnabled(req.body.enabled); res.json({ success: true, message: req.body.enabled ? '移动数据已开启' : '移动数据已关闭', data: mobileData }); } catch (err) { logger.error('设置移动数据状态失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 发送AT命令 this.app.post('/api/modem/at', async (req, res) => { try { const { command } = req.body; if (!command) { return res.status(400).json({ success: false, error: '缺少必要参数: command' }); } const response = await this.modem.sendATCommand(command, 5000); res.json({ success: true, data: { command, response } }); } catch (err) { logger.error('发送AT命令失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 获取日志 this.app.get('/api/logs', (req, res) => { try { const logs = logger.getBuffer().getAll(); res.json({ success: true, data: logs }); } catch (err) { logger.error('获取日志失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 查询推送通道配置 this.app.get('/api/push/channels', async (req, res) => { try { const diskConfig = await this.readConfigFile(); const channels = this.normalizePushChannels(this.getPushChannelsFromConfig(diskConfig)); res.json({ success: true, data: channels }); } catch (err) { logger.error('读取推送通道配置失败:', err); res.status(500).json({ success: false, error: err.message }); } }); // 保存推送通道配置 this.app.put('/api/push/channels', async (req, res) => { try { const rawChannels = Array.isArray(req.body) ? req.body : req.body?.channels; const channels = this.normalizePushChannels(rawChannels); this.validatePushChannels(channels); await this.savePushChannels(channels); res.json({ success: true, message: '推送通道配置已保存', data: channels }); } catch (err) { logger.error('保存推送通道配置失败:', err); res.status(400).json({ success: false, error: err.message }); } }); // 测试推送通道 this.app.post('/api/push/test', async (req, res) => { try { if (!this.pushManager) { return res.status(500).json({ success: false, error: '推送管理器未初始化' }); } const channel = this.normalizePushChannel(req.body?.channel || req.body); this.validatePushChannel(channel, { requireDestination: true }); const success = await this.pushManager.pushToChannel( { ...channel, enabled: true }, '测试号码', '这是一条短信网关推送测试消息', new Date().toISOString() ); if (!success) { return res.status(502).json({ success: false, error: '测试推送发送失败,请查看运行日志' }); } res.json({ success: true, message: '测试推送已发送' }); } catch (err) { logger.error('测试推送失败:', err); res.status(400).json({ success: false, error: err.message }); } }); // 健康检查 this.app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); }); // 404处理 this.app.use((req, res) => { res.status(404).json({ success: false, error: 'Not Found' }); }); // 错误处理 this.app.use((err, req, res, next) => { logger.error('API错误:', err); res.status(500).json({ success: false, error: err.message }); }); } start() { const port = this.config.api.port; this.app.listen(port, () => { logger.info(`API服务器已启动,监听端口: ${port}`); }); } } export default APIServer;