Files
lzwlab_cn/deploy_transfer.py
T
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

262 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""将 lzwlab.cn 文件快传部署到远程服务器(幂等,可重复执行)。
用法(凭据从 Authentication.md 解析;应用登录密码通过环境变量传入,不落盘):
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 分钟清理过期文件与过期剪贴板
- 更新 nginx 反代配置与首页链接
"""
import argparse
import json
import os
import re
import sys
import time
import paramiko
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(ROOT, "transfer"))
import auth # noqa: E402
REMOTE_TMP = "/tmp/lzwlab-transfer-deploy"
APP_DIR = "/opt/lzwlab-transfer"
DATA_DIR = "/var/lib/lzwlab-transfer"
CONFIG_PATH = "/etc/lzwlab-transfer/config.json"
LOG_DIR = "/var/log/lzwlab-transfer"
SERVICE_USER = "lzwlab"
PORT = 8090
LOCAL_FILES = [
("transfer/app.py", "app.py"),
("transfer/auth.py", "auth.py"),
("transfer/storage.py", "storage.py"),
("transfer/cleanup.py", "cleanup.py"),
("transfer/templates/index.html", "templates/index.html"),
]
SYSTEMD_FILES = [
("transfer/systemd/lzwlab-transfer.service", "lzwlab-transfer.service"),
("transfer/systemd/lzwlab-transfer-cleanup.service", "lzwlab-transfer-cleanup.service"),
("transfer/systemd/lzwlab-transfer-cleanup.timer", "lzwlab-transfer-cleanup.timer"),
]
def parse_auth() -> tuple[str, str, str]:
text = open(os.path.join(ROOT, "Authentication.md"), encoding="utf-8").read()
def find(label: str) -> str:
m = re.search(label + r"\s*[::]\s*(\S+)", text)
if not m:
sys.exit("无法解析 Authentication.md 中的 " + label)
return m.group(1)
return find("IP地址"), find("用户名"), find("密码")
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,
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 not quiet and out_s.strip():
print(out_s.strip())
if err_s.strip():
print("[stderr]", err_s.strip())
return code, out_s, err_s
def must(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600) -> str:
code, out, err = run(ssh, cmd, timeout)
if code != 0:
sys.exit("命令失败(exit=%d):%s" % (code, cmd))
return out
def sftp_mkdir_p(sftp: paramiko.SFTPClient, remote_dir: str) -> None:
try:
sftp.stat(remote_dir)
except IOError:
parent = os.path.dirname(remote_dir.rstrip("/"))
if parent and parent != remote_dir:
sftp_mkdir_p(sftp, parent)
sftp.mkdir(remote_dir)
def upload(ssh: paramiko.SSHClient, local: str, remote: str) -> None:
print(" [upload] %s -> %s" % (local, remote))
sftp = ssh.open_sftp()
try:
sftp_mkdir_p(sftp, os.path.dirname(remote))
sftp.put(os.path.join(ROOT, local), remote)
finally:
sftp.close()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--username", default="admin", help="应用登录用户名(默认 admin)")
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 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))
ssh = connect(ip, ssh_user, ssh_pw)
try:
# 1. 依赖
if not args.skip_apt:
code, _, err = run(ssh, "sudo -n env DEBIAN_FRONTEND=noninteractive "
"apt-get install -y python3-flask gunicorn", timeout=900)
if code != 0:
print("直接安装失败,尝试先 apt-get update …")
must(ssh, "sudo -n apt-get update -qq", timeout=900)
must(ssh, "sudo -n env DEBIAN_FRONTEND=noninteractive "
"apt-get install -y python3-flask gunicorn", timeout=900)
# 2. 目录、运行用户与数据目录
must(ssh, "sudo -n mkdir -p %s %s/templates %s/files %s /etc/lzwlab-transfer"
% (APP_DIR, APP_DIR, DATA_DIR, LOG_DIR))
code, _, _ = run(ssh, "id -u %s >/dev/null 2>&1" % SERVICE_USER)
if code != 0:
must(ssh, "sudo -n useradd --system --home-dir %s --shell /usr/sbin/nologin %s"
% (APP_DIR, SERVICE_USER))
must(ssh, "sudo -n chown -R %s:%s %s %s" % (SERVICE_USER, SERVICE_USER, DATA_DIR, LOG_DIR))
must(ssh, "sudo -n chmod 750 %s" % LOG_DIR)
must(ssh, "sudo -n rm -rf %s && mkdir -p %s" % (REMOTE_TMP, REMOTE_TMP))
# 3. 上传应用文件
for local, name in LOCAL_FILES:
upload(ssh, local, "%s/%s" % (REMOTE_TMP, name))
for local, name in SYSTEMD_FILES:
upload(ssh, local, "%s/systemd/%s" % (REMOTE_TMP, name))
upload(ssh, "index.html", "%s/index.html" % REMOTE_TMP)
upload(ssh, "lzwlab.nginx.conf", "%s/lzwlab.cn.conf" % REMOTE_TMP)
# 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:
fh.write(json.dumps(cfg, ensure_ascii=False, indent=2))
finally:
sftp.close()
# 5. 安装应用、配置与 systemd
for _, name in LOCAL_FILES:
must(ssh, "sudo -n install -m 644 -o root -g root %s/%s %s/%s"
% (REMOTE_TMP, name, APP_DIR, name))
must(ssh, "sudo -n install -m 640 -o root -g %s %s/config.json %s"
% (SERVICE_USER, REMOTE_TMP, CONFIG_PATH))
for _, name in SYSTEMD_FILES:
must(ssh, "sudo -n install -m 644 -o root -g root %s/systemd/%s /etc/systemd/system/%s"
% (REMOTE_TMP, name, name))
must(ssh, "sudo -n install -m 644 -o root -g root %s/index.html /var/www/lzwlab.cn/index.html"
% REMOTE_TMP)
must(ssh, "sudo -n install -m 644 -o root -g root %s/lzwlab.cn.conf "
"/etc/nginx/sites-available/lzwlab.cn" % REMOTE_TMP)
# 6. 启动服务与定时器
must(ssh, "sudo -n systemctl daemon-reload")
must(ssh, "sudo -n systemctl enable --now lzwlab-transfer.service lzwlab-transfer-cleanup.timer")
must(ssh, "sudo -n systemctl restart lzwlab-transfer.service")
must(ssh, "sudo -n systemctl enable lzwlab-transfer-cleanup.timer && "
"sudo -n systemctl start lzwlab-transfer-cleanup.timer")
time.sleep(1)
# 7. 校验并重载 nginx
must(ssh, "sudo -n nginx -t")
must(ssh, "sudo -n systemctl reload nginx")
must(ssh, "sudo -n rm -rf %s" % REMOTE_TMP)
print()
print("== 部署完成,服务状态 ==")
run(ssh, "systemctl is-active lzwlab-transfer.service lzwlab-transfer-cleanup.timer")
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/")
if args.keep_credentials:
print("登录用户:%s(沿用服务器上已有的密码,未重置)" % "、".join(sorted(cfg.get("users", {}))))
else:
print("登录用户:%s(密码为环境变量 %s 中提供的值)" % (args.username, args.password_env))
finally:
ssh.close()
if __name__ == "__main__":
main()