V10.515 收緊 Webcrumbs host data 授權
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-31 20:59:47 +08:00
parent f3a3cfe52a
commit 353465d38a
8 changed files with 96 additions and 10 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.515 補 Webcrumbs host data 硬性授權:即使正式環境 `DISABLE_LOGIN=true` 讓一般 `@login_required` 放行,`/api/webcrumbs/marketplace-host-data` 仍必須有登入 session 或 `X-Internal-Key` 才能取真實 SKU/價差;`/webcrumbs` 未授權時只注入 `auth_required` 空狀態,避免 inline seed data 公開正式比價資料。
- V10.514 新增 Webcrumbs MOMO/PChome host data read-only API`/api/webcrumbs/marketplace-host-data` 回傳與 `/webcrumbs` inline seed 相同的登入後 JSON contract提供 plugin / QA / 其他專案 proxy 驗證API boundary 明確標示不寫 DB、不呼叫 LLM、不抓外站只允許 exact / total_price / price_alert_exact 價差摘要。
- V10.513 外部工具診斷頁 payload 模組化:新增 `services/external_tool_payload_service.py`,把 Metabase/Grist/Webcrumbs 的診斷 payload 與 Webcrumbs host data 組裝移出 `routes/system_public_routes.py`,讓 route 回到 HTTP glue`system_public_routes.py` 從 600+ 行降至 500 行內。
- V10.512 Webcrumbs live plugin 接上 MOMO/PChome 只讀 host data新增 `services/webcrumbs_host_data_service.py`,復用 `competitor_intel_repository.fetch_top_competitor_risks()` / coverage把 exact / total_price / price_alert_exact 價差摘要轉成 `StockPlatformSharedUI.marketSnapshot` / `aiCandidate`;不呼叫 LLM、不抓外站、不寫 DB無風險或失敗時仍輸出安全空狀態。

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.514"
SYSTEM_VERSION = "V10.515"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -64,7 +64,8 @@ style.css
- `/webcrumbs` 顯示 runtime URL、版本與 plugin base。
- `/webcrumbs` 會嵌入同源 `/webcrumbs-assets/plugins/...` 的 live plugin preview並由 momo-pro 注入只讀 MOMO/PChome exact 價差摘要;若資料源不可用或無風險候選,改注入診斷空狀態,避免 plugin fallback demo 數字在正式頁面被誤認成真實市場或 AI 建議。
- `/api/webcrumbs/marketplace-host-data` 會回傳同一份登入後只讀 host data contract供 plugin / QA / 其他專案 proxy 驗證boundary 必須標示 `writes_database=false``calls_llm=false``fetches_external=false`
- `/api/webcrumbs/marketplace-host-data` 會回傳同一份登入後只讀 host data contract供 plugin / QA / 其他專案 proxy 驗證;即使全站 `DISABLE_LOGIN=true`,此 API 仍必須要求登入 session 或 `X-Internal-Key`boundary 必須標示 `writes_database=false``calls_llm=false``fetches_external=false`
- `/webcrumbs` 未取得登入 session 或 `X-Internal-Key` 時只能注入 `auth_required` 空狀態,不得把真實 SKU、價格或價差寫進 inline seed data。
- `/webcrumbs-assets/loader/webcrumbs-compatible-loader.js` 回 200 且 content type 是 JavaScript。
- `ewoooc_base.html``WEBCRUMBS_ENABLED=true` 且 runtime URL 有效時輸出 `<script data-webcrumbs-runtime=...>`
- 任一試點頁嵌入 plugin 後,瀏覽器 console 不應有 `MISSING_URI``STYLE_LOAD_ERROR``SCRIPT_LOAD_ERROR`

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-31Webcrumbs 共用 UI Runtime 與市場情報 writer approval
- **V10.515 Webcrumbs host data 硬性授權**: 發現正式環境一般 `@login_required` 可能因 `DISABLE_LOGIN=true` 放行後,為 `/api/webcrumbs/marketplace-host-data``/webcrumbs` inline seed 加上獨立授權判斷;只有登入 session 或 `X-Internal-Key` 可取得真實 SKU/價差,未授權時只回 `auth_required` 空狀態,避免 public runtime 診斷頁洩漏正式比價資料。
- **V10.514 Webcrumbs host data read-only API**: 新增登入後 `/api/webcrumbs/marketplace-host-data`,回傳與 `/webcrumbs` inline seed 相同的 MOMO/PChome exact 價差 host data contract供 plugin、QA 與其他專案 proxy 驗證API boundary 明確標示 `writes_database=false``calls_llm=false``fetches_external=false`,且只允許 exact / total_price / price_alert_exact 摘要。
- **V10.513 外部工具診斷 payload 模組化**: 新增 `services/external_tool_payload_service.py`,把 Metabase/Grist/Webcrumbs 診斷頁 payload 與 Webcrumbs host data 組裝從 `routes/system_public_routes.py` 移出route 保持 HTTP glue 與 asset proxy避免 Webcrumbs live plugin 與比價 host data 持續把公開系統 route 撐成大檔。
- **V10.512 Webcrumbs MOMO/PChome host data**: 新增 `services/webcrumbs_host_data_service.py`,讓 `/webcrumbs` live plugin preview 復用 `competitor_intel_repository.fetch_top_competitor_risks()` 與 coverage將 exact / total_price / price_alert_exact 的只讀價差摘要轉成 `StockPlatformSharedUI.marketSnapshot` / `aiCandidate`;不呼叫 LLM、不抓外站、不寫 DB無風險或讀取失敗時仍輸出安全空狀態。

