1108 lines
42 KiB
Python
1108 lines
42 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""公開系統頁面與健康檢查路由。
|
||
|
||
此 blueprint 無 url_prefix,保留外部監控與既有前端使用的公開 URL。
|
||
"""
|
||
|
||
import os
|
||
import hmac
|
||
import mimetypes
|
||
import posixpath
|
||
import re
|
||
import zipfile
|
||
from datetime import datetime, timezone, timedelta
|
||
from urllib.parse import quote
|
||
|
||
from flask import Blueprint, Response, jsonify, render_template, request, send_from_directory, session, url_for
|
||
import requests
|
||
from sqlalchemy import text
|
||
|
||
from auth import login_required
|
||
from config import (
|
||
BASE_DIR,
|
||
DATABASE_TYPE,
|
||
SYSTEM_VERSION,
|
||
WEBCRUMBS_ASSET_UPSTREAM_URL,
|
||
WEBCRUMBS_ENABLED,
|
||
)
|
||
from database.manager import DatabaseManager
|
||
from database.models import Product, PriceRecord
|
||
from services.external_tool_payload_service import (
|
||
build_external_tool_payload,
|
||
build_webcrumbs_seed_data,
|
||
)
|
||
from services.json_storage import load_categories
|
||
from services.logger_manager import SystemLogger
|
||
from utils.security import safe_join
|
||
|
||
|
||
system_public_bp = Blueprint('system_public', __name__)
|
||
|
||
sys_log = SystemLogger("SystemPublicRoutes").get_logger()
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||
public_url = os.getenv('PUBLIC_URL', '服務啟動中...')
|
||
STATIC_DIR = os.path.join(BASE_DIR, 'web/static')
|
||
WEBCRUMBS_ASSET_ALLOWED_PREFIXES = ('loader/', 'plugins/', 'demo/')
|
||
WEBCRUMBS_COMPATIBLE_LOADER_PATH = 'loader/webcrumbs-compatible-loader.js'
|
||
WEBCRUMBS_FALLBACK_LOADER = """
|
||
(() => {
|
||
const state = { status: 'fallback', reason: 'upstream_unavailable' };
|
||
window.WebcrumbsRuntime = Object.assign(window.WebcrumbsRuntime || {}, state);
|
||
if (!customElements.get('stock-platform-plugin')) {
|
||
customElements.define('stock-platform-plugin', class extends HTMLElement {
|
||
connectedCallback() {
|
||
if (this.dataset.webcrumbsFallbackRendered === '1') return;
|
||
this.dataset.webcrumbsFallbackRendered = '1';
|
||
const uri = this.getAttribute('uri') || '';
|
||
const box = document.createElement('div');
|
||
box.setAttribute('role', 'status');
|
||
box.style.cssText = 'border:1px solid rgba(42,37,32,.14);border-radius:8px;padding:12px;background:#fffaf0;color:#3b332b;font:600 13px/1.5 system-ui,sans-serif;';
|
||
const title = document.createElement('strong');
|
||
title.textContent = 'Webcrumbs runtime temporarily offline';
|
||
const detail = document.createElement('div');
|
||
detail.textContent = uri ? `Plugin waiting for runtime: ${uri}` : 'Plugin waiting for runtime.';
|
||
box.append(title, detail);
|
||
this.replaceChildren(box);
|
||
}
|
||
});
|
||
}
|
||
window.dispatchEvent(new CustomEvent('webcrumbs:runtime-fallback', { detail: state }));
|
||
})();
|
||
""".strip()
|
||
|
||
_PUBLIC_LOG_REPLACEMENTS = (
|
||
('OpenClawBot', 'AI 自動化服務'),
|
||
('OpenClaw', 'AI 架構檢查'),
|
||
('Hermes', 'AI 掃描服務'),
|
||
('NemoTron', 'AI 派工服務'),
|
||
('Ollama', 'AI 模型服務'),
|
||
('ollama', 'AI 模型服務'),
|
||
('fallback', '備援'),
|
||
('Fallback', '備援'),
|
||
('MCP', '工具服務'),
|
||
('RAG', '知識檢索'),
|
||
('autoheal_id_ed25519', '部署金鑰(已隱藏)'),
|
||
('id_ed25519', '部署金鑰(已隱藏)'),
|
||
('google_token.json', '雲端授權檔'),
|
||
)
|
||
_PUBLIC_LOG_RAW_ERROR_MARKERS = (
|
||
'httpconnectionpool',
|
||
'all 3 hosts failed',
|
||
'multimodal data provided',
|
||
'could not locate runnable browser',
|
||
'traceback',
|
||
'secret',
|
||
'api_key',
|
||
)
|
||
|
||
|
||
def _sanitize_public_log_line(line):
|
||
text = str(line or '').rstrip('\n')
|
||
if not text:
|
||
return ''
|
||
prefix = ''
|
||
match = re.match(r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:,\d+)?)\s+(\w+)\s*(.*)$', text)
|
||
if match:
|
||
prefix = f"{match.group(1)} {match.group(2)} "
|
||
body = match.group(3)
|
||
else:
|
||
body = text
|
||
lowered = body.lower()
|
||
if any(marker in lowered for marker in _PUBLIC_LOG_RAW_ERROR_MARKERS):
|
||
body = '內部服務暫時異常,已保留完整診斷於伺服器端。'
|
||
for raw, label in _PUBLIC_LOG_REPLACEMENTS:
|
||
body = body.replace(raw, label)
|
||
body = re.sub(r'\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b', '內部主機', body)
|
||
body = re.sub(
|
||
r'(?i)(?:/[\w.\-~]+)+/(?:[^/\s]*(?:token|secret|key|credential|id_ed25519)[^/\s]*)',
|
||
'敏感設定路徑已隱藏',
|
||
body,
|
||
)
|
||
if len(body) > 260:
|
||
body = body[:260].rstrip() + '…'
|
||
return prefix + body
|
||
|
||
|
||
def _sanitize_public_logs(lines):
|
||
return '\n'.join(_sanitize_public_log_line(line) for line in lines if str(line or '').strip())
|
||
|
||
|
||
def _has_sensitive_webcrumbs_access():
|
||
if session.get('logged_in'):
|
||
return True
|
||
internal_key = os.getenv('INTERNAL_API_KEY', '').strip()
|
||
provided = request.headers.get('X-Internal-Key', '').strip()
|
||
return bool(internal_key and provided and hmac.compare_digest(provided, internal_key))
|
||
|
||
|
||
@system_public_bp.route('/favicon.ico')
|
||
def favicon():
|
||
"""使用既有品牌圖示回應瀏覽器預設 favicon 探測,避免全站 404 噪音。"""
|
||
return send_from_directory(
|
||
os.path.join(STATIC_DIR, 'images'),
|
||
'logo_circle.svg',
|
||
mimetype='image/svg+xml',
|
||
max_age=604800,
|
||
)
|
||
|
||
|
||
@system_public_bp.route('/health')
|
||
def health_check():
|
||
"""健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用"""
|
||
try:
|
||
return jsonify({
|
||
'status': 'healthy',
|
||
'database': DATABASE_TYPE,
|
||
'version': SYSTEM_VERSION
|
||
}), 200
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'unhealthy',
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
@system_public_bp.route('/metabase')
|
||
@system_public_bp.route('/metabase/')
|
||
@login_required
|
||
def metabase_status():
|
||
"""Internal status page for the BI entrypoint when the public proxy is not attached."""
|
||
return render_template(
|
||
'external_tool_status.html',
|
||
active_page='metabase',
|
||
system_version=SYSTEM_VERSION,
|
||
tool=build_external_tool_payload('metabase'),
|
||
)
|
||
|
||
|
||
@system_public_bp.route('/grist')
|
||
@system_public_bp.route('/grist/')
|
||
@login_required
|
||
def grist_status():
|
||
"""Internal status page for the data collaboration entrypoint."""
|
||
return render_template(
|
||
'external_tool_status.html',
|
||
active_page='grist',
|
||
system_version=SYSTEM_VERSION,
|
||
tool=build_external_tool_payload('grist'),
|
||
)
|
||
|
||
|
||
@system_public_bp.route('/webcrumbs')
|
||
@system_public_bp.route('/webcrumbs/')
|
||
@login_required
|
||
def webcrumbs_status():
|
||
"""Internal status page for the shared Webcrumbs microfrontend runtime."""
|
||
return render_template(
|
||
'external_tool_status.html',
|
||
active_page='webcrumbs',
|
||
system_version=SYSTEM_VERSION,
|
||
tool=build_external_tool_payload(
|
||
'webcrumbs',
|
||
include_host_data=_has_sensitive_webcrumbs_access(),
|
||
),
|
||
)
|
||
|
||
|
||
@system_public_bp.route('/api/webcrumbs/marketplace-host-data')
|
||
@login_required
|
||
def webcrumbs_marketplace_host_data_api():
|
||
"""Read-only host data contract for Webcrumbs shared-ui plugins."""
|
||
if not _has_sensitive_webcrumbs_access():
|
||
return jsonify({
|
||
'success': False,
|
||
'error': 'auth_required',
|
||
'message': 'MOMO/PChome host data requires a logged-in session or X-Internal-Key.',
|
||
'boundary': {
|
||
'auth_required': True,
|
||
'writes_database': False,
|
||
'calls_llm': False,
|
||
'fetches_external': False,
|
||
},
|
||
}), 401
|
||
|
||
limit = request.args.get('limit', 5, type=int) or 5
|
||
limit = max(1, min(limit, 8))
|
||
payload = build_webcrumbs_seed_data(limit=limit)
|
||
return jsonify({
|
||
'success': True,
|
||
'data': payload,
|
||
'metadata': payload.get('metadata') or {},
|
||
'boundary': {
|
||
'auth_required': True,
|
||
'writes_database': False,
|
||
'calls_llm': False,
|
||
'fetches_external': False,
|
||
'allowed_match_contract': 'exact/total_price/price_alert_exact',
|
||
},
|
||
})
|
||
|
||
|
||
def _normalize_webcrumbs_asset_path(asset_path):
|
||
candidate = (asset_path or '').strip().replace('\\', '/')
|
||
normalized = posixpath.normpath(candidate).lstrip('/')
|
||
if normalized in {'', '.'} or normalized != candidate.lstrip('/'):
|
||
return ''
|
||
if not normalized.startswith(WEBCRUMBS_ASSET_ALLOWED_PREFIXES):
|
||
return ''
|
||
return normalized
|
||
|
||
|
||
def _webcrumbs_fallback_loader_response(reason):
|
||
response = Response(WEBCRUMBS_FALLBACK_LOADER, status=200, mimetype='application/javascript')
|
||
response.headers['Cache-Control'] = 'no-store'
|
||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||
response.headers['X-Webcrumbs-Fallback'] = reason
|
||
return response
|
||
|
||
|
||
@system_public_bp.route('/webcrumbs-assets/<path:asset_path>')
|
||
def webcrumbs_asset_proxy(asset_path):
|
||
"""Serve allowlisted shared-ui assets through momo-pro's own origin."""
|
||
normalized_path = _normalize_webcrumbs_asset_path(asset_path)
|
||
if not normalized_path or not WEBCRUMBS_ENABLED:
|
||
return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')
|
||
if not WEBCRUMBS_ASSET_UPSTREAM_URL:
|
||
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
|
||
return _webcrumbs_fallback_loader_response('missing-upstream')
|
||
return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')
|
||
|
||
upstream_url = f"{WEBCRUMBS_ASSET_UPSTREAM_URL}/{quote(normalized_path, safe='/@._-')}"
|
||
try:
|
||
upstream_response = requests.get(upstream_url, timeout=(2, 8))
|
||
except requests.RequestException as exc:
|
||
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
|
||
sys_log.info("[Webcrumbs] Loader upstream unavailable; serving local fallback")
|
||
return _webcrumbs_fallback_loader_response('upstream-unavailable')
|
||
sys_log.warning(f"[Webcrumbs] Asset proxy failed: {normalized_path} -> {exc}")
|
||
return Response('Webcrumbs asset upstream unavailable', status=502, mimetype='text/plain')
|
||
|
||
if upstream_response.status_code >= 400:
|
||
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
|
||
sys_log.info(
|
||
"[Webcrumbs] Loader upstream returned %s; serving local fallback",
|
||
upstream_response.status_code,
|
||
)
|
||
return _webcrumbs_fallback_loader_response(f'upstream-{upstream_response.status_code}')
|
||
sys_log.warning(f"[Webcrumbs] Asset proxy returned {upstream_response.status_code}: {normalized_path}")
|
||
return Response('Webcrumbs asset upstream returned error', status=upstream_response.status_code, mimetype='text/plain')
|
||
|
||
content_type = upstream_response.headers.get('Content-Type')
|
||
if not content_type:
|
||
content_type = mimetypes.guess_type(normalized_path)[0] or 'application/octet-stream'
|
||
response = Response(upstream_response.content, status=upstream_response.status_code)
|
||
response.headers['Content-Type'] = content_type
|
||
response.headers['Cache-Control'] = 'public, max-age=300'
|
||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||
return response
|
||
|
||
|
||
@system_public_bp.route('/metrics')
|
||
def prometheus_metrics():
|
||
"""Prometheus 指標端點 - 供 Prometheus 抓取監控資料"""
|
||
try:
|
||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Gauge, CollectorRegistry
|
||
from services.ai_automation_metrics import snapshot as ai_metrics_snapshot
|
||
|
||
registry = CollectorRegistry()
|
||
|
||
app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry)
|
||
app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1)
|
||
|
||
app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry)
|
||
db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry)
|
||
|
||
try:
|
||
db = DatabaseManager()
|
||
with db.engine.connect() as conn:
|
||
conn.execute(text("SELECT 1"))
|
||
db_status.set(1)
|
||
app_health.set(1)
|
||
except Exception:
|
||
db_status.set(0)
|
||
app_health.set(0)
|
||
|
||
session = None
|
||
try:
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
_set_database_record_counts(registry, Gauge, session)
|
||
except Exception as e:
|
||
sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}")
|
||
finally:
|
||
if session is not None:
|
||
session.close()
|
||
|
||
try:
|
||
_register_ai_automation_metrics(registry, Gauge, ai_metrics_snapshot())
|
||
except Exception as e:
|
||
sys_log.warning(f"[Metrics] 無法取得 AI 自動化指標: {e}")
|
||
|
||
try:
|
||
from services.ai_automation_smoke_service import build_scheduled_automation_health_summary
|
||
|
||
_register_ai_automation_health_summary_metrics(
|
||
registry,
|
||
Gauge,
|
||
build_scheduled_automation_health_summary(
|
||
history_limit=50,
|
||
include_current_smoke=False,
|
||
),
|
||
)
|
||
except Exception as e:
|
||
sys_log.warning(f"[Metrics] 無法取得 AI 自動化健康摘要指標: {e}")
|
||
|
||
return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST)
|
||
|
||
except ImportError:
|
||
metrics_text = f"""# HELP momo_app_health 應用程式健康狀態
|
||
# TYPE momo_app_health gauge
|
||
momo_app_health 1
|
||
# HELP momo_app_info 應用程式資訊
|
||
# TYPE momo_app_info gauge
|
||
momo_app_info{{version="{SYSTEM_VERSION}",database_type="{DATABASE_TYPE}"}} 1
|
||
"""
|
||
return Response(metrics_text, mimetype='text/plain; charset=utf-8')
|
||
except Exception as e:
|
||
sys_log.error(f"[Metrics] 指標生成錯誤: {e}")
|
||
return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500
|
||
|
||
|
||
def _labels_to_dict(labels):
|
||
return dict(labels)
|
||
|
||
|
||
def _set_database_record_counts(registry, gauge_cls, session):
|
||
"""Register DB counts without selecting drift-prone sales columns."""
|
||
product_count = gauge_cls('momo_products_total', '商品總數', registry=registry)
|
||
product_count.set(session.query(Product).count())
|
||
|
||
price_record_count = gauge_cls('momo_price_records_total', '價格記錄總數', registry=registry)
|
||
price_record_count.set(session.query(PriceRecord).count())
|
||
|
||
# V-Fix: realtime_sales_monthly 線上欄位曾與 ORM 不同步,metrics 只需要總筆數。
|
||
sales_count = gauge_cls('momo_sales_records_total', '業績資料總數', registry=registry)
|
||
sales_total = session.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar() or 0
|
||
sales_count.set(sales_total)
|
||
|
||
|
||
def _register_ai_automation_metrics(registry, gauge_cls, metrics_snapshot):
|
||
"""Export dependency-free AI metrics into a per-request Prometheus registry."""
|
||
gauges = {}
|
||
|
||
def get_gauge(name, help_text, label_names):
|
||
if name not in gauges:
|
||
gauges[name] = gauge_cls(name, help_text, label_names, registry=registry)
|
||
return gauges[name]
|
||
|
||
definitions = {
|
||
"event_router_dispatch_total": (
|
||
"momo_ai_event_router_dispatch_total",
|
||
"EventRouter dispatch count",
|
||
["event_type", "outcome", "tier"],
|
||
),
|
||
"event_router_safe_action_total": (
|
||
"momo_ai_event_router_safe_action_total",
|
||
"EventRouter L2 safe action count",
|
||
["action", "status"],
|
||
),
|
||
"event_router_replay_total": (
|
||
"momo_ai_event_router_replay_total",
|
||
"EventRouter queued Telegram replay count",
|
||
["status"],
|
||
),
|
||
"autoheal_action_total": (
|
||
"momo_ai_autoheal_action_total",
|
||
"AutoHeal action count",
|
||
["action", "error_type", "result"],
|
||
),
|
||
}
|
||
|
||
counter_samples = {
|
||
("event_router_dispatch_total", (
|
||
("event_type", "baseline"),
|
||
("outcome", "none"),
|
||
("tier", "baseline"),
|
||
)): 0,
|
||
("event_router_safe_action_total", (
|
||
("action", "baseline"),
|
||
("status", "none"),
|
||
)): 0,
|
||
("event_router_replay_total", (
|
||
("status", "none"),
|
||
)): 0,
|
||
("autoheal_action_total", (
|
||
("action", "baseline"),
|
||
("error_type", "none"),
|
||
("result", "none"),
|
||
)): 0,
|
||
}
|
||
counter_samples.update(metrics_snapshot.get("counters", {}))
|
||
|
||
for (metric, labels), value in counter_samples.items():
|
||
if metric not in definitions:
|
||
continue
|
||
name, help_text, label_names = definitions[metric]
|
||
gauge = get_gauge(name, help_text, label_names)
|
||
label_values = _labels_to_dict(labels)
|
||
gauge.labels(**{name: label_values.get(name, "unknown") for name in label_names}).set(value)
|
||
|
||
latency_defs = {
|
||
"event_router_latency_ms": (
|
||
"momo_ai_event_router_latency_ms",
|
||
"EventRouter dispatch latency in milliseconds",
|
||
["event_type", "tier"],
|
||
),
|
||
"autoheal_duration_ms": (
|
||
"momo_ai_autoheal_duration_ms",
|
||
"AutoHeal action duration in milliseconds",
|
||
["action", "error_type"],
|
||
),
|
||
}
|
||
|
||
latency_samples = {
|
||
("event_router_latency_ms", (
|
||
("event_type", "baseline"),
|
||
("tier", "baseline"),
|
||
)): {"count": 0, "sum": 0, "max": 0},
|
||
("autoheal_duration_ms", (
|
||
("action", "baseline"),
|
||
("error_type", "none"),
|
||
)): {"count": 0, "sum": 0, "max": 0},
|
||
}
|
||
latency_samples.update(metrics_snapshot.get("latency", {}))
|
||
|
||
for (metric, labels), values in latency_samples.items():
|
||
if metric not in latency_defs:
|
||
continue
|
||
name, help_text, label_names = latency_defs[metric]
|
||
label_values = _labels_to_dict(labels)
|
||
for suffix in ("count", "sum", "max"):
|
||
gauge = get_gauge(f"{name}_{suffix}", f"{help_text} ({suffix})", label_names)
|
||
gauge.labels(**{name: label_values.get(name, "unknown") for name in label_names}).set(values.get(suffix, 0))
|
||
|
||
|
||
def _register_ai_automation_health_summary_metrics(registry, gauge_cls, health_summary):
|
||
"""Export scheduled AI automation health summary into Prometheus gauges."""
|
||
summary = health_summary.get("summary") or {}
|
||
status_counts = {
|
||
"ok": int(summary.get("ok") or 0),
|
||
"warning": int(summary.get("warning") or 0),
|
||
"critical": int(summary.get("critical") or 0),
|
||
"total": int(summary.get("total") or 0),
|
||
}
|
||
status_gauge = gauge_cls(
|
||
"momo_ai_automation_scheduled_health_summary_total",
|
||
"AI automation scheduled health family counts",
|
||
["status"],
|
||
registry=registry,
|
||
)
|
||
for status, value in status_counts.items():
|
||
status_gauge.labels(status=status).set(value)
|
||
|
||
family_status = gauge_cls(
|
||
"momo_ai_automation_scheduled_health_family_status",
|
||
"AI automation scheduled health family status. Value is 1 for the current status label.",
|
||
["family", "status"],
|
||
registry=registry,
|
||
)
|
||
for family in health_summary.get("families", []) or []:
|
||
family_key = str(family.get("key") or "unknown")[:80]
|
||
status = str(family.get("status") or "unknown")[:40]
|
||
family_status.labels(family=family_key, status=status).set(1)
|
||
|
||
human_gate_gauge = gauge_cls(
|
||
"momo_ai_automation_scheduled_health_primary_human_gate_count",
|
||
"AI automation scheduled health primary human gate count",
|
||
registry=registry,
|
||
)
|
||
human_gate_gauge.set(int(summary.get("primary_human_gate_count") or 0))
|
||
|
||
write_gauge = gauge_cls(
|
||
"momo_ai_automation_scheduled_health_writes_database_count",
|
||
"AI automation scheduled health database write count",
|
||
registry=registry,
|
||
)
|
||
write_gauge.set(int(summary.get("writes_database_count") or 0))
|
||
|
||
|
||
@system_public_bp.route('/settings')
|
||
def settings():
|
||
"""分類設定頁面"""
|
||
categories = load_categories()
|
||
return render_template('settings.html',
|
||
categories=categories,
|
||
public_url=public_url,
|
||
system_version=SYSTEM_VERSION)
|
||
|
||
|
||
@system_public_bp.route('/system_settings')
|
||
def system_settings_page():
|
||
"""系統設定與匯入頁面"""
|
||
return render_template(
|
||
'system_settings.html',
|
||
system_version=SYSTEM_VERSION,
|
||
active_page='system_settings',
|
||
)
|
||
|
||
|
||
@system_public_bp.route('/ai_automation_smoke')
|
||
@login_required
|
||
def ai_automation_smoke_page():
|
||
"""AI 自動化閉環 smoke dashboard."""
|
||
return render_template(
|
||
'ai_automation_smoke.html',
|
||
system_version=SYSTEM_VERSION,
|
||
active_page='ai_automation_smoke',
|
||
)
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/smoke')
|
||
@login_required
|
||
def ai_automation_smoke_api():
|
||
"""Read-only smoke status for the four-agent AI automation control plane."""
|
||
from services.ai_automation_smoke_service import collect_ai_automation_smoke
|
||
return jsonify(collect_ai_automation_smoke())
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/scheduled-health-summary')
|
||
@login_required
|
||
def ai_automation_scheduled_health_summary_api():
|
||
"""Read-only scheduled health summary for AI automation checks."""
|
||
from services.ai_automation_smoke_service import build_scheduled_automation_health_summary
|
||
|
||
include_current = str(request.args.get('include_current_smoke') or '').strip().lower() in {'1', 'true', 'yes'}
|
||
history_limit = request.args.get('history_limit', 200, type=int)
|
||
freshness_hours = request.args.get('freshness_max_age_hours', 26, type=int)
|
||
return jsonify(
|
||
build_scheduled_automation_health_summary(
|
||
history_limit=max(1, min(history_limit or 200, 500)),
|
||
include_current_smoke=include_current,
|
||
freshness_max_age_hours=max(1, min(freshness_hours or 26, 168)),
|
||
)
|
||
)
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/surface-html-readback')
|
||
@login_required
|
||
def ai_automation_surface_html_readback_api():
|
||
"""Read-only AI product surface HTML contract readback."""
|
||
from services.ai_surface_html_readback_service import build_ai_surface_html_readback
|
||
|
||
return jsonify(build_ai_surface_html_readback())
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/surface-html-repair-package')
|
||
@login_required
|
||
def ai_automation_surface_html_repair_package_api():
|
||
"""Read-only controlled repair package for AI product surface HTML drift."""
|
||
from services.ai_surface_html_readback_service import build_ai_surface_html_repair_package
|
||
|
||
return jsonify(build_ai_surface_html_repair_package())
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/sitewide-ui-ux-agent')
|
||
@login_required
|
||
def ai_automation_sitewide_ui_ux_agent_api():
|
||
"""Read-only sitewide UI/UX agent inventory for professionalization work."""
|
||
from services.ai_surface_html_readback_service import build_sitewide_ui_ux_agent_inventory
|
||
|
||
return jsonify(build_sitewide_ui_ux_agent_inventory())
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/sitewide-ui-ux-repair-package')
|
||
@login_required
|
||
def ai_automation_sitewide_ui_ux_repair_package_api():
|
||
"""Read-only sitewide UI/UX controlled repair package."""
|
||
from services.ai_surface_html_readback_service import build_sitewide_ui_ux_repair_package
|
||
|
||
limit = request.args.get('limit', 12, type=int)
|
||
return jsonify(build_sitewide_ui_ux_repair_package(limit=max(1, min(limit or 12, 50))))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/sitewide-visual-qa-readback')
|
||
@login_required
|
||
def ai_automation_sitewide_visual_qa_readback_api():
|
||
"""Read-only sitewide desktop/tablet/mobile visual QA artifact readback."""
|
||
from services.ai_surface_html_readback_service import build_sitewide_visual_qa_readback
|
||
|
||
max_age_hours = request.args.get('max_age_hours', 26, type=int)
|
||
return jsonify(build_sitewide_visual_qa_readback(max_age_hours=max(1, min(max_age_hours or 26, 168))))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-crawler-integration')
|
||
@login_required
|
||
def ai_automation_pixelrag_crawler_integration_api():
|
||
"""Read-only PixelRAG-style crawler visual evidence assessment."""
|
||
from services.pixelrag_crawler_integration_service import (
|
||
build_pixelrag_crawler_integration_assessment,
|
||
build_pixelrag_ecommerce_expansion_plan,
|
||
build_pixelrag_marketplace_search_manifest,
|
||
build_pixelrag_visual_evidence_manifest,
|
||
)
|
||
|
||
platforms = request.args.getlist('platform') or None
|
||
if str(request.args.get('expansion_plan') or '').lower() in {'1', 'true', 'yes'}:
|
||
return jsonify(build_pixelrag_ecommerce_expansion_plan(
|
||
target_platforms=tuple(platforms) if platforms else None,
|
||
))
|
||
marketplace_keyword = str(request.args.get('marketplace_keyword') or '').strip()
|
||
if marketplace_keyword:
|
||
platform = (platforms or ['shopee_tw'])[0]
|
||
return jsonify(build_pixelrag_marketplace_search_manifest(
|
||
platform=platform,
|
||
keyword=marketplace_keyword,
|
||
crawler=str(request.args.get('crawler') or 'PixelRAGMarketplaceSearch.visual_fallback'),
|
||
trigger_reason=str(request.args.get('trigger_reason') or 'marketplace_search_visual_scan'),
|
||
evidence_intent=str(request.args.get('evidence_intent') or 'collect_public_marketplace_offer_cards'),
|
||
))
|
||
manifest_url = str(request.args.get('manifest_url') or '').strip()
|
||
if manifest_url:
|
||
platform = (platforms or ['momo'])[0]
|
||
return jsonify(build_pixelrag_visual_evidence_manifest(
|
||
url=manifest_url,
|
||
platform=platform,
|
||
crawler=str(request.args.get('crawler') or 'crawler_diagnostics'),
|
||
trigger_reason=str(request.args.get('trigger_reason') or 'parser_empty_or_low_confidence'),
|
||
evidence_intent=str(request.args.get('evidence_intent') or 'recover_visual_offer_evidence'),
|
||
))
|
||
return jsonify(build_pixelrag_crawler_integration_assessment(
|
||
target_platforms=tuple(platforms) if platforms else None,
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-visual-evidence-readback')
|
||
@login_required
|
||
def ai_automation_pixelrag_visual_evidence_readback_api():
|
||
"""Read-only PixelRAG visual evidence capture artifact readback."""
|
||
from services.pixelrag_crawler_integration_service import build_pixelrag_visual_evidence_readback
|
||
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
return jsonify(build_pixelrag_visual_evidence_readback(
|
||
platform=str(request.args.get('platform') or '').strip() or None,
|
||
manifest_id=str(request.args.get('manifest_id') or '').strip() or None,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-rag-candidate-replay')
|
||
@login_required
|
||
def ai_automation_pixelrag_rag_candidate_replay_api():
|
||
"""Read-only PixelRAG receipt to internal RAG candidate replay readback."""
|
||
from services.pixelrag_rag_candidate_replay_service import (
|
||
build_pixelrag_rag_candidate_replay_readback,
|
||
)
|
||
|
||
platforms = tuple(
|
||
str(item or '').strip()
|
||
for item in request.args.getlist('platform')
|
||
if str(item or '').strip()
|
||
)
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
limit = request.args.get('limit', 50, type=int)
|
||
return jsonify(build_pixelrag_rag_candidate_replay_readback(
|
||
platform_probe_worker_receipt_root=str(
|
||
request.args.get('platform_probe_worker_receipt_root') or ''
|
||
).strip() or None,
|
||
platform=platforms,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
limit=max(1, min(limit or 50, 250)),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-ocr-vlm-replay')
|
||
@login_required
|
||
def ai_automation_pixelrag_ocr_vlm_replay_api():
|
||
"""Read-only PixelRAG OCR/VLM replay contract readback."""
|
||
from services.pixelrag_ocr_vlm_replay_service import (
|
||
build_pixelrag_ocr_vlm_replay_contract,
|
||
)
|
||
|
||
platforms = tuple(
|
||
str(item or '').strip()
|
||
for item in request.args.getlist('platform')
|
||
if str(item or '').strip()
|
||
)
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
limit = request.args.get('limit', 50, type=int)
|
||
return jsonify(build_pixelrag_ocr_vlm_replay_contract(
|
||
platform=platforms,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
limit=max(1, min(limit or 50, 250)),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-application-portfolio')
|
||
@login_required
|
||
def ai_automation_pixelrag_application_portfolio_api():
|
||
"""Read-only PixelRAG application and integration portfolio."""
|
||
from services.pixelrag_application_portfolio_service import (
|
||
build_pixelrag_application_portfolio,
|
||
)
|
||
|
||
include_forbidden = str(request.args.get('include_forbidden') or '').lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
return jsonify(build_pixelrag_application_portfolio(
|
||
area=str(request.args.get('area') or '').strip() or None,
|
||
priority=str(request.args.get('priority') or '').strip() or None,
|
||
include_forbidden=include_forbidden,
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-vlm-replay-worker')
|
||
@login_required
|
||
def ai_automation_pixelrag_vlm_replay_worker_api():
|
||
"""Dry-run or execute the PixelRAG Ollama-first VLM replay worker."""
|
||
from services.pixelrag_vlm_replay_worker_service import (
|
||
run_pixelrag_ollama_vlm_replay_worker,
|
||
)
|
||
|
||
platforms = tuple(
|
||
str(item or '').strip()
|
||
for item in request.args.getlist('platform')
|
||
if str(item or '').strip()
|
||
)
|
||
execute = str(request.args.get('execute') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
write_receipt = str(request.args.get('write_receipt') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
limit = request.args.get('limit', 25, type=int)
|
||
tile_limit = request.args.get('tile_limit', 4, type=int)
|
||
timeout = request.args.get('timeout', 90, type=int)
|
||
auto_select_model = str(request.args.get('auto_select_model', 'true')).strip().lower() not in {
|
||
'0',
|
||
'false',
|
||
'no',
|
||
}
|
||
probe_generate_before_execute = (
|
||
str(request.args.get('probe_generate_before_execute', 'true')).strip().lower()
|
||
not in {
|
||
'0',
|
||
'false',
|
||
'no',
|
||
}
|
||
)
|
||
route_readiness_timeout = request.args.get('route_readiness_timeout', 3, type=int)
|
||
route_generate_probe_timeout = request.args.get('route_generate_probe_timeout', 20, type=int)
|
||
return jsonify(run_pixelrag_ollama_vlm_replay_worker(
|
||
platform=platforms,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
limit=max(1, min(limit or 25, 250)),
|
||
tile_limit=max(1, min(tile_limit or 4, 12)),
|
||
model=str(request.args.get('model') or '').strip() or None,
|
||
timeout=max(10, min(timeout or 90, 240)),
|
||
execute=execute,
|
||
write_receipt=bool(execute and write_receipt),
|
||
auto_select_model=auto_select_model,
|
||
route_readiness_timeout=max(1, min(route_readiness_timeout or 3, 20)),
|
||
probe_generate_before_execute=probe_generate_before_execute,
|
||
route_generate_probe_timeout=max(1, min(route_generate_probe_timeout or 20, 30)),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-vlm-route-readiness')
|
||
@login_required
|
||
def ai_automation_pixelrag_vlm_route_readiness_api():
|
||
"""Read-only PixelRAG VLM approved route/model readiness."""
|
||
from services.pixelrag_vlm_route_readiness_service import (
|
||
build_pixelrag_vlm_route_readiness,
|
||
)
|
||
|
||
include_models = str(request.args.get('include_models') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
probe_generate = str(request.args.get('probe_generate') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
timeout = request.args.get('timeout', 3, type=int)
|
||
probe_timeout = request.args.get('probe_timeout', 8, type=int)
|
||
return jsonify(build_pixelrag_vlm_route_readiness(
|
||
model=str(request.args.get('model') or '').strip() or None,
|
||
timeout_seconds=max(1, min(timeout or 3, 20)),
|
||
include_models=include_models,
|
||
probe_generate=probe_generate,
|
||
probe_timeout_seconds=max(1, min(probe_timeout or 8, 30)),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-platform-probe')
|
||
@login_required
|
||
def ai_automation_pixelrag_platform_probe_api():
|
||
"""Read-only PixelRAG platform probe readiness from barrier receipts."""
|
||
from services.pixelrag_platform_probe_service import (
|
||
build_pixelrag_platform_probe_readiness,
|
||
)
|
||
|
||
platforms = tuple(
|
||
str(item or '').strip()
|
||
for item in request.args.getlist('platform')
|
||
if str(item or '').strip()
|
||
)
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
limit = request.args.get('limit', 50, type=int)
|
||
return jsonify(build_pixelrag_platform_probe_readiness(
|
||
platform=platforms,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
limit=max(1, min(limit or 50, 250)),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-platform-probe-worker')
|
||
@login_required
|
||
def ai_automation_pixelrag_platform_probe_worker_api():
|
||
"""Controlled PixelRAG platform probe worker dry-run/execute readback."""
|
||
from services.pixelrag_platform_probe_worker_service import (
|
||
run_pixelrag_platform_probe_worker,
|
||
)
|
||
|
||
platforms = tuple(
|
||
str(item or '').strip()
|
||
for item in request.args.getlist('platform')
|
||
if str(item or '').strip()
|
||
)
|
||
execute = str(request.args.get('execute') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
write_receipt = str(request.args.get('write_receipt') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
limit = request.args.get('limit', 25, type=int)
|
||
timeout = request.args.get('timeout', 30, type=int)
|
||
settle_ms = request.args.get('settle_ms', 800, type=int)
|
||
max_tiles = request.args.get('max_tiles', 12, type=int)
|
||
return jsonify(run_pixelrag_platform_probe_worker(
|
||
platform=platforms,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
limit=max(1, min(limit or 25, 250)),
|
||
timeout=max(1, min(timeout or 30, 120)),
|
||
settle_ms=max(0, min(settle_ms or 800, 5000)),
|
||
max_tiles=max(1, min(max_tiles or 12, 80)),
|
||
execute=execute,
|
||
write_receipt=bool(execute and write_receipt),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-source-contract-replay-worker')
|
||
@login_required
|
||
def ai_automation_pixelrag_source_contract_replay_worker_api():
|
||
"""Controlled PixelRAG source-contract replay worker dry-run/execute readback."""
|
||
from services.pixelrag_source_contract_replay_worker_service import (
|
||
run_pixelrag_source_contract_replay_worker,
|
||
)
|
||
|
||
platforms = tuple(
|
||
str(item or '').strip()
|
||
for item in request.args.getlist('platform')
|
||
if str(item or '').strip()
|
||
)
|
||
execute = str(request.args.get('execute') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
write_receipt = str(request.args.get('write_receipt') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
limit = request.args.get('limit', 25, type=int)
|
||
return jsonify(run_pixelrag_source_contract_replay_worker(
|
||
platform_probe_worker_receipt_root=str(
|
||
request.args.get('platform_probe_worker_receipt_root') or ''
|
||
).strip() or None,
|
||
platform=platforms,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
limit=max(1, min(limit or 25, 250)),
|
||
execute=execute,
|
||
write_receipt=bool(execute and write_receipt),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/pixelrag-marketplace-source-contract-adapter-preflight')
|
||
@login_required
|
||
def ai_automation_pixelrag_marketplace_source_contract_adapter_preflight_api():
|
||
"""Controlled PixelRAG marketplace source-contract adapter preflight readback."""
|
||
from services.pixelrag_marketplace_source_contract_adapter_preflight_service import (
|
||
run_pixelrag_marketplace_source_contract_adapter_preflight,
|
||
)
|
||
|
||
platforms = tuple(
|
||
str(item or '').strip()
|
||
for item in request.args.getlist('platform')
|
||
if str(item or '').strip()
|
||
)
|
||
execute = str(request.args.get('execute') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
write_receipt = str(request.args.get('write_receipt') or '').strip().lower() in {
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
}
|
||
max_age_hours = request.args.get('max_age_hours', 168, type=int)
|
||
limit = request.args.get('limit', 25, type=int)
|
||
return jsonify(run_pixelrag_marketplace_source_contract_adapter_preflight(
|
||
source_contract_replay_receipt_root=str(
|
||
request.args.get('source_contract_replay_receipt_root') or ''
|
||
).strip() or None,
|
||
platform=platforms,
|
||
max_age_hours=max(1, min(max_age_hours or 168, 720)),
|
||
limit=max(1, min(limit or 25, 250)),
|
||
execute=execute,
|
||
write_receipt=bool(execute and write_receipt),
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/external-mcp-rag-integration')
|
||
@login_required
|
||
def ai_automation_external_mcp_rag_integration_api():
|
||
"""Read-only external MCP/RAG absorption status for internal MCP/RAG planes."""
|
||
from services.external_mcp_rag_integration_service import (
|
||
build_external_mcp_rag_integration_readback,
|
||
)
|
||
|
||
return jsonify(build_external_mcp_rag_integration_readback(
|
||
target_family=str(request.args.get('family') or '').strip() or None,
|
||
))
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/smoke/history/export')
|
||
@login_required
|
||
def ai_automation_smoke_history_export():
|
||
"""Export compact smoke history JSONL."""
|
||
from services.ai_automation_smoke_service import export_smoke_history_jsonl
|
||
export = export_smoke_history_jsonl()
|
||
response = Response(export["content"], mimetype='application/x-ndjson; charset=utf-8')
|
||
response.headers["Content-Disposition"] = "attachment; filename=ai_automation_smoke_history.jsonl"
|
||
response.headers["X-Smoke-History-Count"] = str(export["count"])
|
||
return response
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/smoke/history/clear', methods=['POST'])
|
||
@login_required
|
||
def ai_automation_smoke_history_clear():
|
||
"""Clear local compact smoke history JSONL."""
|
||
from services.ai_automation_smoke_service import clear_smoke_history
|
||
return jsonify(clear_smoke_history())
|
||
|
||
|
||
@system_public_bp.route('/api/ai-automation/smoke/daily-summary/send', methods=['POST'])
|
||
@login_required
|
||
def ai_automation_smoke_daily_summary_send():
|
||
"""Send compact smoke trend daily summary to Telegram."""
|
||
from services.ai_automation_smoke_service import send_smoke_daily_summary
|
||
result = send_smoke_daily_summary()
|
||
status_code = 200 if result.get("status") == "sent" else 502
|
||
return jsonify(result), status_code
|
||
|
||
|
||
@system_public_bp.route('/logs')
|
||
def show_logs():
|
||
return render_template('logs.html', active_page='logs')
|
||
|
||
|
||
@system_public_bp.route('/api/logs')
|
||
def get_logs_api():
|
||
if os.path.exists(LOG_FILE_PATH):
|
||
try:
|
||
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
|
||
return jsonify({"logs": _sanitize_public_logs(f.readlines()[-60:])})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
|
||
return jsonify({"logs": "讀取日誌異常"})
|
||
return jsonify({"logs": "等待系統啟動中..."})
|
||
|
||
|
||
@system_public_bp.route('/api/backup', methods=['POST'])
|
||
@login_required
|
||
def trigger_backup():
|
||
"""API: 觸發系統完整備份"""
|
||
try:
|
||
sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...")
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
if not os.path.exists(backup_dir):
|
||
os.makedirs(backup_dir)
|
||
|
||
timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
|
||
zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
|
||
zip_filepath = os.path.join(backup_dir, zip_filename)
|
||
|
||
with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||
for root, dirs, files in os.walk(BASE_DIR):
|
||
dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]
|
||
|
||
for file in files:
|
||
if file == zip_filename:
|
||
continue
|
||
if file.endswith('.pyc') or file.endswith('.DS_Store'):
|
||
continue
|
||
|
||
file_path = os.path.join(root, file)
|
||
arcname = os.path.relpath(file_path, BASE_DIR)
|
||
zipf.write(file_path, arcname)
|
||
|
||
sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}")
|
||
|
||
download_url = url_for('system_public.download_backup', filename=zip_filename)
|
||
|
||
return jsonify({
|
||
"status": "success",
|
||
"message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...",
|
||
"download_url": download_url
|
||
})
|
||
except Exception as e:
|
||
sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
|
||
@system_public_bp.route('/api/backup/download/<path:filename>')
|
||
@login_required
|
||
def download_backup(filename):
|
||
"""API: 下載備份檔案(已加入路徑遍歷防護)"""
|
||
try:
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
safe_path = safe_join(backup_dir, filename)
|
||
|
||
if not safe_path.exists():
|
||
sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
|
||
return jsonify({'error': '檔案不存在'}), 404
|
||
|
||
if not safe_path.is_file():
|
||
sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
|
||
return send_from_directory(backup_dir, safe_path.name, as_attachment=True)
|
||
|
||
except ValueError as e:
|
||
sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
except Exception as e:
|
||
sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
|
||
return jsonify({'error': '下載失敗'}), 500
|