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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user