最初版本
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from functools import wraps
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
abort,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from pymysql import MySQLError
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from chatwoot_guest import ChatwootGuestError, create_guest_conversation
|
||||
from chatwoot_proxy import proxy_chatwoot
|
||||
from config import CONFIG
|
||||
from maxkb_chatwoot_bridge import handle_chatwoot_webhook
|
||||
from repository import MappingError, PlatformRepository, decimal_string
|
||||
import threading
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
JSON_AS_ASCII=False,
|
||||
SECRET_KEY=CONFIG.secret_key,
|
||||
SESSION_COOKIE_NAME="api_interface_session",
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=CONFIG.session_cookie_secure,
|
||||
PERMANENT_SESSION_LIFETIME=28800,
|
||||
MAX_CONTENT_LENGTH=1024 * 1024,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if CONFIG.debug else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
|
||||
repo = PlatformRepository()
|
||||
|
||||
|
||||
def payload() -> dict[str, Any]:
|
||||
data: dict[str, Any] = {}
|
||||
body = request.get_json(silent=True)
|
||||
if isinstance(body, dict):
|
||||
data.update(body)
|
||||
data.update(request.form.to_dict())
|
||||
data.update(request.args.to_dict())
|
||||
return data
|
||||
|
||||
|
||||
def api_auth(view: Callable):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if CONFIG.api_key:
|
||||
provided = request.headers.get("X-API-Key", "")
|
||||
if not hmac.compare_digest(provided, CONFIG.api_key):
|
||||
return jsonify(
|
||||
{"error": "unauthorized", "message": "API Key 無效"}
|
||||
), 401
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def login_required(view: Callable):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not session.get("username"):
|
||||
return redirect(url_for("login"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def csrf_token() -> str:
|
||||
token = session.get("csrf_token")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session["csrf_token"] = token
|
||||
return str(token)
|
||||
|
||||
|
||||
def verify_csrf() -> None:
|
||||
received = str(request.form.get("csrf_token", ""))
|
||||
saved = str(session.get("csrf_token", ""))
|
||||
if not received or not saved or not hmac.compare_digest(received, saved):
|
||||
abort(403, description="CSRF 验证失败")
|
||||
|
||||
|
||||
def required_text(
|
||||
data: dict[str, Any],
|
||||
name: str,
|
||||
max_length: int = 191,
|
||||
) -> str:
|
||||
value = str(data.get(name, "")).strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} 為必填")
|
||||
if len(value) > max_length:
|
||||
raise ValueError(f"{name} 長度不可超過 {max_length}")
|
||||
return value
|
||||
|
||||
|
||||
def is_api_request() -> bool:
|
||||
return request.path == "/health" or request.path.startswith("/platform/")
|
||||
|
||||
|
||||
def update_member_session(account: dict[str, Any]) -> None:
|
||||
"""將目前會員資料同步到 Flask Session。"""
|
||||
session["username"] = account.get("username_clean") or account.get("username") or ""
|
||||
session["username_clean"] = account.get("username_clean") or ""
|
||||
session["raw_username"] = account.get("raw_username") or ""
|
||||
session["uid"] = account.get("uid")
|
||||
session["account_status"] = account.get("status") or "normal"
|
||||
session["locked_reason"] = account.get("locked_reason") or ""
|
||||
session["member_type"] = account.get("member_type") or ""
|
||||
session["vip_level"] = account.get("vip_level") or ""
|
||||
session["tier"] = account.get("tier") or ""
|
||||
|
||||
|
||||
def build_chatwoot_user() -> dict[str, Any]:
|
||||
"""建立傳給 Chatwoot 前端 SDK 的安全會員資料。
|
||||
|
||||
identifier 直接使用 username_clean,作為 Chatwoot 抓取會員名的關鍵標識。
|
||||
顯示名稱與自訂屬性同樣使用 username_clean。
|
||||
"""
|
||||
username = str(
|
||||
session.get("username_clean")
|
||||
or session.get("username")
|
||||
or ""
|
||||
).strip()
|
||||
uid = session.get("uid")
|
||||
|
||||
# Chatwoot 的 identifier 直接使用登入會員名。
|
||||
# 例如:yongle001,而不是 member-311。
|
||||
identifier = username if username else ""
|
||||
|
||||
identifier_hash = ""
|
||||
if identifier and CONFIG.chatwoot_identity_secret:
|
||||
identifier_hash = hmac.new(
|
||||
CONFIG.chatwoot_identity_secret.encode("utf-8"),
|
||||
identifier.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return {
|
||||
"is_authenticated": bool(identifier),
|
||||
"visitor_type": "member" if identifier else "guest",
|
||||
"identifier": identifier,
|
||||
"identifier_hash": identifier_hash,
|
||||
"name": username,
|
||||
"username": username,
|
||||
"uid": uid,
|
||||
"account_status": str(
|
||||
session.get("account_status", "")
|
||||
),
|
||||
"locked_reason": str(
|
||||
session.get("locked_reason", "")
|
||||
),
|
||||
"member_type": str(
|
||||
session.get("member_type", "")
|
||||
),
|
||||
"vip_level": str(
|
||||
session.get("vip_level", "")
|
||||
),
|
||||
"tier": str(
|
||||
session.get("tier", "")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_template_values():
|
||||
use_guest_widget = request.path.startswith("/guest-customer-service")
|
||||
guest_token = CONFIG.chatwoot_guest_website_token
|
||||
if use_guest_widget and guest_token:
|
||||
# 依访客实际访问的 host(IP 或 test.pubuli.com)加载 /cw,避免写死域名
|
||||
base_url = f"{request.scheme}://{request.host}/cw"
|
||||
else:
|
||||
base_url = CONFIG.chatwoot_base_url
|
||||
website_token = (
|
||||
guest_token
|
||||
if use_guest_widget and guest_token
|
||||
else CONFIG.chatwoot_website_token
|
||||
)
|
||||
chatwoot_config = {
|
||||
"enabled": bool(
|
||||
CONFIG.chatwoot_enabled
|
||||
and base_url
|
||||
and website_token
|
||||
),
|
||||
"base_url": base_url,
|
||||
"website_token": website_token,
|
||||
"debug": CONFIG.chatwoot_debug,
|
||||
"csrf_token": csrf_token(),
|
||||
"guest_new_path": "/guest-chat/new",
|
||||
}
|
||||
|
||||
return {
|
||||
"app_name": CONFIG.app_name,
|
||||
"csrf_token": csrf_token,
|
||||
"chatwoot_config": chatwoot_config,
|
||||
"chatwoot_user": build_chatwoot_user(),
|
||||
|
||||
# 只在登出後首次渲染未登入頁面時為 True。
|
||||
# 使用 pop() 確保該標記只消費一次。
|
||||
"chatwoot_guest_reset_required": bool(
|
||||
session.pop(
|
||||
"chatwoot_guest_reset_required",
|
||||
False,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
if session.get("username"):
|
||||
return redirect(url_for("home"))
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.get("/guest-customer-service")
|
||||
def guest_customer_service():
|
||||
"""遊客客服頁:不登入、不查會員資料,只載入匿名 Chatwoot。"""
|
||||
return render_template(
|
||||
"guest_customer_service.html",
|
||||
is_guest=True,
|
||||
)
|
||||
|
||||
|
||||
@app.route(
|
||||
"/cw/",
|
||||
defaults={"subpath": ""},
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
@app.route(
|
||||
"/cw/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_guest_proxy(subpath: str):
|
||||
"""把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。"""
|
||||
return proxy_chatwoot(subpath)
|
||||
|
||||
|
||||
@app.route(
|
||||
"/vite/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_vite_proxy(subpath: str):
|
||||
"""widget HTML 用絕對路徑 /vite/assets,需一併代理。"""
|
||||
return proxy_chatwoot(f"vite/{subpath}")
|
||||
|
||||
|
||||
@app.route(
|
||||
"/api/v1/widget/",
|
||||
defaults={"subpath": ""},
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
@app.route(
|
||||
"/api/v1/widget/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_widget_api_proxy(subpath: str):
|
||||
"""widget 會打同源 /api/v1/widget。"""
|
||||
suffix = f"api/v1/widget/{subpath}" if subpath else "api/v1/widget"
|
||||
return proxy_chatwoot(suffix)
|
||||
|
||||
|
||||
@app.route(
|
||||
"/rails/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_rails_proxy(subpath: str):
|
||||
"""歡迎圖走 Active Storage,widget 會打同源 /rails/active_storage。"""
|
||||
return proxy_chatwoot(f"rails/{subpath}")
|
||||
|
||||
|
||||
@app.post("/guest-chat/new")
|
||||
def guest_chat_new():
|
||||
"""為遊客預先建立 Chatwoot 對話,前端 setUser 後可略過「開始對話」。"""
|
||||
received = str(request.headers.get("X-CSRF-Token", ""))
|
||||
saved = str(session.get("csrf_token", ""))
|
||||
if not received or not saved or not hmac.compare_digest(received, saved):
|
||||
return jsonify(
|
||||
{"error": "csrf_failed", "message": "CSRF 验证失败"}
|
||||
), 403
|
||||
|
||||
try:
|
||||
created = create_guest_conversation()
|
||||
except ChatwootGuestError as exc:
|
||||
app.logger.exception("建立游客对话失败")
|
||||
return jsonify(
|
||||
{"error": "chatwoot_guest_failed", "message": str(exc)}
|
||||
), 502
|
||||
|
||||
identifier = created["identifier"]
|
||||
identifier_hash = ""
|
||||
if identifier and CONFIG.chatwoot_identity_secret:
|
||||
identifier_hash = hmac.new(
|
||||
CONFIG.chatwoot_identity_secret.encode("utf-8"),
|
||||
identifier.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"identifier_hash": identifier_hash,
|
||||
"name": created["name"],
|
||||
"source_id": created.get("source_id") or "",
|
||||
"widget_token": created.get("widget_token") or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/webhook/bot1/chatwoot")
|
||||
def chatwoot_maxkb_webhook():
|
||||
"""本機 Chatwoot Agent Bot → PAI MaxKB 橋接(對齊正式站 :6666 路徑)。"""
|
||||
body = request.get_json(silent=True)
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"ok": False, "error": "invalid_json"}), 400
|
||||
|
||||
# Chatwoot SafeFetch 讀超時約 20s;MaxKB 常更久,先快速 200 再背景回覆。
|
||||
def _run() -> None:
|
||||
try:
|
||||
result = handle_chatwoot_webhook(body)
|
||||
app.logger.info("Chatwoot→MaxKB 橋接完成: %s", result)
|
||||
except Exception:
|
||||
app.logger.exception("Chatwoot→MaxKB 橋接失敗")
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"ok": True, "accepted": True})
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if session.get("username"):
|
||||
return redirect(url_for("home"))
|
||||
|
||||
username = ""
|
||||
if request.method == "POST":
|
||||
verify_csrf()
|
||||
username = str(request.form.get("username", "")).strip()
|
||||
|
||||
if not username or len(username) > 191:
|
||||
flash("请输入有效的用户名", "error")
|
||||
else:
|
||||
try:
|
||||
account = repo.get_user_account(username)
|
||||
except Exception as exc:
|
||||
app.logger.exception("会员登录查询失败")
|
||||
flash(f"登录验证失败:{exc}", "error")
|
||||
else:
|
||||
if not account["is_exist"]:
|
||||
flash("该用户名不存在于 members 表", "error")
|
||||
else:
|
||||
session.clear()
|
||||
update_member_session(account)
|
||||
session.permanent = True
|
||||
csrf_token()
|
||||
return redirect(url_for("home"))
|
||||
|
||||
return render_template("login.html", username=username)
|
||||
|
||||
|
||||
@app.get("/home")
|
||||
@login_required
|
||||
def home():
|
||||
username = str(session["username"])
|
||||
warning = ""
|
||||
account = {
|
||||
"username": username,
|
||||
"username_clean": username,
|
||||
"raw_username": str(session.get("raw_username", username)),
|
||||
"status": str(session.get("account_status", "normal")),
|
||||
"locked_reason": str(session.get("locked_reason", "")),
|
||||
}
|
||||
|
||||
try:
|
||||
refreshed = repo.get_user_account(username)
|
||||
if refreshed["is_exist"]:
|
||||
account = refreshed
|
||||
update_member_session(refreshed)
|
||||
except Exception as exc:
|
||||
app.logger.exception("帳戶狀態刷新失敗")
|
||||
warning = str(exc)
|
||||
|
||||
games = [
|
||||
{"icon": "🎰", "name": "幸運拉霸", "category": "電子遊戲"},
|
||||
{"icon": "🃏", "name": "經典百家樂", "category": "真人遊戲"},
|
||||
{"icon": "⚽", "name": "體育競猜", "category": "體育遊戲"},
|
||||
{"icon": "🎲", "name": "骰寶大廳", "category": "桌面遊戲"},
|
||||
{"icon": "🐉", "name": "龍虎鬥", "category": "真人遊戲"},
|
||||
{"icon": "🚀", "name": "飛行挑戰", "category": "休閒遊戲"},
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"home.html",
|
||||
username=username,
|
||||
account=account,
|
||||
warning=warning,
|
||||
games=games,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/customer-service")
|
||||
@login_required
|
||||
def customer_service():
|
||||
username = str(session["username"])
|
||||
withdrawals: list[dict[str, Any]] = []
|
||||
deposits: list[dict[str, Any]] = []
|
||||
account: dict[str, Any] = {}
|
||||
error = ""
|
||||
|
||||
try:
|
||||
account = repo.get_user_account(username)
|
||||
if account.get("is_exist"):
|
||||
update_member_session(account)
|
||||
withdrawals = repo.get_withdraw_orders(username, unfinished_only=True)
|
||||
deposits = repo.get_deposit_orders(username)[:10]
|
||||
except Exception as exc:
|
||||
app.logger.exception("客服頁資料查詢失敗")
|
||||
error = str(exc)
|
||||
|
||||
return render_template(
|
||||
"customer_service.html",
|
||||
username=username,
|
||||
account=account,
|
||||
withdrawals=withdrawals,
|
||||
deposits=deposits,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
verify_csrf()
|
||||
|
||||
# 清除會員 Session 後,保留一個一次性標記。
|
||||
# 登出後首次載入未登入頁面時,前端會再次 reset Chatwoot,
|
||||
# 避免上一位會員身份殘留在匿名訪客頁面。
|
||||
session.clear()
|
||||
session["chatwoot_guest_reset_required"] = True
|
||||
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@app.get("/platform/health")
|
||||
def health():
|
||||
return jsonify(
|
||||
{
|
||||
"service": CONFIG.app_name,
|
||||
"status": "ok",
|
||||
"read_only_mode": CONFIG.read_only_mode,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/platform/get-user-account")
|
||||
@api_auth
|
||||
def get_user_account():
|
||||
username = required_text(payload(), "username")
|
||||
return jsonify(repo.get_user_account(username))
|
||||
|
||||
|
||||
@app.get("/platform/withdraw-orders")
|
||||
@api_auth
|
||||
def withdraw_orders():
|
||||
username = required_text(payload(), "username")
|
||||
return jsonify(repo.get_withdraw_orders(username, unfinished_only=False))
|
||||
|
||||
|
||||
@app.get("/platform/withdraw-orders-unfinish")
|
||||
@api_auth
|
||||
def withdraw_orders_unfinish():
|
||||
username = required_text(payload(), "username")
|
||||
return jsonify(repo.get_withdraw_orders(username, unfinished_only=True))
|
||||
|
||||
|
||||
@app.get("/platform/deposit-orders")
|
||||
@app.get("/platform/deposit-not-received")
|
||||
@api_auth
|
||||
def deposit_orders():
|
||||
data = payload()
|
||||
username = required_text(data, "username")
|
||||
|
||||
amount: Decimal | None = None
|
||||
raw_amount = str(data.get("amount", "")).strip()
|
||||
if raw_amount:
|
||||
try:
|
||||
amount = Decimal(raw_amount)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError("amount 格式不正確") from exc
|
||||
|
||||
created_at: datetime | None = None
|
||||
raw_created_at = str(data.get("created_at", "")).strip()
|
||||
if raw_created_at:
|
||||
try:
|
||||
created_at = datetime.fromisoformat(
|
||||
raw_created_at.replace("Z", "+00:00")
|
||||
)
|
||||
if created_at.tzinfo is not None:
|
||||
created_at = created_at.astimezone().replace(tzinfo=None)
|
||||
except ValueError as exc:
|
||||
raise ValueError("created_at 必須為 ISO-8601 日期時間") from exc
|
||||
|
||||
return jsonify(repo.get_deposit_orders(username, amount, created_at))
|
||||
|
||||
|
||||
@app.route("/platform/apply-activity", methods=["POST", "PUT"])
|
||||
@api_auth
|
||||
def apply_activity():
|
||||
data = payload()
|
||||
username = required_text(data, "username")
|
||||
activity_id = required_text(data, "activity_id", max_length=64)
|
||||
return jsonify(repo.apply_activity(username, activity_id))
|
||||
|
||||
|
||||
@app.route("/platform/fix-balance", methods=["POST", "PUT"])
|
||||
@api_auth
|
||||
def fix_balance():
|
||||
username = required_text(payload(), "username")
|
||||
account = repo.get_user_account(username)
|
||||
if not account["is_exist"]:
|
||||
return jsonify({"error": "not_found", "message": "找不到用戶"}), 404
|
||||
|
||||
balance = repo.get_balance(username)
|
||||
repo.log_balance_fix(username, balance)
|
||||
return jsonify(
|
||||
{
|
||||
"username": account["username_clean"],
|
||||
"balance": decimal_string(balance),
|
||||
"simulated": True,
|
||||
"message": "唯讀測試模式:僅回傳 members.balance,未修改資料庫",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(403)
|
||||
def handle_forbidden_error(exc: HTTPException):
|
||||
message = getattr(exc, "description", "拒絕存取")
|
||||
if is_api_request():
|
||||
return jsonify({"error": "forbidden", "message": message}), 403
|
||||
return render_template(
|
||||
"error.html", title="請求驗證失敗", message=message
|
||||
), 403
|
||||
|
||||
|
||||
@app.errorhandler(ValueError)
|
||||
def handle_value_error(exc: ValueError):
|
||||
if is_api_request():
|
||||
return jsonify({"error": "bad_request", "message": str(exc)}), 400
|
||||
return render_template(
|
||||
"error.html", title="請求錯誤", message=str(exc)
|
||||
), 400
|
||||
|
||||
|
||||
@app.errorhandler(MappingError)
|
||||
def handle_mapping_error(exc: MappingError):
|
||||
if is_api_request():
|
||||
return jsonify({"error": "mapping_error", "message": str(exc)}), 500
|
||||
return render_template(
|
||||
"error.html", title="資料映射錯誤", message=str(exc)
|
||||
), 500
|
||||
|
||||
|
||||
@app.errorhandler(MySQLError)
|
||||
def handle_mysql_error(exc: MySQLError):
|
||||
app.logger.exception("Database error")
|
||||
message = str(exc) if CONFIG.debug else "資料庫查詢失敗,請檢查連線與欄位設定"
|
||||
if is_api_request():
|
||||
return jsonify({"error": "database_error", "message": message}), 500
|
||||
return render_template(
|
||||
"error.html", title="資料庫錯誤", message=message
|
||||
), 500
|
||||
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(exc: HTTPException):
|
||||
status_code = int(exc.code or 500)
|
||||
message = str(exc.description or exc.name)
|
||||
if is_api_request():
|
||||
return jsonify(
|
||||
{
|
||||
"error": exc.name.lower().replace(" ", "_"),
|
||||
"message": message,
|
||||
}
|
||||
), status_code
|
||||
return render_template(
|
||||
"error.html", title=exc.name, message=message
|
||||
), status_code
|
||||
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_unexpected_error(exc: Exception):
|
||||
app.logger.exception("Unexpected error")
|
||||
if is_api_request():
|
||||
return jsonify(
|
||||
{"error": "internal_error", "message": "系統內部錯誤"}
|
||||
), 500
|
||||
return render_template(
|
||||
"error.html", title="系統錯誤", message="系統內部錯誤"
|
||||
), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.logger.info(
|
||||
"啟動 %s host=%s port=%s read_only=%s",
|
||||
CONFIG.app_name,
|
||||
CONFIG.host,
|
||||
CONFIG.port,
|
||||
CONFIG.read_only_mode,
|
||||
)
|
||||
app.run(host=CONFIG.host, port=CONFIG.port, debug=CONFIG.debug)
|
||||
Reference in New Issue
Block a user