fix: 修复 44 个代码审查问题 (CRITICAL/HIGH/MEDIUM/LOW)
Batch 1 — CRITICAL (1): - 提取 is_safe_path() 到 src/core/security.py 公共模块 - CLI 和 ingest_obsidian.py 统一添加路径遍历防护 Batch 2 — HIGH (13) + 架构重构: - CLI 复用 deps.py AppState, 消除 30 行重复代码 - AppState/get_state 添加线程安全锁 - serve 命令传递 --config 到 uvicorn (H1) - OpenAIEmbedder 懒创建+复用 HTTP 客户端 (H2) - DashscopeEmbedder import 移到模块顶部 (H3) - 路径检查改用 os.path.commonpath (H4) - embedder.embed() 返回值长度检查 (H5) - 健康检查不泄露内部错误详情 (H7) - /api/v1/collections 添加 API Key 认证 (H8) - API Key 使用 hmac.compare_digest 恒定时间比较 (H9) - 添加 CORS 中间件 (H10) - ServerConfig 支持 SSL 配置 (H11) - HF_ENDPOINT 修改添加详细注释 (H12) Batch 3 — MEDIUM (20) + Splitter Protocol: - 定义 Splitter(Protocol) 接口, DocumentIngestor 接受可选 splitter - DashScope 响应添加结构验证 (M2) - ingest_obsidian.py 支持 CLI 参数和 OBSIDIAN_DIRS 环境变量 (M6) - scripts/serve.py 添加废弃警告 (M7) - content 限制 500KB, collection 正则限制字符集 (M12-M14) - 默认监听地址 127.0.0.1 (M16) - 添加安全响应头中间件 (M17) - verify_api_key 认证失败记录日志 (M19) Batch 4 — LOW (10): - CLI emoji 清理为纯文本标记 (L5) - logging.basicConfig 移到 FastAPI lifespan (L1) - VectorDB 添加 write_guard() 上下文管理器 (L3) - IngestRequest file_path/content 互斥校验 (L10) - ingest_obsidian.py 注释修正 (L6) 测试: 46 → 70 (+24) - tests/test_security.py: 11 个路径安全测试 - tests/test_deps.py: 11 个依赖注入测试 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+60
-53
@@ -16,9 +16,9 @@ if sys.stdout.encoding != "utf-8":
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.core.config import load_config, DEFAULT_CONFIG_PATH
|
||||
from src.core.db import VectorDB
|
||||
from src.core.embedder import create_embedder
|
||||
from src.core.config import DEFAULT_CONFIG_PATH
|
||||
from src.core.security import is_safe_path
|
||||
from src.server.deps import get_state, get_default_collection
|
||||
|
||||
app = typer.Typer(
|
||||
name="md-vector-db",
|
||||
@@ -31,30 +31,14 @@ app = typer.Typer(
|
||||
)
|
||||
|
||||
|
||||
# -- 共享组件 (懒加载) --
|
||||
_db: VectorDB | None = None
|
||||
_embedder = None
|
||||
_cfg = None
|
||||
|
||||
|
||||
def _init_shared(config_path: str = DEFAULT_CONFIG_PATH):
|
||||
"""初始化 db + embedder(全局共享)."""
|
||||
global _db, _embedder, _cfg
|
||||
if _db is None:
|
||||
_cfg = load_config(config_path)
|
||||
_db = VectorDB(persist_dir=_cfg.chroma.persist_dir)
|
||||
_embedder = create_embedder(_cfg.embed)
|
||||
|
||||
|
||||
def _get_default_collection() -> str:
|
||||
return os.environ.get(
|
||||
"MD_VECTOR_DB_COLLECTION",
|
||||
_cfg.chroma.collection_name if _cfg else "markdown_docs",
|
||||
)
|
||||
# -- 共享初始化 --
|
||||
def _init_config(config_path: str = DEFAULT_CONFIG_PATH):
|
||||
"""确保配置已加载并设置到环境变量 (供 deps.get_state 复用)."""
|
||||
os.environ["MD_VECTOR_CONFIG"] = config_path
|
||||
|
||||
|
||||
def _resolve_collection(collection: str | None) -> str:
|
||||
return collection or _get_default_collection()
|
||||
return collection or get_default_collection()
|
||||
|
||||
|
||||
# -- 共享选项 --
|
||||
@@ -82,40 +66,46 @@ def ingest(
|
||||
config: ConfigOpt = DEFAULT_CONFIG_PATH,
|
||||
collection: CollectionOpt = None,
|
||||
):
|
||||
_init_shared(config)
|
||||
from src.core.ingest import DocumentIngestor
|
||||
ingestor = DocumentIngestor(_db, _embedder, _resolve_collection(collection))
|
||||
_init_config(config)
|
||||
state = get_state()
|
||||
ingestor = state.get_ingestor(_resolve_collection(collection))
|
||||
|
||||
# 标准输入模式
|
||||
if file_paths and file_paths[0] == "-":
|
||||
content = sys.stdin.read()
|
||||
file_name = name or "stdin.md"
|
||||
count = ingestor.ingest_content(content, file_name)
|
||||
typer.echo(f"✅ 已入库: stdin → {count} chunks [{ingestor.collection_name}]")
|
||||
typer.echo(f"[OK] 已入库: stdin -> {count} chunks [{ingestor.collection_name}]")
|
||||
return
|
||||
|
||||
# 多文件模式
|
||||
if file_paths:
|
||||
total = 0
|
||||
for fp in file_paths:
|
||||
if not is_safe_path(fp):
|
||||
typer.echo(f"[SKIP] 不安全的路径: {fp}", err=True)
|
||||
continue
|
||||
# 支持通配符 (shell 展开或 Python glob)
|
||||
p = Path(fp)
|
||||
if "*" in fp or "?" in fp:
|
||||
matches = _glob.glob(fp, recursive=True)
|
||||
for m in matches:
|
||||
if not is_safe_path(m):
|
||||
typer.echo(f"[SKIP] 不安全的路径: {m}", err=True)
|
||||
continue
|
||||
c = ingestor.ingest_file(m)
|
||||
typer.echo(f" 📄 {m}: {c} chunks")
|
||||
typer.echo(f" {m}: {c} chunks")
|
||||
total += c
|
||||
elif p.is_file():
|
||||
c = ingestor.ingest_file(fp)
|
||||
typer.echo(f" 📄 {fp}: {c} chunks")
|
||||
typer.echo(f" {fp}: {c} chunks")
|
||||
total += c
|
||||
else:
|
||||
typer.echo(f"⚠️ 跳过 (非文件): {fp}", err=True)
|
||||
typer.echo(f"✅ 共入库 {total} chunks [{ingestor.collection_name}]")
|
||||
typer.echo(f"[SKIP] 非文件: {fp}", err=True)
|
||||
typer.echo(f"[OK] 共入库 {total} chunks [{ingestor.collection_name}]")
|
||||
return
|
||||
|
||||
# 无参数 → 显示帮助
|
||||
# 无参数 -> 显示帮助
|
||||
typer.echo("用法: md-vector-db ingest <文件1> [文件2 ...] 或 echo '内容' | md-vector-db ingest - --name doc.md", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
@@ -126,17 +116,20 @@ def ingest_dir(
|
||||
config: ConfigOpt = DEFAULT_CONFIG_PATH,
|
||||
collection: CollectionOpt = None,
|
||||
):
|
||||
_init_shared(config)
|
||||
from src.core.ingest import DocumentIngestor
|
||||
ingestor = DocumentIngestor(_db, _embedder, _resolve_collection(collection))
|
||||
_init_config(config)
|
||||
if not is_safe_path(dir_path):
|
||||
typer.echo(f"错误: 不安全的路径 — {dir_path}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
state = get_state()
|
||||
ingestor = state.get_ingestor(_resolve_collection(collection))
|
||||
results = ingestor.ingest_directory(dir_path)
|
||||
if not results:
|
||||
typer.echo(f"⚠️ 目录中未找到 .md 文件: {dir_path}")
|
||||
typer.echo(f"[SKIP] 目录中未找到 .md 文件: {dir_path}")
|
||||
return
|
||||
total = sum(results.values())
|
||||
for name, count in results.items():
|
||||
typer.echo(f" 📄 {name}: {count} chunks")
|
||||
typer.echo(f"✅ 共入库 {len(results)} 个文件, {total} 个 chunks [{ingestor.collection_name}]")
|
||||
typer.echo(f" {name}: {count} chunks")
|
||||
typer.echo(f"[OK] 共入库 {len(results)} 个文件, {total} 个 chunks [{ingestor.collection_name}]")
|
||||
|
||||
|
||||
@app.command(help="语义检索已入库的文档. 加 --json 输出机器可读 JSON.")
|
||||
@@ -147,9 +140,9 @@ def search(
|
||||
config: ConfigOpt = DEFAULT_CONFIG_PATH,
|
||||
collection: CollectionOpt = None,
|
||||
):
|
||||
_init_shared(config)
|
||||
from src.core.search import Searcher
|
||||
searcher = Searcher(_db, _embedder, _resolve_collection(collection))
|
||||
_init_config(config)
|
||||
state = get_state()
|
||||
searcher = state.get_searcher(_resolve_collection(collection))
|
||||
results = searcher.search(query, top_k=top_k)
|
||||
|
||||
if json_output:
|
||||
@@ -161,9 +154,9 @@ def search(
|
||||
return
|
||||
for i, r in enumerate(results, 1):
|
||||
typer.echo(f"\n--- 结果 {i} (相似度: {r['score']:.4f}) ---")
|
||||
typer.echo(f"📄 来源: {r['source_file']}")
|
||||
typer.echo(f"来源: {r['source_file']}")
|
||||
if r["section_title"]:
|
||||
typer.echo(f"📑 章节: {r['section_title']}")
|
||||
typer.echo(f"章节: {r['section_title']}")
|
||||
preview = r["content"][:200] + "..." if len(r["content"]) > 200 else r["content"]
|
||||
typer.echo(preview)
|
||||
|
||||
@@ -173,9 +166,23 @@ def serve(
|
||||
port: Annotated[int, typer.Option("--port", "-p", help="监听端口")] = 8000,
|
||||
config: ConfigOpt = DEFAULT_CONFIG_PATH,
|
||||
):
|
||||
typer.echo(f"🚀 启动服务: http://localhost:{port}")
|
||||
typer.echo(f"📖 API 文档: http://localhost:{port}/docs")
|
||||
uvicorn.run("src.server.app:app", host="0.0.0.0", port=port, reload=False)
|
||||
# 传递 config 给 uvicorn 子进程 (通过环境变量)
|
||||
os.environ["MD_VECTOR_CONFIG"] = config
|
||||
typer.echo(f"启动服务: http://localhost:{port}")
|
||||
typer.echo(f"API 文档: http://localhost:{port}/docs")
|
||||
# 加载配置以获取 SSL 设置
|
||||
cfg = get_state().config
|
||||
ssl_kwargs = {}
|
||||
if cfg.server.ssl_keyfile and cfg.server.ssl_certfile:
|
||||
ssl_kwargs["ssl_keyfile"] = cfg.server.ssl_keyfile
|
||||
ssl_kwargs["ssl_certfile"] = cfg.server.ssl_certfile
|
||||
uvicorn.run(
|
||||
"src.server.app:app",
|
||||
host=cfg.server.host,
|
||||
port=port or cfg.server.port,
|
||||
reload=False,
|
||||
**ssl_kwargs,
|
||||
)
|
||||
|
||||
|
||||
@app.command(help="查看向量库统计信息. 加 --json 输出机器可读 JSON.")
|
||||
@@ -184,9 +191,9 @@ def stats(
|
||||
config: ConfigOpt = DEFAULT_CONFIG_PATH,
|
||||
collection: CollectionOpt = None,
|
||||
):
|
||||
_init_shared(config)
|
||||
from src.core.search import Searcher
|
||||
searcher = Searcher(_db, _embedder, _resolve_collection(collection))
|
||||
_init_config(config)
|
||||
state = get_state()
|
||||
searcher = state.get_searcher(_resolve_collection(collection))
|
||||
info = searcher.get_collection_info()
|
||||
sources = searcher.list_sources()
|
||||
data = {"collection": info["name"], "total_chunks": info["count"], "sources": sources}
|
||||
@@ -195,9 +202,9 @@ def stats(
|
||||
typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
typer.echo(f"📊 Collection: {info['name']}")
|
||||
typer.echo(f"📦 总 chunks: {info['count']}")
|
||||
typer.echo(f"📄 源文件数: {len(sources)}")
|
||||
typer.echo(f"Collection: {info['name']}")
|
||||
typer.echo(f"总 chunks: {info['count']}")
|
||||
typer.echo(f"源文件数: {len(sources)}")
|
||||
if sources:
|
||||
typer.echo("\n源文件列表:")
|
||||
for s in sources:
|
||||
|
||||
+3
-1
@@ -59,8 +59,10 @@ class ChunkConfig:
|
||||
class ServerConfig:
|
||||
"""HTTP 服务配置."""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8000
|
||||
ssl_keyfile: str = "" # HTTPS 密钥文件路径 (空则使用 HTTP)
|
||||
ssl_certfile: str = "" # HTTPS 证书文件路径 (空则使用 HTTP)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+15
-1
@@ -1,5 +1,8 @@
|
||||
"""ChromaDB 数据库层."""
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
import chromadb
|
||||
from chromadb.api.models.Collection import Collection
|
||||
|
||||
@@ -11,9 +14,20 @@ class VectorDB:
|
||||
self.client = chromadb.PersistentClient(path=persist_dir)
|
||||
self._write_lock = threading.Lock()
|
||||
|
||||
@contextmanager
|
||||
def write_guard(self) -> Generator[None, None, None]:
|
||||
"""写操作上下文管理器 — 替代直接使用 write_lock.
|
||||
|
||||
用法:
|
||||
with db.write_guard():
|
||||
collection.add(...)
|
||||
"""
|
||||
with self._write_lock:
|
||||
yield
|
||||
|
||||
@property
|
||||
def write_lock(self) -> threading.Lock:
|
||||
"""获取写锁, 供外部在 add/delete/update 操作时使用."""
|
||||
"""获取写锁 (兼容旧代码, 推荐使用 write_guard 上下文管理器)."""
|
||||
return self._write_lock
|
||||
|
||||
def get_or_create_collection(self, name: str) -> Collection:
|
||||
|
||||
+21
-7
@@ -21,11 +21,13 @@ import os
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
import requests # noqa: F401 — DashscopeEmbedder 使用
|
||||
|
||||
from src.core.config import EmbedConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger("md-vector-db")
|
||||
|
||||
_HF_MIRROR = "https://hf-mirror.com"
|
||||
_HF_MIRROR = os.environ.get("HF_MIRROR", "https://hf-mirror.com")
|
||||
|
||||
# -- Provider 默认配置 --
|
||||
_PROVIDER_DEFAULTS: dict[str, dict[str, str | int]] = {
|
||||
@@ -73,6 +75,8 @@ class LocalEmbedder:
|
||||
)
|
||||
except Exception:
|
||||
logger.info("模型未缓存, 通过镜像下载 %s", config.local_model)
|
||||
# 通过 HF_ENDPOINT 环境变量设置镜像(sentence-transformers 依赖 huggingface_hub)
|
||||
# 临时设置仅用于模型下载,下载完成后还原
|
||||
old_endpoint = os.environ.get("HF_ENDPOINT")
|
||||
os.environ["HF_ENDPOINT"] = _HF_MIRROR
|
||||
try:
|
||||
@@ -121,13 +125,19 @@ class OpenAIEmbedder(_BaseAPIEmbedder):
|
||||
|
||||
def __init__(self, config: EmbedConfig):
|
||||
super().__init__(config, "openai")
|
||||
self._client = None # 懒初始化,首次 embed() 时创建
|
||||
|
||||
def _ensure_client(self):
|
||||
"""懒创建 OpenAI 客户端(避免 import 时依赖 openai 包)."""
|
||||
if self._client is None:
|
||||
from openai import OpenAI
|
||||
self._client = OpenAI(base_url=self._api_base, api_key=self._api_key)
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
if not texts:
|
||||
raise ValueError("文本列表不能为空")
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url=self._api_base, api_key=self._api_key)
|
||||
response = client.embeddings.create(model=self._model, input=texts)
|
||||
self._ensure_client()
|
||||
response = self._client.embeddings.create(model=self._model, input=texts)
|
||||
return [d.embedding for d in response.data]
|
||||
|
||||
|
||||
@@ -141,7 +151,6 @@ class DashscopeEmbedder(_BaseAPIEmbedder):
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
if not texts:
|
||||
raise ValueError("文本列表不能为空")
|
||||
import requests
|
||||
resp = requests.post(
|
||||
self._api_base,
|
||||
headers={
|
||||
@@ -157,7 +166,12 @@ class DashscopeEmbedder(_BaseAPIEmbedder):
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# DashScope 返回: {"output": {"embeddings": [{"text_index": 0, "embedding": [...]}, ...]}}
|
||||
embeddings_raw = data.get("output", {}).get("embeddings", [])
|
||||
output = data.get("output")
|
||||
if output is None:
|
||||
raise ValueError(f"DashScope 响应缺少 output 字段: {data}")
|
||||
embeddings_raw = output.get("embeddings")
|
||||
if not isinstance(embeddings_raw, list):
|
||||
raise ValueError(f"DashScope embeddings 不是列表: {type(embeddings_raw)}")
|
||||
# 按 text_index 排序确保顺序
|
||||
embeddings_raw.sort(key=lambda x: x.get("text_index", 0))
|
||||
return [e["embedding"] for e in embeddings_raw]
|
||||
|
||||
+18
-2
@@ -3,6 +3,7 @@ import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.db import VectorDB
|
||||
from src.core.embedder import Embedder, batch_embed
|
||||
@@ -10,6 +11,15 @@ from src.core.embedder import Embedder, batch_embed
|
||||
logger = logging.getLogger("md-vector-db")
|
||||
|
||||
|
||||
class Splitter(Protocol):
|
||||
"""文档分块器接口 — 将文本拆分为带元数据的 chunk 列表.
|
||||
|
||||
每个 chunk 为 dict: {"content": str, "section_title": str, "heading_level": int, ...}
|
||||
"""
|
||||
|
||||
def split(self, text: str, source_file: str = "") -> list[dict]: ...
|
||||
|
||||
|
||||
class MarkdownSplitter:
|
||||
"""Markdown 混合分块器:先按标题拆,超长再按段落拆."""
|
||||
|
||||
@@ -167,11 +177,17 @@ class MarkdownSplitter:
|
||||
class DocumentIngestor:
|
||||
"""文档入库器: 读取 MD 文件 → 分块 → 嵌入 → 入库."""
|
||||
|
||||
def __init__(self, db: VectorDB, embedder: Embedder, collection_name: str):
|
||||
def __init__(
|
||||
self,
|
||||
db: VectorDB,
|
||||
embedder: Embedder,
|
||||
collection_name: str,
|
||||
splitter: Splitter | None = None,
|
||||
):
|
||||
self.db = db
|
||||
self.embedder = embedder
|
||||
self.collection_name = collection_name
|
||||
self.splitter = MarkdownSplitter()
|
||||
self.splitter = splitter or MarkdownSplitter()
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
|
||||
+4
-1
@@ -25,7 +25,10 @@ class Searcher:
|
||||
source_file: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""语义检索, 返回格式化结果列表."""
|
||||
query_embedding = self.embedder.embed([query])[0]
|
||||
embeddings = self.embedder.embed([query])
|
||||
if not embeddings:
|
||||
raise RuntimeError("嵌入器返回空结果, 无法进行检索")
|
||||
query_embedding = embeddings[0]
|
||||
|
||||
where_filter = None
|
||||
if source_file:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""安全工具 — 路径遍历防护、输入校验等."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def is_safe_path(path_str: str) -> bool:
|
||||
"""检查路径是否安全(拒绝绝对路径和 .. 穿越).
|
||||
|
||||
先检查原始路径中的 .. 组件(在 normpath 解析之前),
|
||||
再检查绝对路径。两个条件同时满足才返回 True。
|
||||
|
||||
Args:
|
||||
path_str: 用户提供的路径字符串
|
||||
|
||||
Returns:
|
||||
路径安全时返回 True
|
||||
"""
|
||||
# 1) 检测原始路径中的 .. 目录穿越组件
|
||||
parts = path_str.replace("\\", "/").split("/")
|
||||
if ".." in parts:
|
||||
return False
|
||||
|
||||
# 2) 检测标准化后的绝对路径
|
||||
normalized = os.path.normpath(path_str)
|
||||
if os.path.isabs(normalized):
|
||||
return False
|
||||
|
||||
return True
|
||||
+73
-32
@@ -3,52 +3,84 @@ import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from src.core.security import is_safe_path
|
||||
from src.server.auth import verify_api_key, rate_limiter
|
||||
from src.server.deps import get_state, AppState
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("md-vector-db")
|
||||
|
||||
|
||||
# -- 请求模型 --
|
||||
class IngestRequest(BaseModel):
|
||||
file_path: str | None = None
|
||||
content: str | None = None
|
||||
file_name: str | None = Field(default=None, max_length=255)
|
||||
collection: str | None = Field(
|
||||
default=None, max_length=128,
|
||||
description="目标 collection(默认使用配置文件中的 collection_name)",
|
||||
content: str | None = Field(
|
||||
default=None, max_length=500_000,
|
||||
description="Markdown 文本内容 (最多 500KB)",
|
||||
)
|
||||
file_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
collection: str | None = Field(
|
||||
default=None, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$",
|
||||
description="目标 collection(仅允许字母数字下划线连字符)",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_exclusive(self):
|
||||
"""确保 file_path 和 content 至少提供一个."""
|
||||
if not self.file_path and not self.content:
|
||||
raise ValueError("需要提供 file_path 或 content")
|
||||
return self
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str = Field(..., min_length=1, max_length=2000)
|
||||
top_k: int = Field(default=10, ge=1, le=100)
|
||||
collection: str | None = Field(
|
||||
default=None, max_length=128,
|
||||
description="检索的 collection(默认使用配置文件中的 collection_name)",
|
||||
default=None, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$",
|
||||
description="检索的 collection(仅允许字母数字下划线连字符)",
|
||||
)
|
||||
|
||||
|
||||
# -- 路径安全检查 --
|
||||
def _is_safe_path(path_str: str) -> bool:
|
||||
normalized = os.path.normpath(path_str)
|
||||
if os.path.isabs(normalized):
|
||||
return False
|
||||
if ".." in normalized.split(os.sep):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# -- App --
|
||||
app = FastAPI(title="md-vector-db", version="0.1.0")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用启动/关闭时的日志和状态管理."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
# 启动时检查 API Key 配置
|
||||
if not os.environ.get("MD_VECTOR_API_KEY"):
|
||||
logger.warning("MD_VECTOR_API_KEY 未设置 — API 认证已禁用, 建议在生产环境设置密钥")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="md-vector-db", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# CORS 中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
"""添加安全响应头."""
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -69,7 +101,10 @@ def health(state: AppState = Depends(get_state)):
|
||||
|
||||
|
||||
@app.get("/api/v1/collections")
|
||||
def list_collections(state: AppState = Depends(get_state)):
|
||||
def list_collections(
|
||||
state: AppState = Depends(get_state),
|
||||
_: bool = Depends(verify_api_key),
|
||||
):
|
||||
return {"collections": state.list_collections_with_stats()}
|
||||
|
||||
|
||||
@@ -82,21 +117,25 @@ def ingest_document(
|
||||
ingestor = state.get_ingestor(req.collection)
|
||||
try:
|
||||
if req.file_path:
|
||||
if not _is_safe_path(req.file_path):
|
||||
if not is_safe_path(req.file_path):
|
||||
raise HTTPException(status_code=400, detail="不允许的路径")
|
||||
path = Path(req.file_path).resolve()
|
||||
cwd = Path.cwd().resolve()
|
||||
if not str(path).startswith(str(cwd)):
|
||||
# 用 commonpath 替代字符串 startswith 比较 (Windows 大小写安全)
|
||||
try:
|
||||
common = Path(os.path.commonpath([str(path), str(cwd)]))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="不允许访问当前目录外的路径")
|
||||
if common != cwd:
|
||||
raise HTTPException(status_code=400, detail="不允许访问当前目录外的路径")
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"文件不存在: {path.name}")
|
||||
count = ingestor.ingest_file(str(path))
|
||||
file_name = path.name
|
||||
elif req.content:
|
||||
else:
|
||||
# content 模式 (file_path/content 互斥由 Pydantic 校验保证)
|
||||
file_name = req.file_name or "untitled.md"
|
||||
count = ingestor.ingest_content(req.content, file_name)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="需要提供 file_path 或 content")
|
||||
return {"status": "ok", "chunks": count, "file": file_name, "collection": ingestor.collection_name}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -123,6 +162,8 @@ def delete_document(
|
||||
_: bool = Depends(verify_api_key),
|
||||
collection: str | None = None,
|
||||
):
|
||||
if not file_name or len(file_name) > 512:
|
||||
raise HTTPException(status_code=400, detail="file_name 长度应在 1-512 之间")
|
||||
searcher = state.get_searcher(collection)
|
||||
deleted = searcher.delete_by_source(file_name)
|
||||
if not deleted:
|
||||
|
||||
+12
-3
@@ -1,19 +1,28 @@
|
||||
"""API 认证与安全中间件."""
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
logger = logging.getLogger("md-vector-db")
|
||||
|
||||
# -- API Key 认证 --
|
||||
EXPECTED_API_KEY = os.environ.get("MD_VECTOR_API_KEY", "")
|
||||
|
||||
|
||||
def verify_api_key(x_api_key: str | None = Header(None)):
|
||||
"""验证 API Key. 若未设置环境变量则跳过验证."""
|
||||
if EXPECTED_API_KEY and x_api_key != EXPECTED_API_KEY:
|
||||
raise HTTPException(status_code=401, detail="无效的 API Key")
|
||||
"""验证 API Key. 若未设置环境变量则跳过验证.
|
||||
|
||||
使用恒定时间比较防止时序攻击.
|
||||
"""
|
||||
if EXPECTED_API_KEY:
|
||||
if x_api_key is None or not hmac.compare_digest(x_api_key, EXPECTED_API_KEY):
|
||||
logger.warning("API Key 认证失败")
|
||||
raise HTTPException(status_code=401, detail="无效的 API Key")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+25
-13
@@ -1,5 +1,6 @@
|
||||
"""FastAPI 依赖注入 — 集中管理应用状态, 替代模块级全局变量."""
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
|
||||
from src.core.config import load_config
|
||||
@@ -31,31 +32,37 @@ class AppState:
|
||||
# 按 collection 懒加载 searcher / ingestor
|
||||
self._searchers: dict[str, Searcher] = {}
|
||||
self._ingestors: dict[str, DocumentIngestor] = {}
|
||||
self._cache_lock = threading.Lock()
|
||||
|
||||
def get_searcher(self, collection: str | None = None) -> Searcher:
|
||||
name = collection or self.default_collection
|
||||
if name not in self._searchers:
|
||||
self._searchers[name] = Searcher(self.db, self.embedder, name)
|
||||
return self._searchers[name]
|
||||
with self._cache_lock:
|
||||
if name not in self._searchers:
|
||||
self._searchers[name] = Searcher(self.db, self.embedder, name)
|
||||
return self._searchers[name]
|
||||
|
||||
def get_ingestor(self, collection: str | None = None) -> DocumentIngestor:
|
||||
name = collection or self.default_collection
|
||||
if name not in self._ingestors:
|
||||
self._ingestors[name] = DocumentIngestor(self.db, self.embedder, name)
|
||||
return self._ingestors[name]
|
||||
with self._cache_lock:
|
||||
if name not in self._ingestors:
|
||||
self._ingestors[name] = DocumentIngestor(self.db, self.embedder, name)
|
||||
return self._ingestors[name]
|
||||
|
||||
def list_collections_with_stats(self) -> list[dict]:
|
||||
"""列出所有 collection 及其统计(直接从 ChromaDB 查询)."""
|
||||
result = []
|
||||
try:
|
||||
for coll in self.db.client.list_collections():
|
||||
for coll in self.db.list_collections():
|
||||
result.append({"name": coll.name, "count": coll.count()})
|
||||
except Exception:
|
||||
logger.exception("列出集合失败")
|
||||
return result
|
||||
|
||||
def is_healthy(self) -> dict:
|
||||
"""真实健康检查: 验证 ChromaDB 和 Embedder 是否可用."""
|
||||
"""真实健康检查: 验证 ChromaDB 和 Embedder 是否可用.
|
||||
|
||||
对外仅返回 ok/degraded 状态,详细信息记入日志,不泄露内部路径.
|
||||
"""
|
||||
status = {"status": "ok", "checks": {}}
|
||||
try:
|
||||
count = self.db.get_or_create_collection(
|
||||
@@ -63,26 +70,31 @@ class AppState:
|
||||
).count()
|
||||
status["checks"]["chromadb"] = {"status": "ok", "count": count}
|
||||
except Exception as e:
|
||||
status["checks"]["chromadb"] = {"status": "error", "detail": str(e)}
|
||||
logger.error("ChromaDB 健康检查失败: %s", e)
|
||||
status["checks"]["chromadb"] = {"status": "error", "detail": "unavailable"}
|
||||
status["status"] = "degraded"
|
||||
try:
|
||||
dim = self.embedder.dimension
|
||||
status["checks"]["embedder"] = {"status": "ok", "dimension": dim}
|
||||
except Exception as e:
|
||||
status["checks"]["embedder"] = {"status": "error", "detail": str(e)}
|
||||
logger.error("Embedder 健康检查失败: %s", e)
|
||||
status["checks"]["embedder"] = {"status": "error", "detail": "unavailable"}
|
||||
status["status"] = "degraded"
|
||||
return status
|
||||
|
||||
|
||||
_state: AppState | None = None
|
||||
_state_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_state() -> AppState:
|
||||
"""获取应用状态单例 (懒初始化)."""
|
||||
"""获取应用状态单例 (懒初始化, 线程安全)."""
|
||||
global _state
|
||||
if _state is None:
|
||||
logger.info("初始化应用状态...")
|
||||
_state = AppState()
|
||||
with _state_lock:
|
||||
if _state is None:
|
||||
logger.info("初始化应用状态...")
|
||||
_state = AppState()
|
||||
return _state
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user