""" 凭证保险库 — Flask 后端 登录凭据与加密主密钥在 .env | 数据加密存 data.json """ import re import uuid from functools import wraps from pathlib import Path 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, filter_records, get_type_def, load_records, load_settings, save_records, save_settings, validate_record_payload, ) env_config.ensure_env() app = Flask(__name__) app.secret_key = env_config.get_secret_key() 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"} def login_required(fn): @wraps(fn) def wrapper(*args, **kwargs): if not session.get("authenticated"): return jsonify({"error": "未登录"}), 401 return fn(*args, **kwargs) return wrapper @app.before_request def guard_api(): if not request.path.startswith("/api/"): return None if request.path in PUBLIC_API: return None if not session.get("authenticated"): return jsonify({"error": "未登录"}), 401 return None @app.route("/") def index(): return send_from_directory(BASE_DIR, "index.html") @app.route("/api/auth/status", methods=["GET"]) def auth_status(): return jsonify({"logged_in": bool(session.get("authenticated"))}) @app.route("/api/auth/login", methods=["POST"]) def auth_login(): body = request.get_json(silent=True) or {} username = (body.get("username") or "").strip() password = body.get("password") or "" if username != env_config.get_auth_username() or not env_config.verify_password(password): return jsonify({"error": "用户名或密码错误"}), 401 session["authenticated"] = True session.permanent = True return jsonify({"ok": True}) @app.route("/api/auth/logout", methods=["POST"]) @login_required def auth_logout(): session.clear() return jsonify({"ok": True}) @app.route("/api/settings", methods=["GET"]) @login_required def get_settings(): s = load_settings() return jsonify( { "builtin_types": BUILTIN_TYPES, "custom_types": s.get("custom_types", []), "username": env_config.get_auth_username(), "backup": backup_mod.get_backup_status(), } ) @app.route("/api/settings/auth", methods=["PUT"]) @login_required def update_settings_auth(): body = request.get_json(silent=True) or {} current = body.get("current_password") or "" if not env_config.verify_password(current): return jsonify({"error": "当前密码错误"}), 400 username = (body.get("username") or "").strip() new_pass = body.get("new_password") or "" if not username: return jsonify({"error": "用户名不能为空"}), 400 if len(new_pass) < 6: return jsonify({"error": "新密码至少 6 位"}), 400 env_config.update_auth(username, new_pass) return jsonify({"ok": True, "username": username}) @app.route("/api/settings/types", methods=["POST"]) @login_required def add_custom_type(): body = request.get_json(silent=True) or {} type_id = (body.get("id") or "").strip().lower() label = (body.get("label") or "").strip() fields = body.get("fields") or [] if not re.match(r"^[a-z][a-z0-9_]{1,31}$", type_id): return jsonify({"error": "类型 ID 需为小写字母开头,仅含 a-z0-9_"}), 400 if not label: return jsonify({"error": "显示名称不能为空"}), 400 if get_type_def(type_id): 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 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) save_settings(s) return jsonify(entry), 201 @app.route("/api/settings/types/", methods=["DELETE"]) @login_required def delete_custom_type(type_id): s = load_settings() before = len(s["custom_types"]) s["custom_types"] = [t for t in s["custom_types"] if t["id"] != type_id] if len(s["custom_types"]) == before: return jsonify({"error": "自定义类型不存在"}), 404 save_settings(s) records = [r for r in load_records() if r.get("type_id") != type_id] save_records(records) 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/", 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(): records = load_records() type_id = (request.args.get("type_id") or "").strip() q = (request.args.get("q") or "").strip() if type_id == "exchange" and request.args.get("exchange"): ex = request.args.get("exchange").strip().lower() records = [ r 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) @app.route("/api/credentials", methods=["POST"]) @login_required def create_credential(): body = request.get_json(silent=True) or {} record, err = validate_record_payload(body) if err: return jsonify({"error": err}), 400 record["id"] = str(uuid.uuid4()) records = load_records() records.append(record) save_records(records) return jsonify(record), 201 @app.route("/api/credentials/", methods=["DELETE"]) @login_required def delete_credential(record_id): records = load_records() new_records = [r for r in records if r.get("id") != record_id] if len(new_records) == len(records): return jsonify({"error": "记录不存在"}), 404 save_records(new_records) return jsonify({"ok": True}) # 兼容旧前端路径 @app.route("/api/accounts", methods=["GET", "POST"]) @login_required def accounts_compat(): if request.method == "GET": return list_credentials() return create_credential() @app.route("/api/accounts/", methods=["DELETE"]) @login_required 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)