""" Exchange credentials vault. encrypted_config stores Fernet ciphertext derived from SECRET_KEY (see app.utils.credential_crypto). """ import json import traceback import requests as rq from flask import Blueprint, g, jsonify, request from app.utils.auth import login_required from app.utils.credential_crypto import decrypt_credential_blob, encrypt_credential_blob from app.utils.db import get_db_connection from app.utils.logger import get_logger logger = get_logger(__name__) credentials_bp = Blueprint("credentials", __name__) def _api_key_hint(api_key: str) -> str: if not api_key: return "" s = str(api_key) if len(s) <= 8: return s[:2] + "***" return f"{s[:4]}...{s[-4:]}" @credentials_bp.route("/list", methods=["GET"]) @login_required def list_credentials(): """List all credentials for the current user.""" try: user_id = g.user_id with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at FROM qd_exchange_credentials WHERE user_id = %s ORDER BY id DESC """, (user_id,), ) rows = cur.fetchall() or [] cur.close() items = [] for row in rows: item = dict(row or {}) item["enable_demo_trading"] = False try: plain = decrypt_credential_blob(item.get("encrypted_config")) cfg = json.loads(plain) if plain else {} item["enable_demo_trading"] = bool(cfg.get("enable_demo_trading") or cfg.get("enableDemoTrading")) except Exception: item["enable_demo_trading"] = False item.pop("encrypted_config", None) items.append(item) return jsonify({"code": 1, "msg": "success", "data": {"items": items}}) except Exception as e: logger.error(f"list_credentials failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({"code": 0, "msg": str(e), "data": {"items": []}}), 500 CRYPTO_EXCHANGES = [ "binance", "okx", "bitget", "bybit", "coinbaseexchange", "kraken", "kucoin", "gate", "bitfinex", "deepcoin", "htx", ] def _egress_ipify(url: str) -> str: try: r = rq.get(url, timeout=8) if r.status_code != 200: return "" j = r.json() if not isinstance(j, dict): return "" return str(j.get("ip") or "").strip() except Exception: return "" @credentials_bp.route("/egress-ip", methods=["GET"]) @login_required def get_egress_ip(): """ Public egress IPv4/IPv6 of this API server (for exchange API key IP whitelist). Uses ipify's v4-only / v6-only endpoints so each family is detected independently. """ ipv4 = _egress_ipify("https://api4.ipify.org?format=json") ipv6 = _egress_ipify("https://api6.ipify.org?format=json") return jsonify( { "code": 1, "msg": "success", "data": { "ipv4": ipv4 or None, "ipv6": ipv6 or None, # Compatible with old frontends: IPv4 first, otherwise IPv6 "ip": ipv4 or ipv6 or None, }, } ) @credentials_bp.route("/create", methods=["POST"]) @login_required def create_credential(): """Create a new credential for the current user. Supports crypto exchanges, IBKR (US stocks) and MT5 (Forex). """ try: user_id = g.user_id data = request.get_json() or {} name = (data.get("name") or "").strip() exchange_id = (data.get("exchange_id") or "").strip().lower() if not exchange_id: return jsonify({"code": 0, "msg": "Missing exchange_id", "data": None}), 400 config = {"exchange_id": exchange_id} hint = "" if exchange_id == "ibkr": # Interactive Brokers (US stocks) config.update( { "ibkr_host": (data.get("ibkr_host") or "127.0.0.1").strip(), "ibkr_port": int(data.get("ibkr_port") or 7497), "ibkr_client_id": int(data.get("ibkr_client_id") or 1), "ibkr_account": (data.get("ibkr_account") or "").strip(), } ) hint = f"{config['ibkr_host']}:{config['ibkr_port']}" elif exchange_id == "mt5": # MetaTrader 5 (Forex) mt5_server = (data.get("mt5_server") or "").strip() mt5_login = str(data.get("mt5_login") or "").strip() mt5_password = (data.get("mt5_password") or "").strip() if not mt5_server or not mt5_login or not mt5_password: return jsonify({"code": 0, "msg": "Missing mt5_server/mt5_login/mt5_password", "data": None}), 400 config.update( { "mt5_server": mt5_server, "mt5_login": mt5_login, "mt5_password": mt5_password, "mt5_terminal_path": (data.get("mt5_terminal_path") or "").strip(), } ) hint = f"{mt5_server}/{mt5_login}" elif exchange_id in CRYPTO_EXCHANGES: # Crypto exchanges api_key = (data.get("api_key") or "").strip() secret_key = (data.get("secret_key") or "").strip() if not api_key or not secret_key: return jsonify({"code": 0, "msg": "Missing api_key/secret_key", "data": None}), 400 config.update( { "api_key": api_key, "secret_key": secret_key, "passphrase": (data.get("passphrase") or "").strip(), "enable_demo_trading": bool(data.get("enable_demo_trading", False)), } ) hint = _api_key_hint(api_key) else: return jsonify({"code": 0, "msg": f"Unsupported exchange: {exchange_id}", "data": None}), 400 plaintext_config = json.dumps(config, ensure_ascii=False) stored_blob = encrypt_credential_blob(plaintext_config) with get_db_connection() as db: cur = db.cursor() cur.execute( """ INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at) VALUES (%s, %s, %s, %s, %s, NOW(), NOW()) RETURNING id """, (user_id, name, exchange_id, hint, stored_blob), ) row = cur.fetchone() new_id = (row or {}).get("id") db.commit() cur.close() return jsonify({"code": 1, "msg": "success", "data": {"id": new_id}}) except Exception as e: logger.error(f"create_credential failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @credentials_bp.route("/delete", methods=["DELETE"]) @login_required def delete_credential(): """Delete a credential for the current user.""" try: user_id = g.user_id cred_id = request.args.get("id", type=int) if not cred_id: return jsonify({"code": 0, "msg": "Missing id", "data": None}), 400 with get_db_connection() as db: cur = db.cursor() cur.execute("DELETE FROM qd_exchange_credentials WHERE id = %s AND user_id = %s", (cred_id, user_id)) db.commit() cur.close() return jsonify({"code": 1, "msg": "success", "data": None}) except Exception as e: logger.error(f"delete_credential failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({"code": 0, "msg": str(e), "data": None}), 500 @credentials_bp.route("/get", methods=["GET"]) @login_required def get_credential(): """ Return decrypted credential for form auto-fill. """ try: user_id = g.user_id cred_id = request.args.get("id", type=int) if not cred_id: return jsonify({"code": 0, "msg": "Missing id", "data": None}), 400 with get_db_connection() as db: cur = db.cursor() cur.execute( """ SELECT id, user_id, name, exchange_id, encrypted_config, api_key_hint, created_at, updated_at FROM qd_exchange_credentials WHERE id = %s AND user_id = %s """, (cred_id, user_id), ) row = cur.fetchone() cur.close() if not row: return jsonify({"code": 0, "msg": "Not found", "data": None}), 404 raw = row.get("encrypted_config") plain = decrypt_credential_blob(raw) decrypted = json.loads(plain) if plain else {} # Ensure exchange_id is present decrypted["exchange_id"] = row.get("exchange_id") or decrypted.get("exchange_id") return jsonify( { "code": 1, "msg": "success", "data": { "id": row.get("id"), "name": row.get("name"), "exchange_id": row.get("exchange_id"), "api_key_hint": row.get("api_key_hint"), "config": decrypted, }, } ) except Exception as e: logger.error(f"get_credential failed: {str(e)}") logger.error(traceback.format_exc()) return jsonify({"code": 0, "msg": str(e), "data": None}), 500