Files

269 lines
8.0 KiB
Python

"""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/<file_id>")
@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/<file_id>")
@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)