626 lines
23 KiB
Python
626 lines
23 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',
|
||
'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}")
|
||
|
||
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))
|
||
|
||
|
||
@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/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
|