diff --git a/CHANGELOG.md b/CHANGELOG.md index a72261e..7cd749d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,17 @@ - 新增 Beszel 服务器监控(https://lzwlab.cn/beszel/):Docker Compose 部署 Beszel hub(henrygd/beszel:latest,容器 beszel,仅监听 127.0.0.1:8092),通过 USER_EMAIL/USER_PASSWORD 自动创建首个管理员。 - 新增 beszel/docker-compose.yml;nginx 按 Beszel 官方子路径方案将 /beszel/ 反代到 8092(含 WebSocket)。 - setup.md 新增第 12 节:Beszel 部署过程、子路径反代、Agent 接入说明与验证结果。 +- 新增“共享剪贴板”功能(与文件快传同一入口 https://lzwlab.cn/transfer/ ):在任一客户端粘贴任意文本发送,其他客户端登录后即可查看并“一键复制”写入本机剪贴板;支持读取本机剪贴板、来源备注(如“公司 PC”)、查看全文、单条删除与复制次数统计。 +- 新增剪贴板接口:GET/POST `/api/clips`(列表、新建)、GET/DELETE `/api/clips/`(取全文、删除)、POST `/api/clips//copy`(复制计数);列表接口只返回前 240 字符预览,全文按 id 单独获取。 +- SQLite 新增 `clips` 表(含 `expires_at` 索引),应用启动时自动建表,线上老库无需手工迁移。 +- deploy_transfer.py 新增 `--keep-credentials` 选项:只更新代码与新增配置项,保留服务器上已有的 secret_key 与用户密码哈希,不重置登录密码、也无需传入密码。 ### Changed +- 更新清理逻辑:systemd 定时任务与 `/api/cleanup` 除过期文件外同时清理过期剪贴板内容,清理日志与接口返回均包含剪贴板条数。 +- 更新应用配置项:新增 `max_clip_length`(默认 262144 字符)、`max_clips`(默认 200 条)、`clip_default_expires_hours`(默认 24 小时),配置中缺省时使用内置默认值。 +- 更新 transfer/templates/index.html :新增共享剪贴板卡片与响应式样式,移动端操作按钮整行排列。 + - 更新首页 index.html :欢迎卡片增加“进入 Git 服务”入口链接(/git/)。 - 更新 lzwlab.nginx.conf :按 Gitea 官方子路径(sub-path)方案新增 /git/ 反向代理到本机 3000 端口。 - 更新 README.md 与 setup.md :补充 Gitea Git 服务部署记录与使用说明。 @@ -38,6 +46,7 @@ - 更新首页 index.html :新增“📊 服务器状态”入口(/beszel/)。 - 更新 lzwlab.nginx.conf :新增 /beszel/ 反向代理到本机 8092 端口。 - 更新 README.md 与 setup.md :补充 Beszel 服务器监控部署记录。 +- 更新 README.md 与 setup.md :补充共享剪贴板功能说明、接口与部署验证记录。 ### Removed @@ -59,6 +68,8 @@ - 应用登录密码仅以 scrypt 哈希保存在 /etc/lzwlab-transfer/config.json (root:lzwlab 640),不在代码或仓库中保存明文。 - 会话 Cookie 启用 Secure/HttpOnly/SameSite=Lax,登录接口按 IP 限流,写接口校验 X-Requested-With 头。 - Beszel hub 端口仅绑定 127.0.0.1,公网仅经 nginx HTTPS 访问 /beszel/;管理员凭据仅记录于 Authentication.md,首次创建后清空服务器 .env 中的明文密码。 +- 共享剪贴板沿用文件快传的登录鉴权与 X-Requested-With 校验:内容仅登录用户可见,列表接口不返回完整正文,单条长度(默认 25.6 万字符)与保留条数(默认 200 条)均有上限,到期内容由定时任务自动删除。 +- deploy_transfer.py 增加 --keep-credentials 后,日常更新代码不再重建 secret_key 与密码哈希,避免每次部署都让在线会话失效或意外重置登录密码。 ## [1.0.0] - 2026-08-24 diff --git a/README.md b/README.md index 4493cd6..ee205a0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ lzwlab.cn 个人云服务器上的站点部署项目:包含站点欢迎页、n - 静态欢迎页:`index.html`,响应式渐变卡片,中文标题“欢迎访问 lzwlab.cn”,并提供“📦 进入文件快传”“🐙 进入 Git 服务”“📊 服务器状态”三个入口。 - HTTPS 全站:`lzwlab.nginx.conf` 将 80 端口请求 301 跳转到 443;443 端口启用 TLS 1.2/1.3 与 HTTP/2,证书链由 TrustAsia 签发。 - 文件快传(`/transfer/`):指定用户登录后才能使用,PC/手机自适应;支持多文件上传与下载进度、有效期设置(默认 7 天,最短 1 小时,最长 30 天)、缓存列表与手动删除;过期文件由 systemd 定时器每 10 分钟自动清理。 +- 共享剪贴板(`/transfer/` 同一页面):在一台客户端粘贴任意文本发送,其他客户端登录后即可看到并“一键复制”写入本机剪贴板;支持读取本机剪贴板、来源备注、查看全文、单条删除、复制次数统计,有效期默认 1 天(最短 1 小时、最长 30 天),单条上限 25.6 万字符、最多保留 200 条,过期内容由同一个 systemd 定时器清理。 - 远程运维脚本:`remote.py` 基于 Paramiko,支持执行远程命令(可 sudo)与上传文件,凭据自动从 `Authentication.md` 解析,无需在命令行传密码。 - RustDesk 自建服务器:Docker Compose 部署 hbbs(ID/注册服务器)+ hbbr(中继服务器),host 网络监听 21115-21119,客户端以域名 `lzwlab.cn` 接入,客户端配置说明见 [setup.md](setup.md) 第 9 节。 - Gitea Git 服务(`https://lzwlab.cn/git/`):Docker 部署 Gitea 1.27.2(SQLite),管理员私有单实例,关闭开放注册;支持 HTTPS 克隆与 SSH 克隆(端口 2222),部署记录见 [setup.md](setup.md) 第 10 节。 @@ -27,13 +28,13 @@ lzwlab.cn 个人云服务器上的站点部署项目:包含站点欢迎页、n │ ├── lzwlab.cn_bundle.crt │ └── lzwlab.cn_bundle.pem ├── remote.py # Paramiko 远程执行/上传脚本 -├── deploy_transfer.py # 文件快传一键部署脚本(幂等,可重复执行) +├── deploy_transfer.py # 文件快传一键部署脚本(幂等,支持 --keep-credentials 只更新代码) ├── transfer/ # 文件快传应用源码 │ ├── app.py # Flask 应用入口(gunicorn 启动) │ ├── auth.py # 配置加载、scrypt 密码哈希、登录限流 -│ ├── storage.py # SQLite 元数据 + 磁盘缓存存储层 -│ ├── cleanup.py # 过期文件/孤儿缓存清理 CLI -│ ├── templates/index.html # 响应式单页前端(登录/上传队列/下载进度/文件列表) +│ ├── storage.py # SQLite 元数据(files / clips)+ 磁盘缓存存储层 +│ ├── cleanup.py # 过期文件/过期剪贴板/孤儿缓存清理 CLI +│ ├── templates/index.html # 响应式单页前端(登录/上传队列/下载进度/文件列表/共享剪贴板) │ └── systemd/ # systemd 服务与清理定时器单元 │ ├── lzwlab-transfer.service │ ├── lzwlab-transfer-cleanup.service @@ -80,6 +81,9 @@ python remote.py --put index.html /tmp/lzwlab-index.html ```bash LZWLAB_APP_PASSWORD='$PASS' python deploy_transfer.py --username admin + +# 只更新代码、不改动登录密码(保留服务器上已有的 secret_key 与密码哈希,无需提供密码) +python deploy_transfer.py --keep-credentials ``` 验证站点: @@ -98,7 +102,7 @@ curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/beszel/ # 200( - 站点根目录:`/var/www/lzwlab.cn`; - nginx 配置安装到 `/etc/nginx/sites-available/lzwlab.cn` 并软链到 `sites-enabled/`,改配置后执行 `sudo systemctl reload nginx`; - 证书安装到 `/etc/nginx/ssl/`(私钥权限 600、root:root); -- 文件快传:应用代码安装到 `/opt/lzwlab-transfer`,数据目录 `/var/lib/lzwlab-transfer`,配置 `/etc/lzwlab-transfer/config.json`(root:lzwlab 640);gunicorn 监听 `127.0.0.1:8090`,由 nginx 将 `/transfer/` 反向代理到该端口,systemd 定时器每 10 分钟执行一次过期清理。 +- 文件快传:应用代码安装到 `/opt/lzwlab-transfer`,数据目录 `/var/lib/lzwlab-transfer`,配置 `/etc/lzwlab-transfer/config.json`(root:lzwlab 640);gunicorn 监听 `127.0.0.1:8090`,由 nginx 将 `/transfer/` 反向代理到该端口,systemd 定时器每 10 分钟执行一次过期清理(同时清理过期文件与过期剪贴板)。共享剪贴板与文件共用同一个 SQLite 库(`files.db` 中的 `clips` 表),首次启动自动建表,无需手工迁移。 - RustDesk:Compose 文件安装在 `/opt/rustdesk`(hbbs+hbbr,host 网络,数据目录 `/opt/rustdesk/data`);客户端 ID 服务器/中继/Key 配置见 [setup.md](setup.md) 第 9 节。 - Gitea:Compose 文件安装在 `/opt/gitea`(容器 `gitea`,SQLite 数据卷 `/opt/gitea/gitea`,Web 仅监听 `127.0.0.1:3000`,SSH 映射 `2222→22`);nginx 按 Gitea 官方 sub-path 方案将 `/git/` 反代到 3000 端口;管理员账号与密码记录在 `Authentication.md`,注册已关闭、安装器已锁定。详见 [setup.md](setup.md) 第 10 节。 - Beszel:Compose 文件安装在 `/opt/beszel`(容器 `beszel`,镜像 `henrygd/beszel:latest`,仅监听 `127.0.0.1:8092`,数据卷 `/opt/beszel/beszel_data`);nginx 按 Beszel 官方 sub-path 方案将 `/beszel/` 反代到 8092,`APP_URL=https://lzwlab.cn/beszel`;管理员账号与密码记录在 `Authentication.md`。添加被监控节点需在 Web 面板“Add System”中生成 Agent 配置,详见 [setup.md](setup.md) 第 12 节。 @@ -109,7 +113,8 @@ curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/beszel/ # 200( - `lzwlab.cn_nginx/lzwlab.cn.key` 为私钥,上传到服务器后权限应为 600(root:root)。 - 站点已启用 HTTPS 并强制 301 跳转,nginx 配置限定 TLS 1.2/1.3。 - 文件快传登录密码只以 scrypt 哈希形式保存在 `/etc/lzwlab-transfer/config.json`,应用不保存明文密码;会话 Cookie 带 `Secure`、`HttpOnly`、`SameSite=Lax`,登录接口带按 IP 限流,写接口要求 `X-Requested-With` 头。 -- 文件快传缓存文件按有效期自动删除,请在服务器上自行控制磁盘空间。 +- 文件快传缓存文件与共享剪贴板内容按有效期自动删除,请在服务器上自行控制磁盘空间。 +- 共享剪贴板与文件快传共用同一套登录鉴权:内容仅登录用户可见,列表接口只返回前 240 字符预览,全文需按 id 单独获取;单条上限 25.6 万字符(`max_clip_length`)、最多保留 200 条(`max_clips`),均可在 `/etc/lzwlab-transfer/config.json` 中调整。 - RustDesk 使用 hbbs 首次启动自动生成的自建密钥(服务器 `/opt/rustdesk/data/id_ed25519.pub`),客户端必须配置该 Key 才能连接;Key 需分发给每个客户端使用,本身不属于机密凭据。 - Gitea 关闭开放注册并锁定安装器;管理员密码只记录在 `Authentication.md`,对外文档一律用 `$PASS` 代替。 - Gitea SSH 克隆端口为 2222,需在腾讯云安全组放行 TCP 2222 后才能从公网使用;HTTPS 克隆与网页访问不受影响。 diff --git a/deploy_transfer.py b/deploy_transfer.py index eb0a35a..f347661 100644 --- a/deploy_transfer.py +++ b/deploy_transfer.py @@ -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() diff --git a/setup.md b/setup.md index 4a28523..dc04cb3 100644 --- a/setup.md +++ b/setup.md @@ -271,9 +271,10 @@ echo | openssl s_client -connect lzwlab.cn:443 -servername lzwlab.cn -alpn h2,ht - 后端:Flask 3.0.2 + gunicorn 20.1(apt 安装),监听 `127.0.0.1:8090`,systemd 服务 `lzwlab-transfer.service`。 - 前端:单页 HTML(内嵌 CSS/JS),PC 与手机自适应,登录后可用。 -- 存储:上传文件保存在 `/var/lib/lzwlab-transfer/files/`,元数据在 SQLite `/var/lib/lzwlab-transfer/files.db`。 +- 存储:上传文件保存在 `/var/lib/lzwlab-transfer/files/`,元数据在 SQLite `/var/lib/lzwlab-transfer/files.db`;共享剪贴板为纯数据库存储,同一库中的 `clips` 表(启动时自动建表,老库无需手工迁移)。 +- 共享剪贴板:与文件快传同一页面,登录用户共用一块剪贴板——任一客户端粘贴文本发送,其他客户端列表可见、可“一键复制”写入本机剪贴板;接口为 `/api/clips`(GET 列表 / POST 新建)、`/api/clips/`(GET 全文 / DELETE 删除)、`/api/clips//copy`(POST 复制计数)。列表只返回前 240 字符预览,全文按需获取;有效期默认 1 天(可选 1 小时~30 天),单条上限 262144 字符(`max_clip_length`),最多保留 200 条(`max_clips`)。 - 配置:`/etc/lzwlab-transfer/config.json`(root:lzwlab 640),登录密码只保存 scrypt 哈希,明文经部署环境变量传入。 -- 清理:`lzwlab-transfer-cleanup.timer` 每 10 分钟执行 `cleanup.py` 删除过期文件;访问列表/手动清理时也会触发检查。 +- 清理:`lzwlab-transfer-cleanup.timer` 每 10 分钟执行 `cleanup.py` 删除过期文件与过期剪贴板;访问列表/手动清理时也会触发检查。 - nginx:`/transfer/` 反向代理到 8090,`proxy_request_buffering off` + `proxy_buffering off` 保证大文件上传下载流式传输,`client_max_body_size 5120m`。 ### 8.2 部署命令(本地运维机) @@ -281,9 +282,13 @@ echo | openssl s_client -connect lzwlab.cn:443 -servername lzwlab.cn -alpn h2,ht ```bash # 依赖脚本见仓库 deploy_transfer.py;密码经环境变量传入,不会写入仓库 LZWLAB_APP_PASSWORD='$PASS' python deploy_transfer.py --username admin + +# 只更新代码(例如新增功能后上线):保留服务器上已有的 secret_key 与密码哈希, +# 不重置登录密码,也无需提供密码 +python deploy_transfer.py --keep-credentials ``` -脚本会依次完成:apt 安装依赖 → 创建系统用户 `lzwlab` 与目录 → 上传/安装应用、配置、systemd 单元 → 启动服务与清理定时器 → 校验并 reload nginx。重复执行幂等。 +脚本会依次完成:apt 安装依赖 → 创建系统用户 `lzwlab` 与目录 → 上传/安装应用、配置、systemd 单元 → 启动服务与清理定时器 → 校验并 reload nginx。重复执行幂等;加 `--skip-apt` 可跳过依赖安装(依赖已装好时更快)。 ### 8.3 验证结果 @@ -295,11 +300,27 @@ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://lzwlab.cn/tra 公网 API 实测:登录成功返回会话 Cookie;未登录访问 `/api/files` 返回 401;上传 25 字节测试文件后列表可查,下载返回 200 且支持 `Range`(206);删除后列表为空;将测试文件过期时间强制置为过去并启动清理服务后,日志输出“清理完成:过期文件 1 个”,列表随即清空。 +共享剪贴板(2026-10-11 上线)实测: + +```bash +# 未登录访问剪贴板接口(路由存在即返回 401,缺路由会是 404) +curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/transfer/api/clips # 401 +``` + +- 接口自测(服务器 `/tmp` 下用 Flask `test_client` 跑,不影响线上服务):58 项全通过,覆盖未登录 401、写入校验(缺 `X-Requested-With`、非字符串、空内容、超长、有效期越界、非 JSON)、正文换行归一化与 NUL 清理、中文/emoji 往返、列表预览不返回全文、复制计数、删除与 404、过期清理、条数上限裁剪,以及“老库(只有 files 表)升级后自动建 clips 表且原记录保留”的迁移场景。 +- 存储层自测:26 项全通过(`clips` 表增删改查、预览截断、字符数统计、同秒插入排序、过期清理、条数裁剪,以及原文件表功能回归)。 +- 线上端到端(走 `https://lzwlab.cn/transfer/` 真实链路 nginx + gunicorn + 线上配置与数据库):原密码登录成功(说明 `--keep-credentials` 未重置密码)、发送含中文与 emoji 的多行文本返回 200、列表可见且预览/字符数/来源备注正确、取全文一致、复制计数累计为 1、删除后列表不再出现、`/api/cleanup` 返回新增的 `clips` 字段;验证数据已删除。 +- 数据库确认:`files.db` 中已存在 `clips` 表与 `idx_clips_expires_at` 索引;`journalctl -u lzwlab-transfer-cleanup` 输出“清理完成:过期文件 0 个,孤儿缓存 0 个,过期剪贴板 0 条”,即定时任务已使用新代码。 + ### 8.4 踩坑 - `deploy_transfer.py` 在 Windows 本地生成远端路径时不能使用 `os.path.join`(会把 `/` 变成 `\`),SQLite 路径必须手工拼接 Linux 路径,否则服务报 `unable to open database file`。 - gunicorn 手动排障时不要在 SSH 会话里前台运行(会话断开会留下占端口的孤儿进程),应直接通过 `systemctl` 管理;排障后可 `sudo pkill -u lzwlab -f gunicorn && sudo systemctl restart lzwlab-transfer.service`。 - Windows 下 curl 的 `-F 'file=@...;filename=中文名'` 对非 ASCII 文件名支持不佳,测试时用 ASCII 文件名,浏览器上传不受影响。 +- 本机(Windows)装不上 Flask 时,接口自测可以直接在服务器 `/tmp` 里跑:把 `transfer/*.py`、`templates/index.html` 与测试脚本传到 `/tmp/`,用 `LZWLAB_TRANSFER_CONFIG` 指向一份临时配置,再跑 Flask `test_client`,既能验证真实 Python 版本又不碰线上服务。 +- `test_client` 用例里必须把临时配置的 `cookie_path` 设为 `/`:线上会话 Cookie 的 Path 是 `/transfer/`(浏览器侧路径),而测试客户端直接请求 `/api/*`,否则登录后仍会因带不上 Cookie 而全部 401。 +- `auth.DEFAULT_CONFIG_PATH` 在 `import auth` 时读取 `LZWLAB_TRANSFER_CONFIG`,所以测试脚本必须**先**设置该环境变量再 `import auth`/`import app`,否则会去读线上的 `/etc/lzwlab-transfer/config.json` 而报权限错误。 +- 部署脚本若不保留凭据(不加 `--keep-credentials`),每次部署都会重建 `secret_key` 与密码哈希:既有会话失效、登录密码变成环境变量里传入的值。 ## 9. 部署 RustDesk 自建服务器(Docker) diff --git a/transfer/app.py b/transfer/app.py index 4b56eb7..bff0c4b 100644 --- a/transfer/app.py +++ b/transfer/app.py @@ -24,6 +24,10 @@ 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) @@ -98,16 +102,19 @@ def not_found(err): return err -def _parse_expires_hours(value) -> int: +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("有效期格式不正确") + raise ValueError("%s格式不正确" % label) if not (1 <= hours <= 720): - raise ValueError("有效期最短 1 小时,最长 30 天(720 小时)") + raise ValueError("%s最短 1 小时,最长 30 天(720 小时)" % label) seconds = int(round(hours * 3600)) if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS): - raise ValueError("有效期超出允许范围") + raise ValueError("%s超出允许范围" % label) return seconds @@ -118,6 +125,17 @@ def _clean_name(raw_name) -> str: 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") @@ -261,7 +279,88 @@ def api_cleanup(): 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) + 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/") +@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//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/") +@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__": diff --git a/transfer/auth.py b/transfer/auth.py index 4528ea4..5d04dd4 100644 --- a/transfer/auth.py +++ b/transfer/auth.py @@ -67,6 +67,10 @@ def load_config(path: str | None = None) -> dict: 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 diff --git a/transfer/cleanup.py b/transfer/cleanup.py index eadcb88..11b6c74 100644 --- a/transfer/cleanup.py +++ b/transfer/cleanup.py @@ -1,4 +1,4 @@ -"""手动/定时清理过期文件与孤儿缓存文件。""" +"""手动/定时清理过期文件、过期剪贴板与孤儿缓存文件。""" import logging import time @@ -17,7 +17,9 @@ def main() -> int: now = int(time.time()) removed = storage.cleanup_expired(db_path, files_dir, now=now) orphans = storage.cleanup_orphans(db_path, files_dir, now=now) - log.info("清理完成:过期文件 %d 个,孤儿缓存 %d 个", removed, orphans) + clips = storage.cleanup_expired_clips(db_path, now=now) + log.info("清理完成:过期文件 %d 个,孤儿缓存 %d 个,过期剪贴板 %d 条", + removed, orphans, clips) return 0 diff --git a/transfer/storage.py b/transfer/storage.py index def1f09..0630867 100644 --- a/transfer/storage.py +++ b/transfer/storage.py @@ -16,8 +16,21 @@ CREATE TABLE IF NOT EXISTS files ( expires_at INTEGER NOT NULL, downloads INTEGER NOT NULL DEFAULT 0 ); + +CREATE TABLE IF NOT EXISTS clips ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + client TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + copies INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_clips_expires_at ON clips (expires_at); """ +CLIP_PREVIEW_CHARS = 240 + def connect(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path, timeout=30) @@ -172,3 +185,136 @@ def cleanup_orphans(db_path: str, files_dir: str, now: int | None = None, except OSError: pass return removed + + +# ---------- 剪贴板(纯数据库存储,无磁盘文件) ---------- + +def clip_to_dict(row: sqlite3.Row, now: int) -> dict: + """把带 preview/size 字段的查询行转换为接口返回结构(不含完整正文)。""" + size = row["size"] if row["size"] is not None else 0 + return { + "id": row["id"], + "preview": row["preview"] or "", + "size": size, + "truncated": size > len(row["preview"] or ""), + "client": row["client"] or "", + "created_at": iso(row["created_at"]), + "expires_at": iso(row["expires_at"]), + "remaining_seconds": max(0, row["expires_at"] - now), + "copies": row["copies"], + } + + +def insert_clip(db_path: str, clip_id: str, content: str, client: str, + expires_at: int, created_at: int) -> None: + with _DB_LOCK: + conn = connect(db_path) + try: + conn.execute( + "INSERT INTO clips (id, content, client, created_at, expires_at, copies) " + "VALUES (?, ?, ?, ?, ?, 0)", + (clip_id, content, client, created_at, expires_at), + ) + conn.commit() + finally: + conn.close() + + +def get_clip(db_path: str, clip_id: str, preview_chars: int = CLIP_PREVIEW_CHARS): + """返回单条剪贴板记录的完整内容(含 content 字段)。""" + with _DB_LOCK: + conn = connect(db_path) + try: + return conn.execute( + "SELECT id, content, client, created_at, expires_at, copies, " + "length(content) AS size FROM clips WHERE id = ?", + (clip_id,), + ).fetchone() + finally: + conn.close() + + +def clip_content_to_dict(row: sqlite3.Row, now: int) -> dict: + return { + "id": row["id"], + "content": row["content"] or "", + "size": row["size"] if row["size"] is not None else 0, + "client": row["client"] or "", + "created_at": iso(row["created_at"]), + "expires_at": iso(row["expires_at"]), + "remaining_seconds": max(0, row["expires_at"] - now), + "copies": row["copies"], + } + + +def list_clips(db_path: str, now: int, + preview_chars: int = CLIP_PREVIEW_CHARS) -> list[dict]: + """列出未过期剪贴板记录;只取正文前若干字符作为预览,避免列表响应过大。""" + with _DB_LOCK: + conn = connect(db_path) + try: + rows = conn.execute( + "SELECT id, client, created_at, expires_at, copies, " + "substr(content, 1, ?) AS preview, length(content) AS size " + "FROM clips WHERE expires_at > ? " + "ORDER BY created_at DESC, rowid DESC", + (preview_chars, now), + ).fetchall() + return [clip_to_dict(r, now) for r in rows] + finally: + conn.close() + + +def increment_clip_copies(db_path: str, clip_id: str) -> bool: + with _DB_LOCK: + conn = connect(db_path) + try: + cur = conn.execute( + "UPDATE clips SET copies = copies + 1 WHERE id = ?", (clip_id,) + ) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + +def delete_clip(db_path: str, clip_id: str) -> bool: + with _DB_LOCK: + conn = connect(db_path) + try: + cur = conn.execute("DELETE FROM clips WHERE id = ?", (clip_id,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + +def cleanup_expired_clips(db_path: str, now: int | None = None) -> int: + import time as _time + now = now if now is not None else int(_time.time()) + with _DB_LOCK: + conn = connect(db_path) + try: + cur = conn.execute("DELETE FROM clips WHERE expires_at <= ?", (now,)) + conn.commit() + return cur.rowcount + finally: + conn.close() + + +def prune_clips(db_path: str, keep: int) -> int: + """只保留最近 keep 条剪贴板记录,返回被删除的条数。""" + if keep <= 0: + return 0 + with _DB_LOCK: + conn = connect(db_path) + try: + cur = conn.execute( + "DELETE FROM clips WHERE id NOT IN (" + "SELECT id FROM clips ORDER BY created_at DESC, rowid DESC LIMIT ?)", + (keep,), + ) + conn.commit() + return cur.rowcount + finally: + conn.close() diff --git a/transfer/templates/index.html b/transfer/templates/index.html index e805a3e..7ff8f37 100644 --- a/transfer/templates/index.html +++ b/transfer/templates/index.html @@ -218,6 +218,51 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t } .empty .empty-icon{font-size:36px;display:block;margin-bottom:8px} +/* ---------- 剪贴板 ---------- */ +.clip-hint{font-size:13px;color:var(--muted);margin:-4px 0 12px} +.clip-input{ + width:100%;min-height:120px;resize:vertical;display:block; + padding:12px; + border:1px solid var(--border);border-radius:10px; + background:var(--bg);color:var(--text); + font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace; + font-size:14px;line-height:1.55; + transition:border-color .15s, box-shadow .15s; +} +.clip-input:focus{border-color:var(--primary);outline:none;box-shadow:var(--focus)} +.clip-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:12px} +.clip-toolbar .field-inline input[type="text"]{ + min-width:150px;min-height:36px;padding:7px 10px;border-radius:8px; +} +.clip-chars{font-size:12px;color:var(--muted);white-space:nowrap} +.clip-chars.over{color:var(--danger);font-weight:600} +.clip-spacer{flex:1 1 auto} +.clip-list{list-style:none;display:grid;gap:10px;margin-top:16px} +.clip-row{ + display:grid;grid-template-columns:minmax(0,1fr) auto; + gap:12px;align-items:start; + border:1px solid var(--border);border-radius:12px;padding:14px; + transition:border-color .15s, box-shadow .15s; +} +.clip-row:hover{border-color:var(--border-strong);box-shadow:var(--shadow)} +.clip-main{min-width:0} +.clip-meta{display:flex;align-items:center;flex-wrap:wrap;gap:6px 14px;color:var(--muted);font-size:12px;margin-bottom:8px} +.clip-client{ + display:inline-block;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; + padding:2px 9px;border-radius:999px; + background:var(--bg);border:1px solid var(--border);color:var(--text);font-weight:600; +} +.clip-text{ + margin:0;padding:10px 12px; + background:var(--bg);border:1px solid var(--border);border-radius:8px; + font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace; + font-size:13px;line-height:1.55; + white-space:pre-wrap;overflow-wrap:anywhere; + max-height:5.4em;overflow:hidden; +} +.clip-row.expanded .clip-text{max-height:340px;overflow:auto} +.clip-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end} + /* Toast 与确认框 */ .toasts{ position:fixed;top:16px;right:16px;z-index:1000; @@ -257,6 +302,13 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t .toasts{left:16px;right:16px;top:12px;align-items:stretch} .q-side{width:100%;justify-content:flex-end} .q-pct{flex:1;text-align:right} + .clip-row{grid-template-columns:minmax(0,1fr);padding:12px} + .clip-actions{justify-content:stretch} + .clip-actions .btn{flex:1} + .clip-toolbar{gap:10px} + .clip-toolbar .field-inline{flex:1 1 150px} + .clip-toolbar .field-inline input[type="text"]{width:100%;min-width:0} + .clip-spacer{display:none} } @media (prefers-reduced-motion: reduce){ *{transition:none !important;animation:none !important} @@ -318,6 +370,41 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t
    +
    +
    +

    共享剪贴板

    +
    + + +
    +
    +

    在一台客户端粘贴文本并发送,其他客户端登录后即可查看,点“复制”即可一键写入本机剪贴板。

    + +
    + + + 0 字符 + + +
    +
      + +
      +

      我的暂存文件

      @@ -363,16 +450,27 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t var dropZone = $('#dropZone'), fileInput = $('#fileInput'), expiresSelect = $('#expiresSelect'); var queueEl = $('#queue'), fileListEl = $('#fileList'), emptyState = $('#emptyState'); var fileCountEl = $('#fileCount'), cleanupBtn = $('#cleanupBtn'), refreshBtn = $('#refreshBtn'); + var clipInput = $('#clipInput'), clipClientInput = $('#clipClient'), clipCharsEl = $('#clipChars'); + var clipExpiresSelect = $('#clipExpiresSelect'), clipSendBtn = $('#clipSendBtn'); + var clipPasteBtn = $('#clipPasteBtn'), clipRefreshBtn = $('#clipRefreshBtn'); + var clipListEl = $('#clipList'), clipEmpty = $('#clipEmpty'), clipCountEl = $('#clipCount'); var toastsEl = $('#toasts'); var modalRoot = $('#modalRoot'), modalTitle = $('#modalTitle'), modalMessage = $('#modalMessage'); var modalCancel = $('#modalCancel'), modalConfirm = $('#modalConfirm'); + var XHR_HEADER = { 'X-Requested-With': 'XMLHttpRequest' }; + var CLIP_MAX_LENGTH = 262144; // 单条剪贴板文本上限(字符),与后端 max_clip_length 一致 + var CLIP_CLIENT_KEY = 'lzwlab.clip.client'; + var currentUser = null; var files = []; var queue = []; var queueSeq = 0; var uploadBusy = false; var modalResolver = null; + var clips = []; + var clipCache = {}; // id -> 完整正文(列表接口只返回预览) + var clipExpanded = {}; // id -> true 表示已展开全文 /* ---------- 工具函数 ---------- */ function fmtSize(bytes) { @@ -463,6 +561,7 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t passwordInput.value = ''; hide(loginError); loginBtn.disabled = false; loginBtn.textContent = '登 录'; + clips = []; clipCache = {}; clipExpanded = {}; } function showApp(user) { @@ -470,6 +569,8 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t hide(loginView); show(appView); userName.textContent = user; loadFiles(true); + loadClips(true); + clipClientInput.value = readStoredClipClient(); } function handle401() { @@ -696,13 +797,15 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t cleanupBtn.addEventListener('click', async function () { cleanupBtn.disabled = true; try { - var resp = await fetch('api/cleanup', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + var resp = await fetch('api/cleanup', { method: 'POST', headers: XHR_HEADER }); var data = await resp.json().catch(function () { return { ok: false }; }); if (resp.ok && data.ok) { - toast('清理完成:移除 ' + data.removed + ' 个过期文件', 'success'); + toast('清理完成:移除 ' + data.removed + ' 个过期文件、' + + (data.clips || 0) + ' 条过期剪贴板', 'success'); } else if (resp.status === 401) { handle401(); } else { toast(data.error || '清理失败', 'error'); } loadFiles(true); + loadClips(false); } catch (e) { toast('网络错误', 'error'); } @@ -711,6 +814,291 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t refreshBtn.addEventListener('click', function () { loadFiles(true); }); + /* ---------- 共享剪贴板 ---------- */ + function readStoredClipClient() { + try { return localStorage.getItem(CLIP_CLIENT_KEY) || ''; } catch (e) { return ''; } + } + + function storeClipClient(name) { + try { + if (name) localStorage.setItem(CLIP_CLIENT_KEY, name); + else localStorage.removeItem(CLIP_CLIENT_KEY); + } catch (e) {} + } + + function updateClipChars() { + var n = clipInput.value.length; + clipCharsEl.textContent = n + ' 字符'; + clipCharsEl.classList.toggle('over', n > CLIP_MAX_LENGTH); + } + + async function loadClips(showErrors) { + try { + var resp = await fetch('api/clips', { headers: XHR_HEADER }); + if (resp.status === 401) { handle401(); return; } + var data = await resp.json().catch(function () { return { ok: false }; }); + if (!resp.ok) { + if (showErrors) toast(data.error || '加载剪贴板失败', 'error'); + return; + } + clips = data.clips || []; + // 丢弃已被删除/过期条目的缓存,避免内存里留下旧正文 + var alive = {}; + clips.forEach(function (c) { alive[c.id] = true; }); + Object.keys(clipCache).forEach(function (id) { if (!alive[id]) delete clipCache[id]; }); + Object.keys(clipExpanded).forEach(function (id) { if (!alive[id]) delete clipExpanded[id]; }); + renderClips(); + } catch (e) { + if (showErrors) toast('加载剪贴板失败', 'error'); + } + } + + function clipSubtitle(c) { + var parts = [c.size + ' 字符', fmtTime(c.created_at)]; + if (c.copies > 0) parts.push('已复制 ' + c.copies + ' 次'); + return parts.join(' · '); + } + + function renderClips() { + clipListEl.innerHTML = ''; + if (!clips.length) { + show(clipEmpty); + clipCountEl.textContent = ''; + return; + } + hide(clipEmpty); + clipCountEl.textContent = '(' + clips.length + ' 条)'; + clips.forEach(function (c) { + var li = document.createElement('li'); + li.className = 'clip-row'; + li.dataset.id = c.id; + + var main = document.createElement('div'); + main.className = 'clip-main'; + + var meta = document.createElement('div'); + meta.className = 'clip-meta'; + if (c.client) { + var who = document.createElement('span'); + who.className = 'clip-client'; + who.title = '来源备注:' + c.client; + who.textContent = '🖥 ' + c.client; + meta.appendChild(who); + } + var when = document.createElement('span'); + when.textContent = clipSubtitle(c); + meta.appendChild(when); + var expire = document.createElement('span'); + expire.className = 'expire ' + (c.remaining_seconds <= 0 ? 'danger' + : c.remaining_seconds < 24 * 3600 ? 'warn' : 'ok'); + expire.textContent = fmtRemaining(c.remaining_seconds); + meta.appendChild(expire); + main.appendChild(meta); + + var body = document.createElement('pre'); + body.className = 'clip-text'; + var expanded = !!clipExpanded[c.id]; + var full = clipCache[c.id]; + body.textContent = expanded && full !== undefined ? full : c.preview; + main.appendChild(body); + + var actions = document.createElement('div'); + actions.className = 'clip-actions'; + var btnView = document.createElement('button'); + btnView.type = 'button'; btnView.className = 'btn btn-ghost btn-sm'; + btnView.dataset.act = 'view'; btnView.dataset.id = c.id; + btnView.textContent = expanded ? '收起' : '查看'; + var btnCopy = document.createElement('button'); + btnCopy.type = 'button'; btnCopy.className = 'btn btn-primary btn-sm'; + btnCopy.dataset.act = 'copy'; btnCopy.dataset.id = c.id; + btnCopy.textContent = '一键复制'; + var btnDel = document.createElement('button'); + btnDel.type = 'button'; btnDel.className = 'btn btn-danger-ghost btn-sm'; + btnDel.dataset.act = 'del'; btnDel.dataset.id = c.id; + btnDel.textContent = '删除'; + actions.appendChild(btnView); actions.appendChild(btnCopy); actions.appendChild(btnDel); + + li.appendChild(main); li.appendChild(actions); + if (expanded) li.classList.add('expanded'); + clipListEl.appendChild(li); + }); + } + + async function fetchClipContent(id) { + if (Object.prototype.hasOwnProperty.call(clipCache, id)) return clipCache[id]; + var resp = await fetch('api/clips/' + id, { headers: XHR_HEADER }); + if (resp.status === 401) { handle401(); throw new Error('登录已过期'); } + var data = await resp.json().catch(function () { return { ok: false }; }); + if (!resp.ok || !data.ok) throw new Error((data && data.error) || '获取剪贴板内容失败'); + clipCache[id] = data.clip.content; + return clipCache[id]; + } + + async function copyToLocalClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + try { await navigator.clipboard.writeText(text); return true; } catch (e) {} + } + // 降级方案:极简隐藏文本框 + execCommand + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', 'readonly'); + ta.style.position = 'fixed'; + ta.style.top = '0'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + var ok = false; + try { + ta.select(); + ta.setSelectionRange(0, ta.value.length); + ok = document.execCommand('copy'); + } catch (e) { ok = false; } + ta.remove(); + return ok; + } + + function reportClipCopied(id) { + var c = clips.find(function (x) { return x.id === id; }); + if (c) c.copies = (c.copies || 0) + 1; + fetch('api/clips/' + id + '/copy', { method: 'POST', headers: XHR_HEADER }).catch(function () {}); + } + + async function viewClip(id, btn) { + if (clipExpanded[id] && clipCache[id] !== undefined) { + delete clipExpanded[id]; + renderClips(); + return; + } + btn.disabled = true; btn.textContent = '加载中…'; + try { + await fetchClipContent(id); + clipExpanded[id] = true; + renderClips(); + } catch (e) { + toast(e.message || '获取内容失败', 'error'); + btn.disabled = false; btn.textContent = '查看'; + } + } + + async function copyClip(id, btn) { + btn.disabled = true; btn.textContent = '复制中…'; + try { + var text = await fetchClipContent(id); + var ok = await copyToLocalClipboard(text); + if (ok) { + toast('已复制到本机剪贴板(' + text.length + ' 字符)', 'success'); + reportClipCopied(id); + } else { + clipExpanded[id] = true; + renderClips(); + toast('浏览器拒绝写入剪贴板,已展开全文,请手动选中复制', 'error'); + return; + } + } catch (e) { + toast(e.message || '复制失败', 'error'); + } + btn.disabled = false; btn.textContent = '一键复制'; + } + + clipListEl.addEventListener('click', async function (e) { + var btn = e.target.closest('button[data-act]'); + if (!btn) return; + var id = btn.dataset.id; + var act = btn.dataset.act; + if (act === 'view') { viewClip(id, btn); return; } + if (act === 'copy') { copyClip(id, btn); return; } + if (act === 'del') { + var c = clips.find(function (x) { return x.id === id; }); + var ok = await openModal('删除剪贴板内容', + '确定删除这条剪贴板内容吗?此操作不可恢复。', true); + if (!ok) return; + btn.disabled = true; btn.textContent = '删除中…'; + try { + var resp = await fetch('api/clips/' + id, { method: 'DELETE', headers: XHR_HEADER }); + var data = await resp.json().catch(function () { return { ok: false }; }); + if (resp.ok && data.ok) { + toast('已删除剪贴板内容', 'success'); + delete clipCache[id]; delete clipExpanded[id]; + loadClips(true); + } else if (resp.status === 401) { handle401(); } + else { toast(data.error || '删除失败', 'error'); btn.disabled = false; btn.textContent = '删除'; } + } catch (err) { + toast('网络错误', 'error'); btn.disabled = false; btn.textContent = '删除'; + } + } + }); + + async function sendClip() { + var text = clipInput.value; + if (!text.trim()) { + toast('剪贴板内容不能为空', 'error'); + clipInput.focus(); + return; + } + if (text.length > CLIP_MAX_LENGTH) { + toast('文本过长:最多 ' + CLIP_MAX_LENGTH + ' 个字符', 'error'); + return; + } + var client = clipClientInput.value.trim(); + clipSendBtn.disabled = true; clipSendBtn.textContent = '发送中…'; + try { + var resp = await fetch('api/clips', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ + text: text, + client: client, + expires_hours: Number(clipExpiresSelect.value) || 24 + }) + }); + if (resp.status === 401) { handle401(); return; } + var data = await resp.json().catch(function () { return { ok: false }; }); + if (resp.ok && data.ok) { + storeClipClient(client); + clipInput.value = ''; + updateClipChars(); + toast('已发送到共享剪贴板,其他客户端刷新即可查看', 'success'); + loadClips(false); + } else { + toast((data && data.error) || '发送失败', 'error'); + } + } catch (e) { + toast('网络错误,发送失败', 'error'); + } + clipSendBtn.disabled = false; clipSendBtn.textContent = '发送到共享剪贴板'; + } + + clipSendBtn.addEventListener('click', sendClip); + clipRefreshBtn.addEventListener('click', function () { loadClips(true); }); + clipInput.addEventListener('input', updateClipChars); + clipInput.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); sendClip(); } + }); + clipClientInput.addEventListener('change', function () { storeClipClient(clipClientInput.value.trim()); }); + + clipPasteBtn.addEventListener('click', async function () { + if (!navigator.clipboard || !navigator.clipboard.readText) { + clipInput.focus(); + toast('当前浏览器不支持自动读取剪贴板,请在输入框中按 Ctrl / ⌘ + V 粘贴', 'info'); + return; + } + try { + var text = await navigator.clipboard.readText(); + if (!text) { toast('本机剪贴板为空', 'info'); return; } + var start = clipInput.selectionStart, end = clipInput.selectionEnd; + if (typeof clipInput.setRangeText === 'function') { + clipInput.setRangeText(text, start, end, 'end'); + } else { + clipInput.value = clipInput.value.slice(0, start) + text + clipInput.value.slice(end); + } + updateClipChars(); + clipInput.focus(); + toast('已读取本机剪贴板(' + text.length + ' 字符),点“发送”即可共享', 'success'); + } catch (e) { + clipInput.focus(); + toast('浏览器未授权读取剪贴板,请在输入框中按 Ctrl / ⌘ + V 粘贴', 'info'); + } + }); + /* ---------- 上传队列 ---------- */ dropZone.addEventListener('click', function () { fileInput.click(); }); dropZone.addEventListener('keydown', function (e) { @@ -907,8 +1295,11 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[t } /* ---------- 启动 ---------- */ + updateClipChars(); setInterval(function () { - if (currentUser) loadFiles(false); + if (!currentUser) return; + loadFiles(false); + loadClips(false); // 定期拉取,其他客户端发送的剪贴板内容会自动出现 }, 30000); checkSession(); })();