diff --git a/backend/app/config.py b/backend/app/config.py index aa2aa32..cb9b4b1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,7 +10,7 @@ class Settings: self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700") self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "") self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents") - self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",") + self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",") settings = Settings() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 07b7fcd..51bb1d9 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -10,20 +10,27 @@ from app.search_client import SearchClient @lru_cache -def get_document_service() -> DocumentService: - minio_sdk = Minio( +def _get_minio_sdk() -> Minio: + return Minio( settings.minio_endpoint, access_key=settings.minio_access_key, secret_key=settings.minio_secret_key, secure=False, ) - meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) - minio_client = MinioClient(minio_sdk, settings.minio_bucket) - search_client = SearchClient(meili_sdk, settings.meilisearch_index) + + +@lru_cache +def _get_meilisearch_sdk() -> meilisearch.Client: + return meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) + + +@lru_cache +def get_document_service() -> DocumentService: + minio_client = MinioClient(_get_minio_sdk(), settings.minio_bucket) + search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index) return DocumentService(minio_client, search_client) @lru_cache def get_search_client() -> SearchClient: - meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) - return SearchClient(meili_sdk, settings.meilisearch_index) + return SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index) diff --git a/backend/app/document_service.py b/backend/app/document_service.py index 4704903..9bdb4e1 100644 --- a/backend/app/document_service.py +++ b/backend/app/document_service.py @@ -26,6 +26,17 @@ class FileTooLargeError(Exception): super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB") +class InvalidPathError(Exception): + def __init__(self, path: str): + self.path = path + super().__init__(f"invalid path: {path}") + + +def _validate_path(path: str) -> None: + if path.startswith("/") or any(segment == ".." for segment in path.split("/")): + raise InvalidPathError(path) + + def _extension_of(key: str) -> str: name = key.rsplit("/", 1)[-1] if "." not in name: @@ -68,6 +79,7 @@ class DocumentService: return {"folders": folders, "files": files} def get_document(self, key: str) -> dict: + _validate_path(key) try: data = self._minio_client.get_object(key) except FileNotFoundError as exc: @@ -94,6 +106,7 @@ class DocumentService: return text def save_document(self, key: str, content: bytes, content_type: str) -> dict: + _validate_path(key) self._minio_client.put_object(key, content, content_type) try: text = self._extract_text(content, content_type) @@ -105,6 +118,7 @@ class DocumentService: return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()} def delete_document(self, key: str) -> None: + _validate_path(key) self._minio_client.delete_object(key) try: self._search_client.delete_document(key) @@ -112,6 +126,7 @@ class DocumentService: logger.exception("Failed to delete search index entry for %s", key) def upload_document(self, key: str, data: bytes, content_type: str) -> dict: + _validate_path(key) extension = _extension_of(key) if extension not in ALLOWED_UPLOAD_EXTENSIONS: raise UnsupportedFileTypeError(extension) diff --git a/backend/app/main.py b/backend/app/main.py index 504694b..0fb0088 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,14 @@ +import logging + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings +from app.dependencies import _get_minio_sdk from app.routers import documents, search +logger = logging.getLogger(__name__) + app = FastAPI(title="DocHub API") app.add_middleware( @@ -18,6 +23,16 @@ app.include_router(documents.router) app.include_router(search.router) +@app.on_event("startup") +def ensure_bucket_exists(): + try: + minio_sdk = _get_minio_sdk() + if not minio_sdk.bucket_exists(settings.minio_bucket): + minio_sdk.make_bucket(settings.minio_bucket) + except Exception: + logger.warning("Could not verify/create MinIO bucket '%s' on startup", settings.minio_bucket, exc_info=True) + + @app.get("/health") def health(): return {"status": "ok"} diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index bc255a9..e7b8c85 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -6,6 +6,7 @@ from app.document_service import ( DocumentNotFoundError, DocumentService, FileTooLargeError, + InvalidPathError, UnsupportedFileTypeError, ) @@ -28,18 +29,26 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi return service.get_document(key) except DocumentNotFoundError: raise HTTPException(status_code=404, detail="Document not found") + except InvalidPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) @router.put("/api/documents/{key:path}") def save_document( key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service) ): - return service.save_document(key, body.content.encode("utf-8"), body.contentType) + try: + return service.save_document(key, body.content.encode("utf-8"), body.contentType) + except InvalidPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) @router.delete("/api/documents/{key:path}", status_code=204) def delete_document(key: str, service: DocumentService = Depends(get_document_service)): - service.delete_document(key) + try: + service.delete_document(key) + except InvalidPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) return None @@ -54,5 +63,5 @@ async def upload_document( content_type = file.content_type or "application/octet-stream" try: return service.upload_document(key, data, content_type) - except (UnsupportedFileTypeError, FileTooLargeError) as exc: + except (UnsupportedFileTypeError, FileTooLargeError, InvalidPathError) as exc: raise HTTPException(status_code=400, detail=str(exc)) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..1d9e44d --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,17 @@ +from app.config import Settings + + +def test_cors_origins_defaults_to_localhost_dev_port(monkeypatch): + monkeypatch.delenv("CORS_ORIGINS", raising=False) + + settings = Settings() + + assert settings.cors_origins == ["http://localhost:5173"] + + +def test_cors_origins_can_be_overridden_to_wildcard(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "*") + + settings = Settings() + + assert settings.cors_origins == ["*"] diff --git a/backend/tests/test_document_service.py b/backend/tests/test_document_service.py index aad84a8..50425db 100644 --- a/backend/tests/test_document_service.py +++ b/backend/tests/test_document_service.py @@ -4,6 +4,7 @@ from app.document_service import ( DocumentService, DocumentNotFoundError, FileTooLargeError, + InvalidPathError, MAX_UPLOAD_SIZE_BYTES, UnsupportedFileTypeError, ) @@ -196,6 +197,40 @@ def test_upload_document_rejects_oversized_file(): assert "产品文档/大文件.md" not in minio_client.stored +def test_get_document_rejects_path_traversal_key(): + service = DocumentService(FakeMinioClient(), FakeSearchClient()) + + with pytest.raises(InvalidPathError): + service.get_document("../../etc/passwd") + + +def test_save_document_rejects_path_traversal_key(): + minio_client = FakeMinioClient() + service = DocumentService(minio_client, FakeSearchClient()) + + with pytest.raises(InvalidPathError): + service.save_document("../../etc/passwd", b"data", "text/plain") + + assert "../../etc/passwd" not in minio_client.stored + + +def test_delete_document_rejects_path_traversal_key(): + service = DocumentService(FakeMinioClient(), FakeSearchClient()) + + with pytest.raises(InvalidPathError): + service.delete_document("../../etc/passwd") + + +def test_upload_document_rejects_path_traversal_key(): + minio_client = FakeMinioClient() + service = DocumentService(minio_client, FakeSearchClient()) + + with pytest.raises(InvalidPathError): + service.upload_document("../../etc/passwd", b"data", "text/plain") + + assert "../../etc/passwd" not in minio_client.stored + + def test_upload_document_accepts_allowed_image_extension(): minio_client = FakeMinioClient() service = DocumentService(minio_client, FakeSearchClient()) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 6371b7b..e0f5b76 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -1,6 +1,9 @@ +from unittest.mock import MagicMock + import pytest from fastapi.testclient import TestClient +import app.main as main_module from app.main import app from app.document_service import DocumentNotFoundError from app.dependencies import get_document_service, get_search_client @@ -14,6 +17,40 @@ def test_health_returns_ok(): assert response.json() == {"status": "ok"} +def test_startup_creates_bucket_when_missing(monkeypatch): + fake_minio = MagicMock() + fake_minio.bucket_exists.return_value = False + monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio) + + with TestClient(app): + pass + + fake_minio.bucket_exists.assert_called() + fake_minio.make_bucket.assert_called() + + +def test_startup_does_not_create_bucket_when_it_already_exists(monkeypatch): + fake_minio = MagicMock() + fake_minio.bucket_exists.return_value = True + monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio) + + with TestClient(app): + pass + + fake_minio.make_bucket.assert_not_called() + + +def test_startup_does_not_crash_app_when_minio_is_unreachable(monkeypatch): + def raise_connection_error(): + raise ConnectionError("minio unreachable") + + monkeypatch.setattr(main_module, "_get_minio_sdk", raise_connection_error) + + with TestClient(app) as test_client: + response = test_client.get("/health") + assert response.status_code == 200 + + class FakeDocumentService: def __init__(self): self.storage = {} diff --git a/backend/tests/test_routers_documents.py b/backend/tests/test_routers_documents.py index be1eef7..ea7408d 100644 --- a/backend/tests/test_routers_documents.py +++ b/backend/tests/test_routers_documents.py @@ -3,7 +3,12 @@ from fastapi.testclient import TestClient from app.main import app from app.dependencies import get_document_service -from app.document_service import DocumentNotFoundError, FileTooLargeError, UnsupportedFileTypeError +from app.document_service import ( + DocumentNotFoundError, + FileTooLargeError, + InvalidPathError, + UnsupportedFileTypeError, +) class FakeDocumentService: @@ -23,16 +28,24 @@ class FakeDocumentService: def get_document(self, key): if key == "不存在/文档.md": raise DocumentNotFoundError(key) + if ".." in key.split("/"): + raise InvalidPathError(key) return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"} def save_document(self, key, content, content_type): + if ".." in key.split("/"): + raise InvalidPathError(key) self.saved.append((key, content, content_type)) return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"} def delete_document(self, key): + if ".." in key.split("/"): + raise InvalidPathError(key) self.deleted.append(key) def upload_document(self, key, data, content_type): + if ".." in key.split("/"): + raise InvalidPathError(key) if key.endswith(".exe"): raise UnsupportedFileTypeError(".exe") if len(data) > 1024: @@ -119,3 +132,30 @@ def test_upload_document_returns_400_for_oversized_file(client): ) assert response.status_code == 400 assert "file too large" in response.json()["detail"] + + +def test_get_document_returns_400_for_path_traversal_key(client): + response = client.get("/api/documents/..%2F..%2Fetc%2Fpasswd") + assert response.status_code == 400 + + +def test_put_document_returns_400_for_path_traversal_key(client): + response = client.put( + "/api/documents/..%2F..%2Fetc%2Fpasswd", + json={"content": "x", "contentType": "text/plain"}, + ) + assert response.status_code == 400 + + +def test_delete_document_returns_400_for_path_traversal_key(client): + response = client.delete("/api/documents/..%2F..%2Fetc%2Fpasswd") + assert response.status_code == 400 + + +def test_upload_document_returns_400_for_path_traversal_in_path(client): + response = client.post( + "/api/upload", + data={"path": "../../etc"}, + files={"file": ("passwd.md", b"data", "text/markdown")}, + ) + assert response.status_code == 400 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 555449b..08ab843 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,11 @@ export default function App() { setRefreshKey((prev) => prev + 1); } + function handleDeleted() { + setSelectedKey(null); + setRefreshKey((prev) => prev + 1); + } + return (