feat: add wallet types, image upload, backup tabs, and manage.sh

Add mnemonic/key credentials with image upload, tabbed UI for add/query
and settings (auth/types/backup), daily backup to /root, and interactive
deploy script that updates via git pull only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 20:35:24 +08:00
parent 703fd912f8
commit 3e52b8eb87
11 changed files with 1081 additions and 421 deletions
+106 -10
View File
@@ -5,13 +5,17 @@
import re
import uuid
from functools import wraps
from pathlib import Path
from flask import Flask, jsonify, request, send_from_directory, session
import io
from flask import Flask, jsonify, request, send_file, send_from_directory, session
from werkzeug.utils import secure_filename
import backup as backup_mod
import env_config
from storage import (
BUILTIN_TYPES,
all_type_defs,
filter_records,
get_type_def,
load_records,
@@ -29,9 +33,14 @@ app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=1800,
MAX_CONTENT_LENGTH=16 * 1024 * 1024,
)
BASE_DIR = env_config.BASE_DIR
UPLOAD_DIR = BASE_DIR / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_IMAGE_EXT = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"})
PUBLIC_API = {"/api/auth/login", "/api/auth/status"}
@@ -94,6 +103,7 @@ def get_settings():
"builtin_types": BUILTIN_TYPES,
"custom_types": s.get("custom_types", []),
"username": env_config.get_auth_username(),
"backup": backup_mod.get_backup_status(),
}
)
@@ -130,19 +140,24 @@ def add_custom_type():
return jsonify({"error": "类型 ID 已存在"}), 400
if not fields:
return jsonify({"error": "至少添加一个字段"}), 400
allowed_types = {"text", "secret", "url", "email", "phone", "select", "textarea", "image"}
norm_fields = []
for f in fields:
key = (f.get("key") or "").strip().lower()
if not re.match(r"^[a-z][a-z0-9_]{0,31}$", key):
return jsonify({"error": f"无效字段 key: {key}"}), 400
norm_fields.append(
{
"key": key,
"label": (f.get("label") or key).strip(),
"type": f.get("type") or "text",
"required": bool(f.get("required")),
}
)
ftype = f.get("type") or "text"
if ftype not in allowed_types:
return jsonify({"error": f"无效字段类型: {ftype}"}), 400
entry = {
"key": key,
"label": (f.get("label") or key).strip(),
"type": ftype,
"required": bool(f.get("required")),
}
if f.get("secret"):
entry["secret"] = True
norm_fields.append(entry)
s = load_settings()
entry = {"id": type_id, "label": label, "builtin": False, "fields": norm_fields}
s["custom_types"].append(entry)
@@ -164,6 +179,82 @@ def delete_custom_type(type_id):
return jsonify({"ok": True})
@app.route("/api/upload", methods=["POST"])
@login_required
def upload_image():
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
ext = Path(f.filename).suffix.lower()
if ext not in ALLOWED_IMAGE_EXT:
return jsonify({"error": "仅支持 jpg/png/gif/webp/bmp"}), 400
name = f"{uuid.uuid4().hex}{ext}"
path = UPLOAD_DIR / name
f.save(path)
return jsonify({"path": name, "url": f"/api/uploads/{name}"}), 201
@app.route("/api/uploads/<filename>", methods=["GET"])
@login_required
def get_upload(filename):
safe = secure_filename(filename)
if not safe or safe != filename:
return jsonify({"error": "无效文件名"}), 400
path = UPLOAD_DIR / safe
if not path.exists() or not path.is_file():
return jsonify({"error": "文件不存在"}), 404
return send_from_directory(UPLOAD_DIR, safe)
@app.route("/api/backup/status", methods=["GET"])
@login_required
def backup_status():
return jsonify(backup_mod.get_backup_status())
@app.route("/api/backup/export", methods=["GET"])
@login_required
def backup_export():
data, filename = backup_mod.create_backup_bytes()
# 同时落盘一份到自动备份目录
try:
backup_mod.create_backup_archive()
except OSError:
pass
return send_file(
path_or_file=io.BytesIO(data),
mimetype="application/gzip",
as_attachment=True,
download_name=filename,
)
@app.route("/api/backup/now", methods=["POST"])
@login_required
def backup_now():
try:
path = backup_mod.create_backup_archive()
return jsonify({"ok": True, **backup_mod.get_backup_status(), "path": str(path)})
except OSError as e:
return jsonify({"error": f"备份失败: {e}"}), 500
@app.route("/api/backup/restore", methods=["POST"])
@login_required
def backup_restore():
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "请上传备份文件 (.tar.gz)"}), 400
if not f.filename.endswith(".tar.gz") and not f.filename.endswith(".tgz"):
return jsonify({"error": "备份文件须为 .tar.gz"}), 400
try:
backup_mod.restore_from_archive(f.stream)
env_config.reload_env()
return jsonify({"ok": True, "message": "恢复成功,建议刷新页面"})
except Exception as e:
return jsonify({"error": f"恢复失败: {e}"}), 400
@app.route("/api/credentials", methods=["GET"])
@login_required
def list_credentials():
@@ -177,6 +268,8 @@ def list_credentials():
for r in records
if r.get("type_id") == "exchange" and r.get("fields", {}).get("exchange") == ex
]
if q:
records = filter_records(records, q=q)
else:
records = filter_records(records, type_id=type_id or None, q=q or None)
return jsonify(records)
@@ -222,5 +315,8 @@ def accounts_delete_compat(account_id):
return delete_credential(account_id)
backup_mod.start_auto_backup_scheduler()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5200, debug=False)