Files
waiwaylee 5af69d694f feat: 文件快传新增共享剪贴板(跨客户端粘贴、查看与一键复制)
- 后端:新增 /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 同步更新
2026-10-11 22:20:54 +08:00

106 lines
3.8 KiB
Python

"""认证、配置加载与登录限流工具(仅依赖标准库)。"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import threading
import time
DEFAULT_CONFIG_PATH = os.environ.get("LZWLAB_TRANSFER_CONFIG", "/etc/lzwlab-transfer/config.json")
_SCRYPT_N = 2 ** 14
_SCRYPT_R = 8
_SCRYPT_P = 1
def _b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def hash_password(password: str) -> str:
"""返回 scrypt 密码哈希,格式:scrypt$N$r$p$salt_b64$hash_b64"""
if not isinstance(password, str) or not password:
raise ValueError("密码不能为空")
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
)
return "scrypt$%d$%d$%d$%s$%s" % (
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _b64encode(salt), _b64encode(digest)
)
def verify_password(password: str, stored: str) -> bool:
try:
parts = stored.split("$")
if len(parts) != 6 or parts[0] != "scrypt":
return False
_, n_s, r_s, p_s, salt_s, digest_s = parts
n, r, p = int(n_s), int(r_s), int(p_s)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=_b64decode(salt_s), n=n, r=r, p=p, dklen=32
)
return hmac.compare_digest(digest, _b64decode(digest_s))
except Exception:
return False
def load_config(path: str | None = None) -> dict:
path = path or DEFAULT_CONFIG_PATH
if not os.path.exists(path):
raise RuntimeError("配置文件不存在:%s" % path)
with open(path, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
if not isinstance(cfg.get("users"), dict) or not cfg["users"]:
raise RuntimeError("配置缺少 users")
if not cfg.get("secret_key") or len(str(cfg["secret_key"])) < 32:
raise RuntimeError("配置缺少 secret_key(至少 32 字符)")
data_dir = cfg.get("data_dir", "/var/lib/lzwlab-transfer")
cfg["files_dir"] = os.path.join(data_dir, "files")
cfg["database_path"] = cfg.get("database_path", os.path.join(data_dir, "files.db"))
cfg["max_content_length"] = int(cfg.get("max_content_length", 5 * 1024 ** 3))
cfg["cookie_secure"] = bool(cfg.get("cookie_secure", True))
cfg["session_days"] = int(cfg.get("session_days", 7))
# 剪贴板:单条文本上限(字符)、保留条数上限、默认有效期(小时)
cfg["max_clip_length"] = int(cfg.get("max_clip_length", 256 * 1024))
cfg["max_clips"] = int(cfg.get("max_clips", 200))
cfg["clip_default_expires_hours"] = int(cfg.get("clip_default_expires_hours", 24))
return cfg
def random_secret() -> str:
return secrets.token_urlsafe(48)
class RateLimiter:
"""基于内存的按 IP 登录失败限流。"""
def __init__(self, max_failures: int = 10, window_seconds: int = 900):
self.max_failures = max_failures
self.window_seconds = window_seconds
self._lock = threading.Lock()
self._failures: dict[str, list[float]] = {}
def _prune(self, ip: str, now: float) -> None:
self._failures[ip] = [t for t in self._failures.get(ip, []) if now - t < self.window_seconds]
def is_blocked(self, ip: str) -> bool:
with self._lock:
self._prune(ip, time.monotonic())
return len(self._failures.get(ip, [])) >= self.max_failures
def record_failure(self, ip: str) -> None:
with self._lock:
self._failures.setdefault(ip, []).append(time.monotonic())
self._prune(ip, time.monotonic())
def reset(self, ip: str) -> None:
with self._lock:
self._failures.pop(ip, None)