Files
sms_forwarding/src/api.js
T
2026-06-26 16:48:40 +08:00

676 lines
19 KiB
JavaScript

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(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SMS Gateway Token</title>
<style>
:root { color-scheme: light; --canvas:#faf9f5; --ink:#141413; --body:#3d3d3a; --muted:#6c6a64; --card:#efe9de; --primary:#cc785c; --primary-active:#a9583e; --hairline:#e6dfd8; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--canvas); color: var(--body); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.55; }
main { width: min(480px, calc(100% - 32px)); border-radius: 12px; background: var(--card); padding: 32px; }
.mark { color: var(--ink); font-size: 20px; }
h1 { margin: 18px 0 10px; color: var(--ink); font-family: "Cormorant Garamond", "EB Garamond", Georgia, serif; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: 0; }
p { margin: 0 0 24px; color: var(--muted); }
label { display: grid; gap: 8px; color: var(--ink); font-size: 14px; font-weight: 500; }
input { width: 100%; min-height: 40px; border: 1px solid var(--hairline); border-radius: 8px; background: var(--canvas); color: var(--ink); padding: 10px 14px; font: inherit; }
input:focus { border-color: var(--primary); outline: 3px solid rgba(204, 120, 92, .15); }
button { margin-top: 18px; width: 100%; min-height: 40px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; padding: 12px 20px; font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
button:active { background: var(--primary-active); }
</style>
</head>
<body>
<main>
<span class="mark">✣</span>
<h1>输入访问 token</h1>
<p>管理台需要有效 token 才能继续访问。</p>
<form method="get" action="/admin">
<label>
Token
<input name="token" type="password" autocomplete="current-password" autofocus required>
</label>
<button type="submit">进入管理台</button>
</form>
</main>
</body>
</html>`);
}
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;