"""lzwlab.cn 文件快传 —— Flask 应用入口。 生产环境由 gunicorn 启动: gunicorn --workers 1 --threads 4 --bind 127.0.0.1:8090 app:app """ import functools import os import re import secrets import time from datetime import timedelta from flask import (Flask, jsonify, redirect, render_template, request, send_file, session) from werkzeug.exceptions import RequestEntityTooLarge import auth import storage cfg = auth.load_config() FILES_DIR = cfg["files_dir"] DB_PATH = cfg["database_path"] USERS = cfg["users"] MIN_EXPIRES_SECONDS = 3600 # 最短 1 小时 MAX_EXPIRES_SECONDS = 30 * 24 * 3600 # 最长 30 天 DEFAULT_EXPIRES_HOURS = 168 # 默认 7 天 os.makedirs(FILES_DIR, exist_ok=True) storage.init_db(DB_PATH) app = Flask(__name__) app.config.update( SECRET_KEY=cfg["secret_key"], MAX_CONTENT_LENGTH=cfg["max_content_length"], SESSION_COOKIE_NAME="lzwlab_transfer_session", SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=cfg["cookie_secure"], SESSION_COOKIE_PATH=cfg.get("cookie_path", "/transfer/"), PERMANENT_SESSION_LIFETIME=timedelta(days=cfg["session_days"]), ) login_limiter = auth.RateLimiter(max_failures=10, window_seconds=900) def client_ip() -> str: fwd = request.headers.get("X-Forwarded-For", "") if fwd: return fwd.split(",")[0].strip() return request.remote_addr or "unknown" def require_xhr(): if request.headers.get("X-Requested-With", "").lower() != "xmlhttprequest": return jsonify(ok=False, error="非法请求:缺少 X-Requested-With"), 400 def login_required(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): user = session.get("user") if not user or user not in USERS: if request.path.startswith("/api/"): return jsonify(ok=False, error="未登录或会话已过期"), 401 return redirect("/") return fn(*args, **kwargs) return wrapper @app.after_request def harden(response): response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("Referrer-Policy", "no-referrer") if request.path.startswith("/api/"): response.headers.setdefault("Cache-Control", "no-store") if request.endpoint == "index": response.headers.setdefault( "Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " "script-src 'self' 'unsafe-inline'; connect-src 'self'; " "font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'", ) return response @app.errorhandler(RequestEntityTooLarge) def too_large(_err): if request.path.startswith("/api/"): return jsonify(ok=False, error="文件大小超过允许上限"), 413 return "文件过大", 413 @app.errorhandler(404) def not_found(err): if request.path.startswith("/api/"): return jsonify(ok=False, error="资源不存在"), 404 return err def _parse_expires_hours(value) -> int: try: hours = float(value) except (TypeError, ValueError): raise ValueError("有效期格式不正确") if not (1 <= hours <= 720): raise ValueError("有效期最短 1 小时,最长 30 天(720 小时)") seconds = int(round(hours * 3600)) if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS): raise ValueError("有效期超出允许范围") return seconds def _clean_name(raw_name) -> str: name = str(raw_name or "").strip() or "未命名文件" name = name.replace("\\", "/").split("/")[-1] name = re.sub(r"[\x00-\x1f\x7f]", "", name) return (name[:200] or "未命名文件") @app.get("/") def index(): return render_template("index.html") @app.get("/healthz") def healthz(): return jsonify(ok=True) @app.get("/api/session") def api_session(): user = session.get("user") if user and user in USERS: return jsonify(logged_in=True, user=user) return jsonify(logged_in=False) @app.post("/api/login") def api_login(): ip = client_ip() if login_limiter.is_blocked(ip): return jsonify(ok=False, error="尝试次数过多,请 15 分钟后再试"), 429 data = request.get_json(silent=True) or {} username = str(data.get("username", "")).strip() password = str(data.get("password", "")) user = USERS.get(username) ok = bool(user) and auth.verify_password(password, user.get("password_hash", "")) if not ok: login_limiter.record_failure(ip) time.sleep(0.3) return jsonify(ok=False, error="用户名或密码错误"), 401 login_limiter.reset(ip) session.clear() session.permanent = True session["user"] = username return jsonify(ok=True, user=username) @app.post("/api/logout") def api_logout(): session.clear() return jsonify(ok=True) @app.post("/api/upload") @login_required def api_upload(): bad = require_xhr() if bad: return bad if "file" not in request.files: return jsonify(ok=False, error="未选择文件"), 400 upload = request.files["file"] if upload is None or upload.filename is None: return jsonify(ok=False, error="未选择文件"), 400 try: expires_seconds = _parse_expires_hours(request.form.get("expires_hours", DEFAULT_EXPIRES_HOURS)) except ValueError as exc: return jsonify(ok=False, error=str(exc)), 400 file_id = secrets.token_hex(8) stored_path = os.path.join(FILES_DIR, file_id) name = _clean_name(upload.filename) try: upload.save(stored_path) except RequestEntityTooLarge: raise except Exception: app.logger.exception("upload save failed: %s", file_id) try: os.remove(stored_path) except OSError: pass return jsonify(ok=False, error="上传失败,请重试"), 500 size = os.path.getsize(stored_path) now = int(time.time()) storage.insert_file(DB_PATH, file_id, name, stored_path, size, now + expires_seconds, now) return jsonify(ok=True, file={ "id": file_id, "name": name, "size": size, "created_at": storage.iso(now), "expires_at": storage.iso(now + expires_seconds), "remaining_seconds": expires_seconds, "downloads": 0, }) @app.get("/api/files") @login_required def api_files(): now = int(time.time()) storage.cleanup_expired(DB_PATH, FILES_DIR, now=now) return jsonify(files=storage.list_files(DB_PATH, now)) @app.get("/api/download/") @login_required def api_download(file_id): now = int(time.time()) row = storage.get_file(DB_PATH, file_id) if row is None: return jsonify(ok=False, error="文件不存在或已过期"), 404 if row["expires_at"] <= now: storage.cleanup_expired(DB_PATH, FILES_DIR, now=now) return jsonify(ok=False, error="文件已过期"), 404 if not os.path.isfile(row["stored_path"]): storage.delete_file(DB_PATH, FILES_DIR, file_id) return jsonify(ok=False, error="缓存文件已丢失"), 404 storage.increment_downloads(DB_PATH, file_id) return send_file( row["stored_path"], as_attachment=True, download_name=row["name"], conditional=True, etag=True, max_age=0, ) @app.delete("/api/files/") @login_required def api_delete_file(file_id): bad = require_xhr() if bad: return bad ok = storage.delete_file(DB_PATH, FILES_DIR, file_id) if not ok: return jsonify(ok=False, error="文件不存在"), 404 return jsonify(ok=True) @app.post("/api/cleanup") @login_required def api_cleanup(): bad = require_xhr() if bad: return bad now = int(time.time()) removed = storage.cleanup_expired(DB_PATH, FILES_DIR, now=now) orphans = storage.cleanup_orphans(DB_PATH, FILES_DIR, now=now) return jsonify(ok=True, removed=removed, orphans=orphans) if __name__ == "__main__": app.run(host="127.0.0.1", port=8090, debug=False)