Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,34 @@ CDN_UPLOAD_FIELD=file
# VIDEO_STT_LANGUAGE=zh
# 优先请求 verbose_json(带 segments);网关不支持会自动回退(默认 1)
# VIDEO_STT_PREFER_VERBOSE_JSON=1

# === 安全配置 ===
# 管理员密码(二选一;同时设置则 ADMIN_PASSWORD_SHA512 优先):
# ADMIN_PASSWORD_SHA512=<sha512 hex hash of your password>
# ADMIN_PASSWORD=<your plaintext password,启动时自动哈希>
#
# 管理员登录速率限制(窗口内最大尝试次数)
# ADMIN_MAX_ATTEMPTS_PER_WINDOW=8
# ADMIN_RATE_LIMIT_WINDOW_SEC=300
#
# 全局 API 速率限制
# API_RATE_LIMIT_WINDOW_SEC=60
# API_RATE_LIMIT_PER_WINDOW=30
#
# 诊断/优化等高消耗端点速率限制(更严格)
# DIAGNOSE_RATE_LIMIT_PER_WINDOW=8
#
# CORS 覆盖域名(生产环境设置;留空则使用默认域名)
# CORS_ORIGIN_OVERRIDE=https://yourdomain.com
#
# 请求体大小上限(MB,含文件上传;默认 350MB)
# MAX_REQUEST_BODY_MB=350
#
# 输入字段长度限制
# MAX_TITLE_LENGTH=200
# MAX_CONTENT_LENGTH=10000
# MAX_CATEGORY_LENGTH=50
# MAX_TAGS_LENGTH=500
#
# 临时视频目录总大小上限(MB,默认 4096;超过则清理最旧文件)
# TEMP_VIDEO_DIR_MAX_TOTAL_MB=4096
67 changes: 55 additions & 12 deletions backend/app/api/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,61 @@
from __future__ import annotations

import hashlib
import hmac as _hmac_mod
import logging
import os
import sqlite3
import time
from datetime import datetime

from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Header, Request
from fastapi.responses import HTMLResponse

router = APIRouter()
logger = logging.getLogger("noterx.admin")

ADMIN_PASSWORD_SHA512 = "2edcf6be5d8b758e185c1e73d86430bf7c438a87aad4649e185845ddca7b19bdc340ea56e8c5d89e3c60d736d49665c8465567075d1715f3d4d186ee33e9dc9e"
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "baseline.db")
_start_time = time.time()


def _get_password_hash() -> str:
env_hash = (os.getenv("ADMIN_PASSWORD_SHA512", "")).strip()
if env_hash:
return env_hash
admin_password = (os.getenv("ADMIN_PASSWORD", "")).strip()
if admin_password:
return hashlib.sha512(admin_password.encode()).hexdigest()
logger.critical("ADMIN_PASSWORD_SHA512 or ADMIN_PASSWORD env var not set; admin panel unavailable")
return ""


