""" 系統設定 API 端點 """ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session from pydantic import BaseModel from typing import Optional import os import hashlib from pathlib import Path import logging from app.db.session import get_db from app.models import User, SystemSetting from app.api.v1.endpoints.auth import get_current_user, require_roles from app.services.llm_service import test_llm_connection logger = logging.getLogger(__name__) router = APIRouter() class SystemSettingsResponse(BaseModel): crawl_schedule_time: Optional[str] = None publish_deadline: Optional[str] = None llm_provider: Optional[str] = None llm_model: Optional[str] = None llm_ollama_endpoint: Optional[str] = None data_retention_days: Optional[int] = None pdf_logo_path: Optional[str] = None pdf_header_text: Optional[str] = None pdf_footer_text: Optional[str] = None smtp_host: Optional[str] = None smtp_port: Optional[int] = None smtp_username: Optional[str] = None smtp_from_email: Optional[str] = None smtp_from_name: Optional[str] = None class SystemSettingsUpdate(BaseModel): crawl_schedule_time: Optional[str] = None publish_deadline: Optional[str] = None llm_provider: Optional[str] = None llm_api_key: Optional[str] = None llm_model: Optional[str] = None llm_ollama_endpoint: Optional[str] = None data_retention_days: Optional[int] = None pdf_header_text: Optional[str] = None pdf_footer_text: Optional[str] = None smtp_host: Optional[str] = None smtp_port: Optional[int] = None smtp_username: Optional[str] = None smtp_password: Optional[str] = None smtp_from_email: Optional[str] = None smtp_from_name: Optional[str] = None class LLMTestResponse(BaseModel): success: bool provider: str model: str response_time_ms: int message: Optional[str] = None def get_setting_value(db: Session, key: str) -> Optional[str]: """取得設定值""" setting = db.query(SystemSetting).filter(SystemSetting.setting_key == key).first() return setting.setting_value if setting else None def set_setting_value(db: Session, key: str, value: str, user_id: int): """設定值""" setting = db.query(SystemSetting).filter(SystemSetting.setting_key == key).first() if setting: setting.setting_value = value setting.updated_by = user_id else: setting = SystemSetting(setting_key=key, setting_value=value, updated_by=user_id) db.add(setting) @router.get("", response_model=SystemSettingsResponse) def get_settings( db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin")) ): """取得系統設定""" retention = get_setting_value(db, "data_retention_days") smtp_port = get_setting_value(db, "smtp_port") return SystemSettingsResponse( crawl_schedule_time=get_setting_value(db, "crawl_schedule_time"), publish_deadline=get_setting_value(db, "publish_deadline"), llm_provider=get_setting_value(db, "llm_provider"), llm_model=get_setting_value(db, "llm_model"), llm_ollama_endpoint=get_setting_value(db, "llm_ollama_endpoint"), data_retention_days=int(retention) if retention else None, pdf_logo_path=get_setting_value(db, "pdf_logo_path"), pdf_header_text=get_setting_value(db, "pdf_header_text"), pdf_footer_text=get_setting_value(db, "pdf_footer_text"), smtp_host=get_setting_value(db, "smtp_host"), smtp_port=int(smtp_port) if smtp_port else None, smtp_username=get_setting_value(db, "smtp_username"), smtp_from_email=get_setting_value(db, "smtp_from_email"), smtp_from_name=get_setting_value(db, "smtp_from_name") ) @router.put("") def update_settings( settings_in: SystemSettingsUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin")) ): """更新系統設定""" updates = settings_in.model_dump(exclude_unset=True) for key, value in updates.items(): if value is not None: # 敏感欄位需加密(簡化處理,實際應使用加密) if key in ["llm_api_key", "smtp_password"]: key = f"{key.replace('_key', '').replace('_password', '')}_encrypted" set_setting_value(db, key, str(value), current_user.id) db.commit() return {"message": "設定更新成功"} @router.post("/llm/test", response_model=LLMTestResponse) def test_llm( db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin")) ): """測試 LLM 連線""" provider = get_setting_value(db, "llm_provider") or "claude" model = get_setting_value(db, "llm_model") or "claude-3-sonnet" result = test_llm_connection(provider, model) return LLMTestResponse( success=result["success"], provider=provider, model=model, response_time_ms=result.get("response_time_ms", 0), message=result.get("message") ) @router.post("/pdf/logo") async def upload_pdf_logo( logo: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(require_roles("admin")) ): """上傳 PDF Logo(加強安全檢查)""" # 1. 檢查檔案大小(限制 5MB) MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB content = await logo.read() if len(content) > MAX_FILE_SIZE: raise HTTPException(status_code=400, detail="檔案大小超過 5MB 限制") # 2. 檢查檔案類型(基本檢查,建議安裝 python-magic 進行更嚴格的檢查) allowed_content_types = ["image/png", "image/jpeg", "image/svg+xml"] if logo.content_type not in allowed_content_types: raise HTTPException(status_code=400, detail=f"不支援的檔案類型: {logo.content_type},僅支援 PNG、JPEG、SVG") # 3. 檢查檔案副檔名(額外安全層) file_ext = logo.filename.split(".")[-1].lower() if "." in logo.filename else "" allowed_extensions = ["png", "jpg", "jpeg", "svg"] if file_ext not in allowed_extensions: raise HTTPException(status_code=400, detail=f"不支援的檔案副檔名: {file_ext}") # 4. 使用安全的檔案名稱(使用 hash,避免路徑遍歷和檔案名稱衝突) file_hash = hashlib.sha256(content).hexdigest()[:16] safe_filename = f"company_logo_{file_hash}.{file_ext}" # 5. 使用絕對路徑,避免路徑遍歷 # 取得專案根目錄 project_root = Path(__file__).parent.parent.parent.parent.resolve() upload_dir = (project_root / "uploads" / "logos").resolve() upload_dir.mkdir(parents=True, exist_ok=True) file_path = upload_dir / safe_filename # 6. 確保檔案路徑在允許的目錄內(防止路徑遍歷) try: file_path.resolve().relative_to(upload_dir.resolve()) except ValueError: raise HTTPException(status_code=400, detail="無效的檔案路徑") # 7. 檢查檔案內容的魔術數字(Magic Number)以驗證真實檔案類型 # PNG: 89 50 4E 47 # JPEG: FF D8 FF # SVG: 檢查是否為 XML 格式 magic_numbers = { b'\x89PNG\r\n\x1a\n': 'png', b'\xff\xd8\xff': 'jpg', } file_type_detected = None for magic, ext in magic_numbers.items(): if content.startswith(magic): file_type_detected = ext break # SVG 檢查(開頭應該是 = today ).count() # 活躍用戶數 active_users = db.query(User).filter(User.is_active == True).count() # 待發布報告 pending_reports = db.query(Report).filter( Report.status.in_(["draft", "pending"]) ).count() # 系統狀態 from app.models import NewsSource sources = db.query(NewsSource).filter(NewsSource.is_active == True).all() system_health = [] for source in sources: last_job = db.query(CrawlJob).filter( CrawlJob.source_id == source.id ).order_by(CrawlJob.created_at.desc()).first() system_health.append({ "name": f"{source.name} 爬蟲", "status": "正常" if last_job and last_job.status.value == "completed" else "異常", "last_run": last_job.completed_at.strftime("%H:%M") if last_job and last_job.completed_at else "-" }) # LLM 狀態 system_health.append({ "name": f"LLM 服務 ({get_setting_value(db, 'llm_provider') or 'Claude'})", "status": "正常", "last_run": "-" }) return AdminDashboardResponse( today_articles=today_articles, active_users=active_users, pending_reports=pending_reports, system_health=system_health )