View File

@@ -6,13 +6,14 @@
"""
import os
import hmac
import mimetypes
import posixpath
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, url_for
from flask import Blueprint, Response, jsonify, render_template, request, send_from_directory, session, url_for
import requests
from sqlalchemy import text
@@ -26,7 +27,10 @@ from config import (
)
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.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
@@ -42,6 +46,14 @@ STATIC_DIR = os.path.join(BASE_DIR, 'web/static')
WEBCRUMBS_ASSET_ALLOWED_PREFIXES = ('loader/', 'plugins/', 'demo/')
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 噪音。"""
@@ -104,7 +116,10 @@ def webcrumbs_status():
'external_tool_status.html',
active_page='webcrumbs',
system_version=SYSTEM_VERSION,
tool=build_external_tool_payload('webcrumbs'),
tool=build_external_tool_payload(
'webcrumbs',
include_host_data=_has_sensitive_webcrumbs_access(),
),
)
@@ -112,6 +127,19 @@ def webcrumbs_status():
@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)

View File

@@ -62,6 +62,37 @@ def _is_blocked_external_url(configured_url):
return parsed.scheme in {"http", "https"} and parsed.hostname != "mo.wooo.work"
def build_webcrumbs_auth_required_seed_data():
"""Return a non-sensitive host data shape when auth is not proven."""
return {
"marketSnapshot": [
{
"name": "MOMO/PChome host data requires authentication",
"price": "not_available",
"change_pct": "not_available",
"freshness_status": "auth_required",
}
],
"aiCandidate": {
"ticker": "-",
"name": "MOMO/PChome host data locked",
"thesis": "Webcrumbs runtime 已接入,但真實 SKU、價格與候選摘要需要登入 session 或 X-Internal-Key未授權時不顯示 fallback demo 數字,也不顯示正式比價資料。",
"confidence_score": "not_available",
"risk_level": "auth_required",
"release_status": "blocked",
"evidence_refs": ["auth_required"],
"updated_at": SYSTEM_VERSION,
},
"metadata": {
"source": "auth_required",
"row_count": 0,
"writes_database": False,
"calls_llm": False,
"fetches_external": False,
},
}
def build_webcrumbs_seed_data(limit: int = 5):
"""Build safe Webcrumbs host data for page seed and read-only API."""
try:
@@ -115,7 +146,7 @@ def _metabase_payload():
}
def _webcrumbs_payload():
def _webcrumbs_payload(include_host_data: bool = True):
runtime_ready = bool(WEBCRUMBS_ENABLED and WEBCRUMBS_RUNTIME_URL)
launch_url = WEBCRUMBS_BASE_URL if WEBCRUMBS_BASE_URL else ""
plugin_base = (WEBCRUMBS_PLUGIN_BASE_URL or "").rstrip("/")
@@ -171,7 +202,11 @@ def _webcrumbs_payload():
],
"plugin_previews": plugin_previews,
"plugin_preview_uris": [preview["uri"] for preview in plugin_previews],
"plugin_seed_data": build_webcrumbs_seed_data(limit=5),
"plugin_seed_data": (
build_webcrumbs_seed_data(limit=5)
if include_host_data
else build_webcrumbs_auth_required_seed_data()
),
}
@@ -201,10 +236,10 @@ def _grist_payload():
}
def build_external_tool_payload(kind):
def build_external_tool_payload(kind, include_host_data: bool = True):
"""建立外部工具診斷頁 payload。"""
if kind == "metabase":
return _metabase_payload()
if kind == "webcrumbs":
return _webcrumbs_payload()
return _webcrumbs_payload(include_host_data=include_host_data)
return _grist_payload()

