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 同步更新
This commit is contained in:
2026-10-11 22:20:54 +08:00
parent 67caaa428e
commit 5af69d694f
9 changed files with 773 additions and 45 deletions
+75 -26
View File
@@ -4,12 +4,16 @@
LZWLAB_APP_PASSWORD='你的应用登录密码' python deploy_transfer.py --username admin
仅更新代码、不改动登录密码时(保留服务器上已有的 secret_key 与密码哈希):
python deploy_transfer.py --keep-credentials
服务器端安装:
- apt 安装 python3-flask、gunicorn
- 应用代码安装到 /opt/lzwlab-transfer(root 所有)
- 运行用户 lzwlab(systemd 服务,监听 127.0.0.1:8090)
- 数据目录 /var/lib/lzwlab-transfer,配置 /etc/lzwlab-transfer/config.json(密码仅存 scrypt 哈希)
- systemd 定时器每 10 分钟清理过期文件
- systemd 定时器每 10 分钟清理过期文件与过期剪贴板
- 更新 nginx 反代配置与首页链接
"""
import argparse
@@ -57,21 +61,36 @@ def parse_auth() -> tuple[str, str, str]:
return find("IP地址"), find("用户名"), find("密码")
def connect(ip: str, user: str, pw: str) -> paramiko.SSHClient:
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
cli.connect(ip, port=22, username=user, password=pw, timeout=20,
look_for_keys=False, allow_agent=False, banner_timeout=30)
return cli
def connect(ip: str, user: str, pw: str, attempts: int = 3) -> paramiko.SSHClient:
"""连接 SSH;服务器偶发拒绝握手(Error reading SSH protocol banner)时自动重试。"""
last_exc = None
for attempt in range(1, attempts + 1):
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
cli.connect(ip, port=22, username=user, password=pw, timeout=20,
look_for_keys=False, allow_agent=False,
banner_timeout=30, auth_timeout=30)
return cli
except (paramiko.SSHException, OSError) as exc:
last_exc = exc
cli.close()
if attempt < attempts:
print(" [retry] SSH 连接失败(%s),2 秒后重试(%d/%d)…"
% (exc, attempt, attempts))
time.sleep(2)
raise SystemExit("SSH 连接失败:%s" % last_exc)
def run(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600) -> tuple[int, str, str]:
print(" $ " + cmd)
def run(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600,
quiet: bool = False) -> tuple[int, str, str]:
if not quiet:
print(" $ " + cmd)
_in, out, err = ssh.exec_command(cmd, timeout=timeout)
out_s = out.read().decode("utf-8", "replace")
err_s = err.read().decode("utf-8", "replace")
code = out.channel.recv_exit_status()
if out_s.strip():
if not quiet and out_s.strip():
print(out_s.strip())
if err_s.strip():
print("[stderr]", err_s.strip())
@@ -111,11 +130,15 @@ def main() -> None:
ap.add_argument("--password-env", default="LZWLAB_APP_PASSWORD",
help="应用登录密码所在的环境变量名")
ap.add_argument("--skip-apt", action="store_true", help="跳过 apt 安装依赖")
ap.add_argument("--keep-credentials", action="store_true",
help="保留服务器上已有的 secret_key 与用户密码哈希(只更新代码与新增配置项,"
"不改登录密码;此时无需提供应用密码)")
args = ap.parse_args()
password = os.environ.get(args.password_env, "")
if len(password) < 8:
sys.exit("请通过环境变量 %s 提供至少 8 位的应用登录密码" % args.password_env)
if not args.keep_credentials and len(password) < 8:
sys.exit("请通过环境变量 %s 提供至少 8 位的应用登录密码"
"(仅更新代码可用 --keep-credentials 保留现有密码)" % args.password_env)
ip, ssh_user, ssh_pw = parse_auth()
print("目标服务器:%s@%s" % (ssh_user, ip))
@@ -150,19 +173,42 @@ def main() -> None:
upload(ssh, "index.html", "%s/index.html" % REMOTE_TMP)
upload(ssh, "lzwlab.nginx.conf", "%s/lzwlab.cn.conf" % REMOTE_TMP)
# 4. 生成配置(仅包含密码哈希,不保存明文)
cfg = {
"secret_key": auth.random_secret(),
"cookie_secure": True,
"max_content_length": 5 * 1024 ** 3,
"data_dir": DATA_DIR,
"database_path": DATA_DIR + "/files.db", # 远端为 Linux 路径,不能用 Windows 的 os.path.join
"session_days": 7,
"users": {
args.username: {"password_hash": auth.hash_password(password)}
},
}
print(" [generate] 应用配置(用户:%s)" % args.username)
# 4. 生成配置(密码只以 scrypt 哈希保存;--keep-credentials 时沿用服务器上已有凭据)
existing = None
if args.keep_credentials:
code, out, _err = run(ssh, "sudo -n cat %s" % CONFIG_PATH, quiet=True)
if code != 0 or not out.strip():
sys.exit("--keep-credentials 需要服务器上已存在 %s" % CONFIG_PATH)
existing = json.loads(out)
print(" [config] 保留服务器上已有的 secret_key 与用户密码哈希")
if existing is not None:
cfg = dict(existing)
cfg.setdefault("cookie_secure", True)
cfg.setdefault("max_content_length", 5 * 1024 ** 3)
cfg.setdefault("data_dir", DATA_DIR)
cfg.setdefault("database_path", DATA_DIR + "/files.db")
cfg.setdefault("session_days", 7)
cfg.setdefault("max_clip_length", 256 * 1024) # 单条剪贴板文本上限(字符)
cfg.setdefault("max_clips", 200) # 剪贴板保留条数上限
cfg.setdefault("clip_default_expires_hours", 24) # 剪贴板默认有效期
print(" [generate] 应用配置(保留用户:%s)" % "、".join(sorted(cfg.get("users", {}))))
else:
cfg = {
"secret_key": auth.random_secret(),
"cookie_secure": True,
"max_content_length": 5 * 1024 ** 3,
"data_dir": DATA_DIR,
"database_path": DATA_DIR + "/files.db", # 远端为 Linux 路径,不能用 Windows 的 os.path.join
"session_days": 7,
"max_clip_length": 256 * 1024, # 单条剪贴板文本上限(字符)
"max_clips": 200, # 剪贴板保留条数上限
"clip_default_expires_hours": 24,
"users": {
args.username: {"password_hash": auth.hash_password(password)}
},
}
print(" [generate] 应用配置(用户:%s)" % args.username)
sftp = ssh.open_sftp()
try:
with sftp.open("%s/config.json" % REMOTE_TMP, "w") as fh:
@@ -203,7 +249,10 @@ def main() -> None:
run(ssh, "curl -s -o /dev/null -w 'local https /transfer/ => %{http_code}\n' "
"-k https://127.0.0.1/transfer/ -H 'Host: lzwlab.cn'")
print("应用入口:https://lzwlab.cn/transfer/")
print("登录用户:%s(密码为环境变量 %s 中提供的值)" % (args.username, args.password_env))
if args.keep_credentials:
print("登录用户:%s(沿用服务器上已有的密码,未重置)" % "、".join(sorted(cfg.get("users", {}))))
else:
print("登录用户:%s(密码为环境变量 %s 中提供的值)" % (args.username, args.password_env))
finally:
ssh.close()