def _verify_password(password: str) -> bool:
import hmac
return hmac.compare_digest(
stored = _get_password_hash()
if not stored:
return False
return _hmac_mod.compare_digest(
hashlib.sha512(password.encode()).hexdigest(),
ADMIN_PASSWORD_SHA512,
stored,
)


_ADMIN_RATE_LIMIT: dict[str, list[float]] = {}


def _check_admin_rate_limit(identifier: str) -> bool:
now = time.time()
max_attempts = int(os.getenv("ADMIN_MAX_ATTEMPTS_PER_WINDOW", "8"))
window_sec = int(os.getenv("ADMIN_RATE_LIMIT_WINDOW_SEC", "300"))
if identifier not in _ADMIN_RATE_LIMIT:
_ADMIN_RATE_LIMIT[identifier] = []
timestamps = _ADMIN_RATE_LIMIT[identifier]
timestamps[:] = [ts for ts in timestamps if now - ts < window_sec]
if len(timestamps) >= max_attempts:
return False
timestamps.append(now)
return True


def _get_stats() -> dict:
stats = {"timestamp": datetime.utcnow().isoformat(), "uptime_seconds": time.time() - _start_time}
conn = None
Expand Down Expand Up @@ -199,8 +229,10 @@ def _get_stats() -> dict:
}
async function doLogin(){
const pw=document.getElementById('pw').value;
try{const r=await fetch('/admin/api/stats?password='+encodeURIComponent(pw));
if(!r.ok){showLogin('密码错误');return;}token=pw;showDash(await r.json());}catch(e){showLogin('连接失败');}
try{
const r=await fetch('/admin/api/stats',{headers:{'Authorization':'Bearer '+pw}});
if(!r.ok){showLogin('密码错误');return;}token=pw;showDash(await r.json());
}catch(e){showLogin('连接失败');}
}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function showDash(d){
Expand Down Expand Up @@ -251,22 +283,33 @@ def _get_stats() -> dict:
}
async function doRefresh(){
if(!token)return;
try{const r=await fetch('/admin/api/stats?password='+encodeURIComponent(token));
if(r.ok)showDash(await r.json());}catch(e){}
try{
const r=await fetch('/admin/api/stats',{headers:{'Authorization':'Bearer '+token}});
if(r.ok)showDash(await r.json());
}catch(e){}
}
// Auto-refresh every 30s
setInterval(()=>{if(token)doRefresh();},30000);
showLogin();
</script></body></html>"""


@router.get("/admin", response_class=HTMLResponse)
async def admin_page():
if not _get_password_hash():
raise HTTPException(503, "管理员面板未配置。请设置 ADMIN_PASSWORD_SHA512 或 ADMIN_PASSWORD 环境变量。")
return ADMIN_HTML


@router.get("/admin/api/stats")
async def admin_stats(password: str = Query(...)):
if not _verify_password(password):
async def admin_stats(request: Request, authorization: str = Header("")):
if not authorization.startswith("Bearer "):
raise HTTPException(401, "需要 Bearer token 认证")
token = authorization[7:]
if not token:
raise HTTPException(401, "需要 Bearer token 认证")
if not _verify_password(token):
identifier = request.client.host if request.client else "unknown"
if not _check_admin_rate_limit(identifier):
raise HTTPException(429, "请求过于频繁,请稍后重试")
raise HTTPException(403, "密码错误")
return _get_stats()
6 changes: 6 additions & 0 deletions backend/app/api/comments_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ class GenerateCommentsRequest(BaseModel):
@router.post("/generate-comments")
async def generate_comments(req: GenerateCommentsRequest):
"""用 flash 模型快速生成更多模拟评论"""
if len(req.title) > 200:
req.title = req.title[:200]
if len(req.content) > 5000:
req.content = req.content[:5000]
if len(req.category) > 50:
req.category = req.category[:50]
category_names = {"food": "美食", "fashion": "穿搭", "tech": "科技",
"travel": "旅行", "beauty": "美妆", "fitness": "健身"}
cat_cn = category_names.get(req.category, req.category)
Expand Down
34 changes: 33 additions & 1 deletion backend/app/api/diagnose.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def _sign_temp_video(file_name: str, exp: int) -> str:
def _cleanup_expired_temp_videos(now_ts: Optional[int] = None) -> None:
_ensure_temp_video_dir()
now = now_ts or int(time.time())
items = []
for item in TEMP_VIDEO_DIR.iterdir():
if not item.is_file():
continue
Expand All @@ -147,9 +148,18 @@ def _cleanup_expired_temp_videos(now_ts: Optional[int] = None) -> None:
exp = int(exp_str)
except ValueError:
continue
if exp < now - 60:
items.append((item, exp))
items.sort(key=lambda x: x[1])
total_bytes = sum(it[0].stat().st_size for it in items)
max_total_mb = int(os.getenv("TEMP_VIDEO_DIR_MAX_TOTAL_MB", "4096"))
max_total_mb = max(10, min(max_total_mb, 32768))
max_total_bytes = max_total_mb * 1024 * 1024
for item, exp in items:
if exp < now - 60 or total_bytes > max_total_bytes:
try:
size = item.stat().st_size
item.unlink(missing_ok=True)
total_bytes -= size
except Exception:
logger.warning("Failed to delete expired temp video: %s", item)

Expand Down Expand Up @@ -337,6 +347,23 @@ async def get_temp_video(

# ─── Main diagnose endpoint ───

_MAX_TITLE_LENGTH = int(os.getenv("MAX_TITLE_LENGTH", "200"))
_MAX_CONTENT_LENGTH = int(os.getenv("MAX_CONTENT_LENGTH", "10000"))
_MAX_CATEGORY_LENGTH = int(os.getenv("MAX_CATEGORY_LENGTH", "50"))
_MAX_TAGS_LENGTH = int(os.getenv("MAX_TAGS_LENGTH", "500"))


def _validate_input_fields(title: str, content: str, category: str, tags: str) -> None:
if len(title) > _MAX_TITLE_LENGTH:
raise HTTPException(400, f"标题长度不能超过 {_MAX_TITLE_LENGTH} 字符")
if len(content) > _MAX_CONTENT_LENGTH:
raise HTTPException(400, f"正文长度不能超过 {_MAX_CONTENT_LENGTH} 字符")
if len(category) > _MAX_CATEGORY_LENGTH:
raise HTTPException(400, f"品类名称过长")
if len(tags) > _MAX_TAGS_LENGTH:
raise HTTPException(400, f"标签过长")


@router.post("/diagnose", response_model=DiagnoseResponse)
async def diagnose_note(
request: Request,
Expand All @@ -351,6 +378,8 @@ async def diagnose_note(
"""Receive note content and run multi-agent diagnosis."""
from app.agents.orchestrator import Orchestrator

_validate_input_fields(title, content, category, tags)

# Collect image files
image_files: list[UploadFile] = []
if cover_image is not None:
Expand Down Expand Up @@ -475,6 +504,7 @@ async def pre_score_note(
"""Instant Model A pre-score (no LLM, pure math, <50ms)."""
from app.agents.research_data import pre_score, MODEL_PARAMS, CATEGORY_CN

_validate_input_fields(title, content, category, tags)
tag_count = len([t for t in tags.split(",") if t.strip()]) if tags else 0
result = pre_score(title, content, category, tag_count, image_count)
result["category"] = category
Expand All @@ -500,6 +530,8 @@ async def diagnose_stream(
from app.agents.orchestrator import Orchestrator
from app.agents.research_data import pre_score as _pre_score

_validate_input_fields(title, content, category, tags)

# Parse inputs (same as /diagnose)
image_files: list[UploadFile] = []
if cover_image is not None:
Expand Down
9 changes: 9 additions & 0 deletions backend/app/api/history_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import logging
import os
import re
import sqlite3
import uuid

Expand All @@ -16,6 +17,12 @@
logger = logging.getLogger("noterx.history")

DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "baseline.db")
_RECORD_ID_RE = re.compile(r"^[a-f0-9]{32}$")


def _vali_date_record_id(record_id: str) -> None:
if not _RECORD_ID_RE.fullmatch(record_id):
raise HTTPException(400, "无效的记录 ID")


def _get_conn() -> sqlite3.Connection:
Expand Down Expand Up @@ -103,6 +110,7 @@ async def get_history(record_id: str):
获取单条历史记录详情(含完整报告)。
@param record_id - UUID
"""
_vali_date_record_id(record_id)
conn = _get_conn()
try:
row = conn.execute(
Expand Down Expand Up @@ -133,6 +141,7 @@ async def delete_history(record_id: str):
删除一条历史记录。
@param record_id - UUID
"""
_vali_date_record_id(record_id)
conn = _get_conn()
try:
cur = conn.execute("DELETE FROM diagnosis_history WHERE id = ?", (record_id,))
Expand Down
6 changes: 6 additions & 0 deletions backend/app/api/optimize_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class OptimizeRequest(BaseModel):
@router.post("/optimize")
async def optimize(req: OptimizeRequest):
"""生成2-3个优化方案并自动评分"""
if len(req.title) > 200:
req.title = req.title[:200]
if len(req.content) > 10000:
req.content = req.content[:10000]
if len(req.category) > 50:
req.category = req.category[:50]
issues_text = req.issues[:500] if req.issues else "无具体扣分项"
suggestions_text = req.suggestions[:500] if req.suggestions else ""

Expand Down
Loading