Files
7z-encrypt-tui/scripts/tui.py
T

630 lines
23 KiB
Python

#!/usr/bin/env python3
"""7z-encrypt 客户端 TUI (Python input() REPL, 中文 IME 友好)
vue-tui 版输入竞争问题修复前的过渡方案 (mt-translate 同款模式:
vue-tui 的 raw mode 输入无法配合 IME, Python cooked-mode input() 可以)。
流程: 直接驱动 7z-encrypt 客户端模块 CLI, 实时透传进度输出。
"""
import glob
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from urllib import request as urlrequest
# 已压缩/不可压格式 (正则): 命中则跳过 7z 直传 (压不了的不浪费时间)
INCOMPRESSIBLE = re.compile(
r"\.(7z|apk|avi|bz2|flac|gif|gz|jpeg|jpg|m4a|mkv|mov|mp3|mp4|pdf|png|rar|tar|ttf|webm|webp|woff|woff2|xz|zip)$",
re.IGNORECASE,
)
CLIENT_DIR = os.environ.get("SZ_CLIENT_DIR", "/home/lou/文档/7z-encrypt")
PYTHON = f"{CLIENT_DIR}/.venv/bin/python"
# 编译产物优先 (Nuitka 原生二进制), 无则回退源码解释执行
TRANSFER_BIN = f"{CLIENT_DIR}/bin/sz-transfer"
TRANSFER_CMD = [TRANSFER_BIN] if os.path.exists(TRANSFER_BIN) else [PYTHON, "transfer.py"]
def _tui_config() -> Path:
"""配置文件: 源码目录 config/config.json (开发) -> XDG (安装版)"""
p = Path(f"{CLIENT_DIR}/config/config.json")
if p.exists():
return p
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(xdg) / "7z-encrypt" / "config.json"
def _default_server() -> str:
"""从配置文件读 server.url (无则本地默认)"""
try:
cfg = json.loads(_tui_config().read_text(encoding="utf-8"))
return str(cfg.get("server", {}).get("url", "http://127.0.0.1:8000"))
except (OSError, json.JSONDecodeError):
return "http://127.0.0.1:8000"
def run(args: list[str]) -> int:
"""子进程运行 7z-encrypt CLI, 实时透传输出 (进度可见)
transfer.py 走编译产物 (sz-transfer 原生二进制), 其余模块走 venv python
"""
if args and args[0] == "transfer.py":
cmd = TRANSFER_CMD + args[1:]
else:
cmd = [PYTHON] + args
proc = subprocess.Popen(
cmd,
cwd=CLIENT_DIR,
text=True,
encoding="utf-8",
)
try:
return proc.wait()
except KeyboardInterrupt:
proc.kill()
print("\n[已中断]")
return 130
def _clean(s: str) -> str:
"""剥掉包裹引号 (用户复制路径常带 ' 或 ")"""
return s.strip().strip("'\"") if s else ""
def cleanup_artifacts(path: str) -> None:
"""上传成功后清理临时产物 (密文/分卷/init json/清单)
失败时不清: 断点续传依赖卷文件 + init json。
"""
removed = 0
for pattern in (
path + ".enc",
path + ".enc.part*",
path + ".init.json",
path + "_manifest.json",
path + ".chunk*", # 卷压缩模式产物 (逐卷加密文件)
):
for p in glob.glob(pattern):
try:
os.remove(p)
removed += 1
except OSError:
pass
if removed:
print(f"[清理] 已删除 {removed} 个临时文件")
def upload() -> None:
print("--- 上传文件 ---")
path = _clean(input("文件路径: "))
if not path or not os.path.exists(path):
print(f"[错误] 文件不存在: {path}")
return
if not os.path.isfile(path):
print(f"[错误] 不是文件 (目录上传请用菜单 13 一键备份): {path}")
return
server = _clean(input(f"服务器 [{_default_server()}]: ")) or _default_server()
if not server.startswith(("http://", "https://")):
server = "http://" + server # 只输 IP:端口 自动补协议
dname = _clean(input("目标目录 (回车=根目录): ")).strip()
dir_id = ""
if dname:
# 按名称查目录 id (没有则创建)
try:
from transfer import TransferClient
cfg = json.loads(_tui_config().read_text(encoding="utf-8"))
cli = TransferClient(cfg.get("server", {}).get("url", server),
token=cfg.get("server", {}).get("token"))
dirs = cli.list_dirs()
hit = next((d for d in dirs if d["name"] == dname), None)
if hit is None:
hit = cli.create_dir(dname)
dir_id = str(hit["dir_id"])
except Exception as e:
print(f"[错误] 目录处理失败: {e}")
return
# 全部文件统一卷模式: 32MB 钉死切卷; 可压缩格式逐卷 7z, 已压缩格式切卷+加密不 7z
if not INCOMPRESSIBLE.search(path):
print("\n[1/2] 卷压缩: 32MB 流式切卷 + 逐卷 7z (mx=9) + 逐卷加密 ...")
code = run([
"metadata.py", path, "--chunk-size", "32", "--vol-compress", "32",
"--file-name", os.path.basename(path),
])
else:
print("[1/2] 已压缩格式: 32MB 切卷 + 逐卷加密 (不 7z)")
code = run([
"metadata.py", path, "--chunk-size", "32", "--vol-compress", "32",
"--vol-no-7z", "--file-name", os.path.basename(path),
])
if code != 0:
print("[失败] 切卷/加密失败")
return
print("\n[2/2] 上传 ...")
init_json = path + ".init.json"
manifest = path + "_manifest.json"
cmd = ["transfer.py", "--server", server, "--init", init_json, "--manifest", manifest]
if dir_id:
cmd += ["--dir", dir_id]
code = run(cmd)
if code == 0:
print("[完成]")
cleanup_artifacts(path) # 清 .enc/.part/init/manifest/卷文件
else:
print("[失败] 上传出错 (加密产物保留, 可续传)")
def tasks() -> None:
print("--- 任务续传 ---")
run(["state.py", "pending"])
def config_show() -> None:
print("--- 配置 ---")
run(["config.py"])
def config_edit() -> None:
url = _clean(input("服务器地址: "))
if url:
run(["config.py", "--set-server", url])
token = _clean(input("认证 token (留空不改): "))
if token:
run(["config.py", "--set-token", token])
config_show()
def auth_register() -> None:
"""注册账号 (成功即登录)"""
print("--- 注册账号 ---")
user = _clean(input("用户名 (3-32 位字母数字或下划线): "))
pw = input("密码 (至少 6 位): ")
if not user or not pw:
print("[错误] 用户名/密码不能为空")
return
server = _clean(input(f"服务器 [{_default_server()}]: ")) or _default_server()
if not server.startswith(("http://", "https://")):
server = "http://" + server
code = run(["transfer.py", "--server", server, "--register", user, pw])
print("[完成]" if code == 0 else "[失败] 注册出错")
def auth_login() -> None:
"""登录账号"""
print("--- 登录 ---")
user = _clean(input("用户名: "))
pw = input("密码: ")
if not user or not pw:
print("[错误] 用户名/密码不能为空")
return
server = _clean(input(f"服务器 [{_default_server()}]: ")) or _default_server()
if not server.startswith(("http://", "https://")):
server = "http://" + server
code = run(["transfer.py", "--server", server, "--login", user, pw])
print("[完成]" if code == 0 else "[失败] 登录出错")
def auth_logout() -> None:
"""注销当前账号 (token 失效)"""
print("--- 注销 ---")
c = input("确认退出当前账号? (输入 yes): ").strip().lower()
if c != "yes":
print("[取消]")
return
server = _clean(input(f"服务器 [{_default_server()}]: ")) or _default_server()
if not server.startswith(("http://", "https://")):
server = "http://" + server
code = run(["transfer.py", "--server", server, "--logout"])
print("[完成] 已注销" if code == 0 else "[失败] 注销出错")
def regenerate_key() -> None:
"""重新生成密钥: 红色风险警告 + 二次确认 (旧密文全废)"""
print("--- 重新生成密钥 ---")
print()
print("\033[91m" + "═" * 52 + "\033[0m")
print("\033[91m ⚠ 危险操作警告\033[0m")
print()
print("\033[91m 重新生成密钥 = 丢弃当前密钥!\033[0m")
print("\033[91m · 当前密钥加密的已上传文件将永久无法解密\033[0m")
print("\033[91m · 建议先导出旧密钥备份 (菜单 7)\033[0m")
print("\033[91m · 执行后仅新上传的文件可用新密钥解密\033[0m")
print("\033[91m" + "═" * 52 + "\033[0m")
print()
c1 = input("是否了解风险并继续? (输入 yes 继续): ").strip().lower()
if c1 != "yes":
print("[取消] 未重新生成")
return
c2 = input("再次确认: 输入 CONFIRM 才执行: ").strip()
if c2 != "CONFIRM":
print("[取消] 未重新生成")
return
code = run(["config.py", "--regenerate-key", "--yes"])
print("[完成]" if code == 0 else "[失败] 重新生成出错")
def files_list() -> None:
"""ls: 列出服务端文件 + 空间配额"""
print("--- 文件列表 ---")
server = _clean(input(f"服务器 [{_default_server()}]: ")) or _default_server()
if not server.startswith(("http://", "https://")):
server = "http://" + server
code = run(["transfer.py", "--server", server, "--list"])
if code == 0:
run(["transfer.py", "--server", server, "--quota"])
if code != 0:
print("[失败] 获取列表出错")
def download() -> None:
"""下载: 先列文件, 再选序号, 本地解密还原"""
print("--- 下载文件 ---")
server = _clean(input(f"服务器 [{_default_server()}]: ")) or _default_server()
if not server.startswith(("http://", "https://")):
server = "http://" + server
code = run(["transfer.py", "--server", server, "--list"])
if code != 0:
print("[失败] 获取列表出错, 无法下载")
return
ref = _clean(input("序号 (列表里的编号, 如 3): "))
if not ref:
return
out = _clean(input("保存路径 [当前目录/服务端文件名]: "))
args = ["transfer.py", "--server", server, "--download", ref, "--decrypt"]
if out:
args += ["--out", out]
code = run(args)
print("[完成]" if code == 0 else "[失败] 下载出错")
def delete_file() -> None:
"""删除: 先列文件, 再选序号删除"""
print("--- 删除文件 ---")
server = _clean(input(f"服务器 [{_default_server()}]: ")) or _default_server()
if not server.startswith(("http://", "https://")):
server = "http://" + server
code = run(["transfer.py", "--server", server, "--list"])
if code != 0:
print("[失败] 获取列表出错, 无法删除")
return
ref = _clean(input("要删除的序号: "))
if not ref:
return
code = run(["transfer.py", "--server", server, "--delete", ref])
print("[完成]" if code == 0 else "[失败] 删除出错")
def _keyring_path() -> Path:
"""密钥库路径: 配置显式指定用指定, 否则系统默认 (SZ_KEYRING env/XDG)"""
try:
cfg = json.loads(_tui_config().read_text(encoding="utf-8"))
raw = cfg.get("crypto", {}).get("keyring_path", "")
if raw:
return Path(raw)
except (OSError, json.JSONDecodeError):
pass
env = os.environ.get("SZ_KEYRING")
if env:
return Path(env)
xdg = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
return Path(xdg) / "7z-encrypt" / "keyring.json"
def export_key() -> None:
"""密钥导出: 红色风险警告 + 二次确认 (防误操作)"""
print("--- 密钥导出 ---")
print()
print("\033[91m" + "═" * 52 + "\033[0m")
print("\033[91m ⚠ 危险操作警告\033[0m")
print()
print("\033[91m 密钥是解密你所有文件的唯一凭证!\033[0m")
print("\033[91m 导出后:\033[0m")
print("\033[91m · 任何人拿到此文件都能解密你服务器上的全部密文\033[0m")
print("\033[91m · 密钥泄露 = 数据完全暴露, 无法撤销\033[0m")
print("\033[91m · 请确保导出文件安全存放, 不要发到聊天/网盘/邮件\033[0m")
print("\033[91m · 用途: 备份 / 迁移到新设备 (新设备导入后可解密)\033[0m")
print("\033[91m" + "═" * 52 + "\033[0m")
print()
c1 = input("是否了解风险并继续? (输入 yes 继续): ").strip().lower()
if c1 != "yes":
print("[取消] 未导出")
return
c2 = input("再次确认: 输入 CONFIRM 才执行导出: ").strip()
if c2 != "CONFIRM":
print("[取消] 未导出")
return
keyring = _keyring_path()
if not keyring.exists():
print(f"[错误] 密钥文件不存在: {keyring}")
return
out = _clean(input(f"导出到路径 [~/keyring-export.json]: "))
out = out or str(Path.home() / "keyring-export.json")
shutil.copy2(keyring, out)
os.chmod(out, 0o600) # 密钥文件权限 600
print(f"[完成] 密钥已导出 -> {out} (权限 600)")
print("[提示] 新设备导入: 将该文件覆盖到客户端 config/keyring.json 即可")
def import_key() -> None:
"""密钥导入: 红色风险警告 + 二次确认 + 自动备份现有密钥库"""
print("--- 密钥导入 ---")
src = _clean(input("密钥文件路径: "))
if not src or not os.path.exists(src):
print("[错误] 文件不存在")
return
try:
with open(src, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict) or not data:
print("[错误] 不是有效的密钥库文件 (期望 {key_id: hex})")
return
for kid, val in data.items():
if not isinstance(val, str) or len(val) != 64:
print(f"[错误] 密钥 {kid} 格式非法 (期望 32 字节 hex)")
return
except (OSError, ValueError) as e:
print(f"[错误] 无法读取密钥文件: {e}")
return
keyring = _keyring_path()
print()
print("\033[91m" + "═" * 52 + "\033[0m")
print("\033[91m ⚠ 危险操作警告\033[0m")
print()
print("\033[91m 导入将替换当前密钥库!\033[0m")
print("\033[91m · 现有密钥加密的密文, 导入后可能无法解密\033[0m")
print("\033[91m · 除非你确认此文件就是你要用的密钥\033[0m")
print("\033[91m · 导入前会自动备份当前密钥 -> keyring.json.bak\033[0m")
print("\033[91m · 备份文件请勿删除, 出错可手动恢复\033[0m")
print("\033[91m" + "═" * 52 + "\033[0m")
print()
c1 = input("是否了解风险并继续? (输入 yes 继续): ").strip().lower()
if c1 != "yes":
print("[取消] 未导入")
return
c2 = input("再次确认: 输入 CONFIRM 才执行导入: ").strip()
if c2 != "CONFIRM":
print("[取消] 未导入")
return
# 备份现有密钥库 -> keyring.json.bak (防手滑可回滚)
if keyring.exists():
shutil.copy2(keyring, str(keyring) + ".bak")
print(f"[备份] 现有密钥已备份 -> config/keyring.json.bak")
shutil.copy2(src, keyring)
os.chmod(keyring, 0o600)
print(f"[完成] 密钥已导入 (来自 {src}, 权限 600)")
print("[提示] 导入后即可解密该密钥加密的密文; 出错可恢复 keyring.json.bak")
def _load_token() -> str:
"""从配置文件读 server.token (源码目录优先, 安装版走 XDG)"""
try:
cfg = json.loads(_tui_config().read_text(encoding="utf-8"))
return str(cfg.get("server", {}).get("token", ""))
except (OSError, json.JSONDecodeError):
return ""
def _check_login() -> str | None:
"""验证当前 token: 有效返回用户名, 否则 None (不登录不能操作文件)"""
server = _default_server()
token = _load_token()
if not server or not token:
return None
try:
req = urlrequest.Request(
server.rstrip("/") + "/api/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
with urlrequest.urlopen(req, timeout=10) as resp:
return str(json.loads(resp.read()).get("username", ""))
except Exception:
return None
def _require_login() -> bool:
"""文件操作前置校验: 未登录直接报错 (不登入不能进行文件操作)"""
if _check_login() is not None:
return True
print("\033[91m[错误] 未登录, 不能进行文件操作, 请先登录 (菜单 10)\033[0m")
return False
def main() -> int:
print("7z-encrypt 客户端 (Python TUI)")
print("安全文件传输 · 加密 / 分卷 / 上传")
while True:
user = _check_login()
print()
if user is None:
# 未登录: 文件操作全部不可用 (灰掉), 只有登录/注册/配置
print("=== 请先登录 ===")
print(f"服务器: {_default_server() or '(未配置)'}")
print(" 1. 登录")
print(" 2. 注册")
print(" 3. 配置服务器")
print(" q. 退出")
c = input("> ").strip().lower()
if c == "1":
auth_login()
elif c == "2":
auth_register()
elif c == "3":
config_edit()
elif c == "q":
print("再见")
return 0
else:
print("\033[91m[错误] 未登录, 只能进行 登录/注册/配置 操作\033[0m")
continue
# 已登录: 完整主菜单
print(f"主菜单 (当前用户: {user})")
print(" 1. 上传文件")
print(" 2. 任务续传")
print(" 3. 配置")
print(" 4. 文件列表 (ls)")
print(" 5. 下载文件")
print(" 6. 删除文件")
print(" 7. 密钥导出")
print(" 8. 密钥导入")
print(" 9. 注册账号")
print(" 10. 登录")
print(" 11. 注销")
print(" 12. 重新生成密钥")
print(" 13. 一键备份 (相册/文档/下载)")
print(" 14. 恢复备份")
print(" 15. 目录管理 (创建/列表/删除)")
print(" 16. 移动文件到目录")
print(" 17. 克隆文件")
print(" q. 退出")
choice = input("> ").strip().lower()
if choice == "1":
if not _require_login():
continue
upload()
elif choice == "2":
if not _require_login():
continue
tasks()
elif choice == "3":
config_show()
edit = input("修改服务器地址? (y/N): ").strip().lower()
if edit == "y":
config_edit()
elif choice == "4":
if not _require_login():
continue
files_list()
elif choice == "5":
if not _require_login():
continue
download()
elif choice == "6":
if not _require_login():
continue
delete_file()
elif choice == "7":
export_key()
elif choice == "8":
import_key()
elif choice == "9":
auth_register()
elif choice == "10":
auth_login()
elif choice == "11":
auth_logout()
continue # 注销后回未登录界面
elif choice == "12":
regenerate_key()
elif choice == "13":
backup_now()
elif choice == "14":
restore_backup()
elif choice == "15":
dirs_menu()
elif choice == "16":
move_file_menu()
elif choice == "17":
clone_file_menu()
elif choice == "q":
print("再见")
return 0
else:
print("[提示] 请选择 1-17 或 q")
def move_file_menu() -> None:
"""菜单 16: 移动文件到目录"""
print("\n=== 移动文件 ===")
run(["transfer.py", "--list"])
ref = _clean(input("文件序号/ID: ")).strip()
dst = _clean(input("目标目录 (0=根目录, 可先菜单15查看): ")).strip()
if not ref or not dst:
print("[失败] 序号和目标目录不能为空")
return
code = run(["transfer.py", "--move", ref, dst])
print("[完成]" if code == 0 else "[失败] 移动出错")
def clone_file_menu() -> None:
"""菜单 17: 克隆文件"""
print("\n=== 克隆文件 ===")
run(["transfer.py", "--list"])
ref = _clean(input("文件序号/ID: ")).strip()
dst = _clean(input("目标目录 (0=根目录, 可先菜单15查看): ")).strip()
if not ref or not dst:
print("[失败] 序号和目标目录不能为空")
return
code = run(["transfer.py", "--clone", ref, dst])
print("[完成]" if code == 0 else "[失败] 克隆出错")
def dirs_menu() -> None:
"""菜单 15: 目录管理 (创建/列表/删除)"""
while True:
print("\n=== 目录管理 ===")
print(" 1. 创建目录")
print(" 2. 目录列表")
print(" 3. 删除目录")
print(" q. 返回主菜单")
c = input("> ").strip().lower()
if c == "1":
name = _clean(input("目录名: ")).strip()
if not name:
print("[失败] 目录名不能为空")
continue
code = run(["transfer.py", "--dir-create", name])
print("[完成]" if code == 0 else "[失败] 创建目录出错")
elif c == "2":
run(["transfer.py", "--dir-list"])
elif c == "3":
did = _clean(input("要删除的目录序号 (dir_id): ")).strip()
if not did:
continue
code = run(["transfer.py", "--dir-delete", did])
print("[完成]" if code == 0 else "[失败] 删除目录出错")
elif c == "q":
return
def backup_now() -> None:
"""菜单 13: 一键备份 (相册/文档/下载/聊天记录/指定目录)"""
print("--- 一键备份 ---")
print(" 预设项: photos (相册) / documents (文档) / download (下载) / chat (聊天记录)")
print(" 或直接输入目录路径备份指定目录 (如 /home/lou/图片), 回车=全部")
val = _clean(input(" 备份项或目录 (回车=全部): ").strip())
if not val:
run(["backup.py"])
elif os.path.isdir(os.path.expanduser(val)):
run(["backup.py", "--dir", val])
else:
run(["backup.py"] + val.split())
def restore_backup() -> None:
"""菜单 14: 恢复备份"""
print("--- 恢复备份 ---")
print(" 备份项: photos/documents/download/chat 或自定义目录名 (--dir 备份的)")
name = _clean(input(" 备份项: ").strip())
if not name:
print("[提示] 已取消")
return
run(["backup.py", "restore", name])
if __name__ == "__main__":
try:
sys.exit(main())
except (KeyboardInterrupt, EOFError):
print("\n再见")
sys.exit(0)