327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""
|
||
文件上传网站 - 后端服务
|
||
基于 Flask,提供文件上传、图形验证码、Rate Limiting 等功能
|
||
"""
|
||
import os
|
||
import re
|
||
import time
|
||
import random
|
||
import string
|
||
from io import BytesIO
|
||
from datetime import datetime
|
||
|
||
from flask import Flask, request, jsonify, render_template, session, send_file
|
||
from flask_limiter import Limiter
|
||
from flask_limiter.util import get_remote_address
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
import magic
|
||
|
||
from config import Config
|
||
|
||
# ── 初始化 Flask 应用 ──────────────────────────────────────────────
|
||
app = Flask(__name__)
|
||
app.config.from_object(Config)
|
||
app.secret_key = Config.SECRET_KEY
|
||
|
||
# ── Rate Limiter ───────────────────────────────────────────────────
|
||
limiter = Limiter(
|
||
app=app,
|
||
key_func=get_remote_address,
|
||
default_limits=[],
|
||
storage_uri="memory://"
|
||
)
|
||
|
||
# ── 常量 ───────────────────────────────────────────────────────────
|
||
# 姓名校验:中文、英文字母、数字、下划线,2-20 字符
|
||
NAME_REGEX = re.compile(r'^[\u4e00-\u9fa5a-zA-Z0-9_]{2,20}$')
|
||
|
||
# 文件名中不安全的字符
|
||
UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||
|
||
# 验证码图片参数
|
||
CAPTCHA_IMAGE_SIZE = (320, 100)
|
||
CAPTCHA_FONT_SIZE = 68
|
||
CAPTCHA_FONT_PATHS = (
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||
"/usr/local/share/fonts/DejaVuSans-Bold.ttf",
|
||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||
"/System/Library/Fonts/Supplemental/Verdana Bold.ttf",
|
||
)
|
||
|
||
|
||
# ── 工具函数 ────────────────────────────────────────────────────────
|
||
def sanitize_name(name: str) -> str | None:
|
||
"""校验并清洗姓名,防止路径穿越攻击"""
|
||
name = name.strip()
|
||
# 移除路径穿越字符
|
||
name = name.replace('..', '').replace('/', '').replace('\\', '')
|
||
# 校验格式
|
||
if not NAME_REGEX.match(name):
|
||
return None
|
||
return name
|
||
|
||
|
||
def sanitize_filename(filename: str) -> str:
|
||
"""清洗文件名,移除不安全字符"""
|
||
# 只保留文件名部分(去掉路径)
|
||
filename = os.path.basename(filename)
|
||
# 移除不安全字符
|
||
filename = UNSAFE_FILENAME_CHARS.sub('', filename)
|
||
# 去掉首尾空格和点
|
||
filename = filename.strip(' .')
|
||
# 限制长度(保留扩展名)
|
||
if len(filename) > 200:
|
||
name_part, ext = os.path.splitext(filename)
|
||
filename = name_part[:200 - len(ext)] + ext
|
||
# 如果文件名为空,使用默认名
|
||
return filename if filename else 'unnamed.txt'
|
||
|
||
|
||
def verify_file_type(file_data: bytes, filename: str) -> tuple[bool, str | None]:
|
||
"""通过 Magic Bytes 校验文件真实类型"""
|
||
try:
|
||
mime = magic.from_buffer(file_data, mime=True)
|
||
except Exception:
|
||
return False, '无法识别文件类型'
|
||
|
||
if mime not in Config.ALLOWED_MIME_TYPES:
|
||
return False, f'不支持的文件类型 ({mime}),请上传文字类文档'
|
||
|
||
return True, None
|
||
|
||
|
||
def generate_captcha_text(length: int = 4) -> str:
|
||
"""生成随机验证码文本(去掉易混淆字符)"""
|
||
chars = string.ascii_uppercase + string.digits
|
||
for c in 'O0I1L':
|
||
chars = chars.replace(c, '')
|
||
return ''.join(random.choices(chars, k=length))
|
||
|
||
|
||
def load_captcha_font(size: int = CAPTCHA_FONT_SIZE) -> ImageFont.ImageFont:
|
||
"""加载适合验证码的大号字体,兼容 Linux/Docker 和 macOS 本地环境"""
|
||
for font_path in CAPTCHA_FONT_PATHS:
|
||
if not os.path.exists(font_path):
|
||
continue
|
||
try:
|
||
return ImageFont.truetype(font_path, size)
|
||
except OSError:
|
||
continue
|
||
|
||
try:
|
||
return ImageFont.truetype("DejaVuSans-Bold.ttf", size)
|
||
except OSError:
|
||
return ImageFont.load_default(size=size)
|
||
|
||
|
||
def generate_captcha_image(text: str) -> Image.Image:
|
||
"""生成验证码图片(含干扰线和噪点)"""
|
||
width, height = CAPTCHA_IMAGE_SIZE
|
||
# 浅色随机背景
|
||
bg_color = (random.randint(235, 255), random.randint(235, 255), random.randint(235, 255))
|
||
image = Image.new('RGB', (width, height), bg_color)
|
||
draw = ImageDraw.Draw(image)
|
||
|
||
# 干扰线
|
||
for _ in range(6):
|
||
x1, y1 = random.randint(0, width), random.randint(0, height)
|
||
x2, y2 = random.randint(0, width), random.randint(0, height)
|
||
line_color = (random.randint(160, 210), random.randint(160, 210), random.randint(160, 210))
|
||
draw.line([(x1, y1), (x2, y2)], fill=line_color, width=1)
|
||
|
||
# 噪点
|
||
for _ in range(120):
|
||
x, y = random.randint(0, width - 1), random.randint(0, height - 1)
|
||
dot_color = (random.randint(140, 200), random.randint(140, 200), random.randint(140, 200))
|
||
draw.point((x, y), fill=dot_color)
|
||
|
||
font = load_captcha_font()
|
||
char_slot_width = width // len(text)
|
||
|
||
for i, char in enumerate(text):
|
||
bbox = draw.textbbox((0, 0), char, font=font)
|
||
char_width = bbox[2] - bbox[0]
|
||
char_height = bbox[3] - bbox[1]
|
||
x = i * char_slot_width + (char_slot_width - char_width) // 2 - bbox[0] + random.randint(-4, 4)
|
||
y = (height - char_height) // 2 - bbox[1] + random.randint(-5, 5)
|
||
color = (random.randint(0, 80), random.randint(0, 80), random.randint(0, 80))
|
||
draw.text((x, y), char, font=font, fill=color)
|
||
|
||
return image
|
||
|
||
|
||
def update_upload_log(user_dir: str, name: str, results: list, timestamp: str):
|
||
"""更新用户文件夹中的 Markdown 上传日志"""
|
||
log_file = os.path.join(user_dir, '00_上传日志.md')
|
||
|
||
# 统计累计文件数(排除日志文件)
|
||
total_files = len([f for f in os.listdir(user_dir) if not f.startswith('00_')])
|
||
|
||
# 构建日志条目
|
||
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
lines = [
|
||
f"\n### {now_str}",
|
||
f"- 上传者:{name}",
|
||
f"- 本次上传文件数:{len(results)}",
|
||
"| 原文件名 | 存储文件名 |",
|
||
"|---------|----------|",
|
||
]
|
||
for r in results:
|
||
lines.append(f"| {r['filename']} | {r['storage_filename']} |")
|
||
lines.append(f"- 累计总文件数:{total_files}")
|
||
entry = '\n'.join(lines) + '\n'
|
||
|
||
# 写入或追加
|
||
if os.path.exists(log_file):
|
||
with open(log_file, 'a', encoding='utf-8') as f:
|
||
f.write(entry)
|
||
else:
|
||
with open(log_file, 'w', encoding='utf-8') as f:
|
||
f.write("## 上传记录\n")
|
||
f.write(entry)
|
||
|
||
|
||
# ── 路由 ────────────────────────────────────────────────────────────
|
||
@app.route('/')
|
||
def index():
|
||
"""渲染上传页面"""
|
||
return render_template('index.html')
|
||
|
||
|
||
@app.route('/api/captcha')
|
||
def get_captcha():
|
||
"""生成并返回新的验证码图片"""
|
||
text = generate_captcha_text()
|
||
session['captcha'] = text.lower()
|
||
session['captcha_time'] = time.time()
|
||
|
||
image = generate_captcha_image(text)
|
||
buf = BytesIO()
|
||
image.save(buf, format='PNG')
|
||
buf.seek(0)
|
||
|
||
response = send_file(buf, mimetype='image/png')
|
||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||
return response
|
||
|
||
|
||
@app.route('/api/upload', methods=['POST'])
|
||
@limiter.limit(f"{Config.RATE_LIMIT_PER_HOUR}/hour")
|
||
def upload_file():
|
||
"""处理文件上传"""
|
||
|
||
# ── 1. 校验验证码 ──────────────────────────────────────────
|
||
captcha_input = request.form.get('captcha', '').strip().lower()
|
||
captcha_expected = session.get('captcha')
|
||
captcha_time = session.get('captcha_time', 0)
|
||
|
||
if not captcha_expected:
|
||
return jsonify({'error': '请先获取验证码'}), 400
|
||
if time.time() - captcha_time > Config.CAPTCHA_EXPIRY:
|
||
session.pop('captcha', None)
|
||
session.pop('captcha_time', None)
|
||
return jsonify({'error': '验证码已过期,请刷新'}), 400
|
||
if captcha_input != captcha_expected:
|
||
return jsonify({'error': '验证码错误'}), 400
|
||
|
||
# 验证码使用后立即清除(一次性)
|
||
session.pop('captcha', None)
|
||
session.pop('captcha_time', None)
|
||
|
||
# ── 2. 校验姓名 ────────────────────────────────────────────
|
||
raw_name = request.form.get('name', '')
|
||
name = sanitize_name(raw_name)
|
||
if not name:
|
||
return jsonify({'error': '姓名格式不正确(2-20个字符,仅支持中文、英文字母、数字和下划线)'}), 400
|
||
|
||
# ── 3. 校验文件列表 ────────────────────────────────────────
|
||
files = request.files.getlist('files')
|
||
if not files or all(f.filename == '' for f in files):
|
||
return jsonify({'error': '请选择要上传的文件'}), 400
|
||
valid_files = [f for f in files if f.filename]
|
||
if len(valid_files) > Config.MAX_FILES_PER_UPLOAD:
|
||
return jsonify({'error': f'单次最多上传 {Config.MAX_FILES_PER_UPLOAD} 个文件'}), 400
|
||
|
||
# ── 4. 创建用户目录 ────────────────────────────────────────
|
||
user_dir = os.path.join(Config.UPLOAD_ROOT, name)
|
||
os.makedirs(user_dir, exist_ok=True)
|
||
|
||
# ── 5. 逐个处理文件 ────────────────────────────────────────
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
results = []
|
||
|
||
for file in valid_files:
|
||
original_filename = file.filename
|
||
file_data = file.read()
|
||
|
||
# 5a. 文件大小校验
|
||
if len(file_data) > Config.MAX_FILE_SIZE:
|
||
results.append({
|
||
'filename': original_filename,
|
||
'success': False,
|
||
'error': f'文件超过 {Config.MAX_FILE_SIZE // (1024 * 1024)}MB 限制'
|
||
})
|
||
continue
|
||
|
||
# 5b. 文件类型校验(Magic Bytes)
|
||
valid, error_msg = verify_file_type(file_data, original_filename)
|
||
if not valid:
|
||
results.append({
|
||
'filename': original_filename,
|
||
'success': False,
|
||
'error': error_msg
|
||
})
|
||
continue
|
||
|
||
# 5c. 清洗文件名 + 生成存储文件名
|
||
safe_name = sanitize_filename(original_filename)
|
||
storage_filename = f"{timestamp}_{safe_name}"
|
||
filepath = os.path.join(user_dir, storage_filename)
|
||
|
||
# 处理同名文件(同一秒内上传的)
|
||
counter = 1
|
||
while os.path.exists(filepath):
|
||
name_part, ext = os.path.splitext(safe_name)
|
||
storage_filename = f"{timestamp}_{name_part}_{counter}{ext}"
|
||
filepath = os.path.join(user_dir, storage_filename)
|
||
counter += 1
|
||
|
||
# 5d. 写入文件
|
||
with open(filepath, 'wb') as f:
|
||
f.write(file_data)
|
||
|
||
results.append({
|
||
'filename': original_filename,
|
||
'storage_filename': storage_filename,
|
||
'success': True
|
||
})
|
||
|
||
# ── 6. 更新上传日志 ────────────────────────────────────────
|
||
success_results = [r for r in results if r['success']]
|
||
if success_results:
|
||
update_upload_log(user_dir, name, success_results, timestamp)
|
||
|
||
# ── 7. 返回结果 ────────────────────────────────────────────
|
||
total_files = len([f for f in os.listdir(user_dir) if not f.startswith('00_')])
|
||
|
||
return jsonify({
|
||
'success': len(success_results) > 0,
|
||
'results': results,
|
||
'success_count': len(success_results),
|
||
'fail_count': len(results) - len(success_results),
|
||
'total_files': total_files,
|
||
'storage_path': f"文字稿/{name}/"
|
||
})
|
||
|
||
|
||
# ── 自定义 Rate Limit 错误处理 ──────────────────────────────────────
|
||
@app.errorhandler(429)
|
||
def ratelimit_handler(e):
|
||
return jsonify({'error': '上传过于频繁,请稍后再试(每小时最多 20 次上传)'}), 429
|
||
|
||
|
||
# ── 启动 ────────────────────────────────────────────────────────────
|
||
if __name__ == '__main__':
|
||
os.makedirs(Config.UPLOAD_ROOT, exist_ok=True)
|
||
app.run(host='0.0.0.0', port=Config.PORT, debug=Config.DEBUG)
|