View File

@@ -32,7 +32,13 @@ def test_external_tool_bridge_pages_are_diagnostic_not_blank():
css = (ROOT / "web/static/css/page-external-tools.css").read_text(encoding="utf-8")
assert "build_external_tool_payload" in route_source
assert "def build_external_tool_payload(kind)" in service_source
assert "def _has_sensitive_webcrumbs_access()" in route_source
assert "hmac.compare_digest(provided, internal_key)" in route_source
assert "include_host_data=_has_sensitive_webcrumbs_access()" in route_source
assert "def build_external_tool_payload(kind, include_host_data: bool = True)" in service_source
assert "include_host_data: bool = True" in service_source
assert "def build_webcrumbs_auth_required_seed_data()" in service_source
assert "auth_required" in service_source
assert 'parsed.path.rstrip("/") == bridge_path.rstrip("/")' in service_source
assert "def _is_blocked_external_url(configured_url)" in service_source
assert "external-tool-checks" in template
@@ -41,6 +47,8 @@ def test_external_tool_bridge_pages_are_diagnostic_not_blank():
assert "def webcrumbs_status()" in route_source
assert "@system_public_bp.route('/api/webcrumbs/marketplace-host-data')" in route_source
assert "def webcrumbs_marketplace_host_data_api()" in route_source
assert "if not _has_sensitive_webcrumbs_access()" in route_source
assert "'error': 'auth_required'" in route_source
assert "build_webcrumbs_seed_data(limit=limit)" in route_source
assert "'allowed_match_contract': 'exact/total_price/price_alert_exact'" in route_source
assert "def webcrumbs_asset_proxy(asset_path)" in route_source

View File

@@ -81,3 +81,15 @@ def test_webcrumbs_seed_data_fallback_does_not_expose_demo_values(monkeypatch):
assert payload["aiCandidate"]["release_status"] == "blocked"
assert "fallback demo" in payload["aiCandidate"]["thesis"]
assert "TAIEX" not in str(payload)
def test_webcrumbs_auth_required_seed_data_is_non_sensitive():
from services.external_tool_payload_service import build_webcrumbs_auth_required_seed_data
payload = build_webcrumbs_auth_required_seed_data()
assert payload["marketSnapshot"][0]["freshness_status"] == "auth_required"
assert payload["aiCandidate"]["release_status"] == "blocked"
assert payload["metadata"]["source"] == "auth_required"
assert "MOMO NT$" not in str(payload)
assert "PChome NT$" not in str(payload)