Initial commit: SMS Receiver Web Service

Features:
- Receive SMS from TranspondSms Android APP
- HMAC-SHA256 signature verification (optional)
- SQLite database storage
- Web UI with login authentication
- Multiple API tokens support
- Timezone conversion (Asia/Shanghai)
- Search, filter, and statistics
- Auto refresh and session management

Tech Stack:
- Flask 3.0
- SQLite database
- HTML5/CSS3 responsive design
This commit is contained in:
OpenClaw Agent
2026-02-06 23:23:49 +00:00
commit 4e5e93660d
16 changed files with 3754 additions and 0 deletions

396
app.py Normal file
View File

@@ -0,0 +1,396 @@
"""
短信转发接收端主应用
"""
import os
import logging
import logging.handlers
from datetime import datetime, timedelta, timezone
from functools import wraps
import hashlib
from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, make_response
from functools import update_wrapper
from config import config, Config
from database import Database
from sign_verify import verify_from_app
def no_cache(f):
"""禁用缓存的装饰器"""
def new_func(*args, **kwargs):
resp = make_response(f(*args, **kwargs))
resp.cache_control.no_cache = True
resp.cache_control.no_store = True
resp.cache_control.max_age = 0
return resp
return update_wrapper(new_func, f)
# 初始化应用
def create_app(config_name='default'):
# 从配置文件加载
app_config = Config.load_from_json('config.json')
app = Flask(__name__)
app.secret_key = app_config.SECRET_KEY
# 应用配置
app.config['HOST'] = app_config.HOST
app.config['PORT'] = app_config.PORT
app.config['DEBUG'] = app_config.DEBUG
app.config['SECRET_KEY'] = app_config.SECRET_KEY
app.config['SIGN_VERIFY'] = app_config.SIGN_VERIFY
app.config['SIGN_MAX_AGE'] = app_config.SIGN_MAX_AGE
app.config['DATABASE_PATH'] = app_config.DATABASE_PATH
app.config['MAX_MESSAGES'] = app_config.MAX_MESSAGES
app.config['AUTO_CLEANUP'] = app_config.AUTO_CLEANUP
app.config['CLEANUP_DAYS'] = app_config.CLEANUP_DAYS
app.config['PER_PAGE'] = app_config.PER_PAGE
app.config['REFRESH_INTERVAL'] = app_config.REFRESH_INTERVAL
app.config['LOG_LEVEL'] = app_config.LOG_LEVEL
app.config['LOG_FILE'] = app_config.LOG_FILE
app.config['TIMEZONE'] = app_config.TIMEZONE
app.config['API_TOKENS'] = app_config.API_TOKENS
app.config['LOGIN_ENABLED'] = app_config.LOGIN_ENABLED
app.config['LOGIN_USERNAME'] = app_config.LOGIN_USERNAME
app.config['LOGIN_PASSWORD'] = app_config.LOGIN_PASSWORD
app.config['SESSION_LIFETIME'] = app_config.SESSION_LIFETIME
# 初始化日志
setup_logging(app)
# 解析时区
try:
timezone_name = app_config.TIMEZONE
import pytz
app.timezone = pytz.timezone(timezone_name)
app.timezone_offset = app.timezone.utcoffset(datetime.now()).total_seconds() / 3600
except ImportError:
# 如果没有 pytz使用简单的时区偏移
app.timezone_offset = 8 # 默认 UTC+8
app.timezone = None
app.logger.warning('pytz not installed, using simple timezone offset')
# 初始化数据库
db = Database(app.config['DATABASE_PATH'], timezone_offset=app.timezone_offset)
# 注册路由
register_routes(app, db)
return app
def setup_logging(app):
"""配置日志"""
log_level = getattr(logging, app.config['LOG_LEVEL'])
formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
)
# 文件日志
log_file_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
app.config['LOG_FILE']
)
file_handler = logging.handlers.RotatingFileHandler(
log_file_path,
maxBytes=10*1024*1024, # 10MB
backupCount=5
)
file_handler.setFormatter(formatter)
file_handler.setLevel(log_level)
# 控制台日志
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setLevel(log_level)
app.logger.addHandler(file_handler)
app.logger.addHandler(console_handler)
app.logger.setLevel(log_level)
def login_required(f):
"""登录验证装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
if app.config.get('LOGIN_ENABLED', True):
if 'logged_in' not in session or not session['logged_in']:
return redirect(url_for('login', next=request.url))
# 检查会话是否过期
last_activity = session.get('last_activity')
if last_activity:
session_lifetime = app.config.get('SESSION_LIFETIME', 3600)
if datetime.now().timestamp() - last_activity > session_lifetime:
session.clear()
flash('会话已过期,请重新登录', 'info')
return redirect(url_for('login', next=request.url))
# 更新最后活动时间
session['last_activity'] = datetime.now().timestamp()
return f(*args, **kwargs)
return decorated_function
def register_routes(app, db):
"""注册路由"""
@app.route('/login', methods=['GET', 'POST'])
@no_cache
def login():
"""登录页面"""
# 如果禁用了登录,直接跳转到首页
if not app.config.get('LOGIN_ENABLED', True):
return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username == app.config['LOGIN_USERNAME'] and \
password == app.config['LOGIN_PASSWORD']:
session['logged_in'] = True
session['username'] = username
session['login_time'] = datetime.now().timestamp()
session['last_activity'] = datetime.now().timestamp()
next_url = request.args.get('next')
if next_url:
return redirect(next_url)
return redirect(url_for('index'))
else:
flash('用户名或密码错误', 'error')
return render_template('login.html')
@app.route('/logout')
@no_cache
def logout():
"""登出"""
session.clear()
flash('已退出登录', 'info')
return redirect(url_for('login'))
@app.route('/')
@login_required
def index():
"""首页 - 短信列表"""
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', app.config['PER_PAGE'], type=int)
from_number = request.args.get('from', None)
search = request.args.get('search', None)
messages = db.get_messages(page, limit, from_number, search)
total = db.get_message_count(from_number, search)
total_pages = (total + limit - 1) // limit
stats = db.get_statistics()
from_numbers = db.get_from_numbers()[:20]
return render_template('index.html',
messages=messages,
page=page,
total_pages=total_pages,
total=total,
from_number=from_number,
search=search,
limit=limit,
stats=stats,
from_numbers=from_numbers,
refresh_interval=app.config['REFRESH_INTERVAL'])
@app.route('/message/<int:message_id>')
@login_required
def message_detail(message_id):
"""短信详情"""
message = db.get_message_by_id(message_id)
if not message:
flash('短信不存在', 'error')
return redirect(url_for('index'))
return render_template('message_detail.html', message=message)
@app.route('/logs')
@login_required
def logs():
"""查看接收日志"""
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', app.config['PER_PAGE'], type=int)
logs = db.get_logs(page, limit)
return render_template('logs.html', logs=logs, page=page, limit=limit)
@app.route('/statistics')
@login_required
def statistics():
"""统计信息"""
stats = db.get_statistics()
recent = db.get_recent_messages(10)
from_numbers = db.get_from_numbers()
return render_template('statistics.html',
stats=stats,
recent=recent,
from_numbers=from_numbers,
cleanup_days=app.config['CLEANUP_DAYS'],
max_messages=app.config['MAX_MESSAGES'])
@app.route('/settings')
@login_required
def settings():
"""设置页面"""
return render_template('settings.html',
api_tokens=app.config['API_TOKENS'],
login_enabled=app.config['LOGIN_ENABLED'],
sign_verify=app.config['SIGN_VERIFY'])
@app.route('/api/receive', methods=['POST'])
def receive_sms():
"""接收短信接口"""
try:
# 获取参数
from_number = request.form.get('from')
content = request.form.get('content')
timestamp_str = request.form.get('timestamp')
sign = request.form.get('sign', '')
token_param = request.form.get('token', '')
ip_address = request.remote_addr
# 验证必填字段
if not from_number or not content:
db.add_log(from_number, content, None, sign, None, ip_address,
'error', '缺少必填参数 (from/content)')
return jsonify({'error': '缺少必填参数'}), 400
# 如果提供了 token查找对应的 secret
secret = None
if token_param:
for token_config in app.config['API_TOKENS']:
if token_config.get('enabled') and token_config.get('token') == token_param:
secret = token_config.get('secret')
break
# 解析时间戳
timestamp = None
if timestamp_str:
try:
timestamp = int(timestamp_str)
except ValueError:
db.add_log(from_number, content, None, sign, None, ip_address,
'error', f'时间戳格式错误: {timestamp_str}')
return jsonify({'error': '时间戳格式错误'}), 400
# 验证签名
sign_verified = False
if sign and secret and app.config['SIGN_VERIFY']:
is_valid, message = verify_from_app(
from_number, content, timestamp, sign, secret,
app.config['SIGN_MAX_AGE']
)
if not is_valid:
db.add_log(from_number, content, timestamp, sign, False, ip_address,
'error', f'签名验证失败: {message}')
app.logger.warning(f'签名验证失败: {message}')
return jsonify({'error': message}), 403
else:
sign_verified = True
app.logger.info(f'短信已签名验证: {from_number}')
# 保存短信
message_id = db.add_message(
from_number=from_number,
content=content,
timestamp=timestamp or int(datetime.now().timestamp() * 1000),
device_info=request.form.get('device'),
sim_info=request.form.get('sim'),
sign_verified=sign_verified,
ip_address=ip_address
)
# 记录成功日志
db.add_log(from_number, content, timestamp, sign, sign_verified, ip_address,
'success')
app.logger.info(f'收到短信: {from_number} -> {content[:50]}... (ID: {message_id})')
return jsonify({
'success': True,
'message_id': message_id,
'message': '短信已接收'
}), 200
except Exception as e:
app.logger.error(f'处理短信失败: {e}', exc_info=True)
return jsonify({'error': '服务器内部错误'}), 500
@app.route('/api/messages', methods=['GET'])
@login_required
def api_messages():
"""短信列表 API"""
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
from_number = request.args.get('from', None)
search = request.args.get('search', None)
messages = db.get_messages(page, limit, from_number, search)
total = db.get_message_count(from_number, search)
return jsonify({
'success': True,
'data': messages,
'total': total,
'page': page,
'limit': limit
})
@app.route('/api/statistics', methods=['GET'])
@login_required
def api_statistics():
"""统计信息 API"""
stats = db.get_statistics()
return jsonify({
'success': True,
'data': stats
})
@app.route('/cleanup')
@login_required
def cleanup():
"""清理老数据"""
deleted = db.cleanup_old_messages(
days=app.config['CLEANUP_DAYS'],
max_messages=app.config['MAX_MESSAGES']
)
flash(f'已清理 {deleted} 条旧数据', 'success')
return redirect(url_for('statistics'))
@app.errorhandler(404)
def not_found(e):
return render_template('error.html', error='页面不存在'), 404
@app.errorhandler(500)
def server_error(e):
app.logger.error(f'服务器错误: {e}', exc_info=True)
return render_template('error.html', error='服务器内部错误'), 500
if __name__ == '__main__':
env = os.environ.get('FLASK_ENV', 'development')
app = create_app(env)
app.logger.info(f'启动短信接收服务 (环境: {env})')
app.logger.info(f'数据库: {app.config["DATABASE_PATH"]}')
app.logger.info(f'监听端口: {app.config["PORT"]}')
app.logger.info(f'登录已启用: {app.config["LOGIN_ENABLED"]}')
app.run(
host=app.config['HOST'],
port=app.config['PORT'],
debug=app.config['DEBUG']
)