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:
396
app.py
Normal file
396
app.py
Normal 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']
|
||||
)
|
||||
Reference in New Issue
Block a user