- 后端:新增 /api/clips(列表/新建)、/api/clips/<id>(取全文/删除)、/api/clips/<id>/copy(复制计数),列表仅返回 240 字符预览 - 存储:SQLite 新增 clips 表(含 expires_at 索引),启动自动建表,线上老库无需手工迁移 - 前端:剪贴板卡片支持读取本机剪贴板、来源备注、查看全文、一键复制到本机剪贴板、单条删除与复制次数 - 清理:systemd 定时任务与 /api/cleanup 同时清理过期剪贴板 - 配置:新增 max_clip_length / max_clips / clip_default_expires_hours - 部署:deploy_transfer.py 新增 --keep-credentials(不重置登录密码与会话密钥)与 SSH 连接重试 - 文档:README / setup.md / CHANGELOG 同步更新
368 lines
12 KiB
Python
368 lines
12 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 天
|
|
MAX_CLIP_LENGTH = cfg["max_clip_length"] # 单条剪贴板文本上限(字符)
|
|
MAX_CLIPS = cfg["max_clips"] # 剪贴板保留条数上限
|
|
DEFAULT_CLIP_EXPIRES_HOURS = cfg["clip_default_expires_hours"] # 剪贴板默认有效期
|
|
MAX_CLIP_PREVIEW_CHARS = 240 # 列表接口返回的正文预览长度
|
|
|
|
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, default_hours: int = DEFAULT_EXPIRES_HOURS,
|
|
label: str = "有效期") -> int:
|
|
if value is None or (isinstance(value, str) and not value.strip()):
|
|
value = default_hours
|
|
try:
|
|
hours = float(value)
|
|
except (TypeError, ValueError):
|
|
raise ValueError("%s格式不正确" % label)
|
|
if not (1 <= hours <= 720):
|
|
raise ValueError("%s最短 1 小时,最长 30 天(720 小时)" % label)
|
|
seconds = int(round(hours * 3600))
|
|
if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS):
|
|
raise ValueError("%s超出允许范围" % label)
|
|
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 "未命名文件")
|
|
|
|
|
|
def _clean_clip_text(raw_text) -> str:
|
|
"""规范化剪贴板正文:统一换行、去掉 NUL,保留换行与制表符。"""
|
|
text = raw_text.replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "")
|
|
return text
|
|
|
|
|
|
def _clean_client(raw_client) -> str:
|
|
name = re.sub(r"[\x00-\x1f\x7f]", "", str(raw_client or "").strip())
|
|
return name[:40]
|
|
|
|
|
|
@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)
|
|
clips = storage.cleanup_expired_clips(DB_PATH, now=now)
|
|
return jsonify(ok=True, removed=removed, orphans=orphans, clips=clips)
|
|
|
|
|
|
# ---------- 剪贴板 ----------
|
|
# 登录用户共享同一块剪贴板:任一客户端粘贴文本上传,其他客户端可查看并一键复制。
|
|
|
|
@app.get("/api/clips")
|
|
@login_required
|
|
def api_clips():
|
|
now = int(time.time())
|
|
storage.cleanup_expired_clips(DB_PATH, now=now)
|
|
return jsonify(clips=storage.list_clips(DB_PATH, now))
|
|
|
|
|
|
@app.post("/api/clips")
|
|
@login_required
|
|
def api_create_clip():
|
|
bad = require_xhr()
|
|
if bad:
|
|
return bad
|
|
data = request.get_json(silent=True)
|
|
if not isinstance(data, dict):
|
|
return jsonify(ok=False, error="请求格式不正确"), 400
|
|
raw_text = data.get("text")
|
|
if not isinstance(raw_text, str):
|
|
return jsonify(ok=False, error="缺少剪贴板文本"), 400
|
|
text = _clean_clip_text(raw_text)
|
|
if not text.strip():
|
|
return jsonify(ok=False, error="剪贴板内容不能为空"), 400
|
|
if len(text) > MAX_CLIP_LENGTH:
|
|
return jsonify(ok=False, error="文本过长:最多 %d 个字符" % MAX_CLIP_LENGTH), 400
|
|
try:
|
|
expires_seconds = _parse_expires_hours(
|
|
data.get("expires_hours"), DEFAULT_CLIP_EXPIRES_HOURS, "剪贴板有效期")
|
|
except ValueError as exc:
|
|
return jsonify(ok=False, error=str(exc)), 400
|
|
|
|
now = int(time.time())
|
|
clip_id = secrets.token_hex(8)
|
|
storage.insert_clip(DB_PATH, clip_id, text, _clean_client(data.get("client")),
|
|
now + expires_seconds, now)
|
|
storage.prune_clips(DB_PATH, MAX_CLIPS)
|
|
row = storage.get_clip(DB_PATH, clip_id)
|
|
if row is None: # 理论上不会发生,被 prune 挤掉时也返回成功但不回传内容
|
|
return jsonify(ok=True, clip=None)
|
|
return jsonify(ok=True, clip=storage.clip_content_to_dict(row, now))
|
|
|
|
|
|
@app.get("/api/clips/<clip_id>")
|
|
@login_required
|
|
def api_clip_detail(clip_id):
|
|
now = int(time.time())
|
|
row = storage.get_clip(DB_PATH, clip_id)
|
|
if row is None:
|
|
return jsonify(ok=False, error="剪贴板内容不存在或已删除"), 404
|
|
if row["expires_at"] <= now:
|
|
storage.cleanup_expired_clips(DB_PATH, now=now)
|
|
return jsonify(ok=False, error="剪贴板内容已过期"), 404
|
|
return jsonify(ok=True, clip=storage.clip_content_to_dict(row, now))
|
|
|
|
|
|
@app.post("/api/clips/<clip_id>/copy")
|
|
@login_required
|
|
def api_clip_copied(clip_id):
|
|
bad = require_xhr()
|
|
if bad:
|
|
return bad
|
|
if not storage.increment_clip_copies(DB_PATH, clip_id):
|
|
return jsonify(ok=False, error="剪贴板内容不存在"), 404
|
|
return jsonify(ok=True)
|
|
|
|
|
|
@app.delete("/api/clips/<clip_id>")
|
|
@login_required
|
|
def api_delete_clip(clip_id):
|
|
bad = require_xhr()
|
|
if bad:
|
|
return bad
|
|
if not storage.delete_clip(DB_PATH, clip_id):
|
|
return jsonify(ok=False, error="剪贴板内容不存在"), 404
|
|
return jsonify(ok=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="127.0.0.1", port=8090, debug=False)
|