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 (
@@ -67,7 +72,7 @@ export default function App() {
{selectedKey ? ( - + ) : (
未选择文档 diff --git a/frontend/src/components/DocumentEditor.tsx b/frontend/src/components/DocumentEditor.tsx index fa8b051..b6d20c8 100644 --- a/frontend/src/components/DocumentEditor.tsx +++ b/frontend/src/components/DocumentEditor.tsx @@ -1,20 +1,23 @@ import { useEffect, useState } from 'react'; import Editor from '@monaco-editor/react'; -import { getDocument, saveDocument } from '../api/client'; +import { deleteDocument, getDocument, saveDocument } from '../api/client'; interface DocumentEditorProps { documentKey: string; onDirtyChange?: (isDirty: boolean) => void; + onDeleted: () => void; } -export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentEditorProps) { +export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }: DocumentEditorProps) { const [content, setContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); const [contentType, setContentType] = useState('text/markdown'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); const [loadError, setLoadError] = useState(null); const [saveError, setSaveError] = useState(null); + const [deleteError, setDeleteError] = useState(null); useEffect(() => { setLoading(true); @@ -57,6 +60,23 @@ export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentE }); } + function handleDelete() { + if (!window.confirm('确定要删除这篇文档吗?此操作无法撤销。')) { + return; + } + setDeleting(true); + setDeleteError(null); + deleteDocument(documentKey) + .then(() => { + setDeleting(false); + onDeleted(); + }) + .catch(() => { + setDeleteError('删除失败,请重试'); + setDeleting(false); + }); + } + if (loading) { return
加载中…
; } @@ -84,6 +104,17 @@ export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentE )} + {deleteError && ( + + {deleteError} + + + )} + diff --git a/frontend/tests/components/DocumentEditor.test.tsx b/frontend/tests/components/DocumentEditor.test.tsx index 93df960..cf02437 100644 --- a/frontend/tests/components/DocumentEditor.test.tsx +++ b/frontend/tests/components/DocumentEditor.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { vi, test, expect, beforeEach } from 'vitest'; import DocumentEditor from '../../src/components/DocumentEditor'; -import { getDocument, saveDocument } from '../../src/api/client'; +import { deleteDocument, getDocument, saveDocument } from '../../src/api/client'; vi.mock('../../src/api/client'); @@ -18,6 +18,7 @@ vi.mock('@monaco-editor/react', () => ({ beforeEach(() => { vi.mocked(getDocument).mockReset(); vi.mocked(saveDocument).mockReset(); + vi.mocked(deleteDocument).mockReset(); }); test('shows a loading state, then loads and displays document content', async () => { @@ -27,7 +28,7 @@ test('shows a loading state, then loads and displays document content', async () contentType: 'text/markdown', }); - render(); + render(); expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument(); @@ -52,7 +53,7 @@ test('typing marks the document dirty and saving clears the dirty state', async }); const user = userEvent.setup(); - render(); + render(); const textarea = await screen.findByTestId('monaco-editor-mock'); await user.clear(textarea); @@ -84,7 +85,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error, }); const user = userEvent.setup(); - render(); + render(); const textarea = await screen.findByTestId('monaco-editor-mock'); await user.clear(textarea); @@ -117,7 +118,7 @@ test('calls onDirtyChange when dirty state changes', async () => { const onDirtyChange = vi.fn(); const user = userEvent.setup(); - render(); + render(); const textarea = await screen.findByTestId('monaco-editor-mock'); @@ -131,3 +132,72 @@ test('calls onDirtyChange when dirty state changes', async () => { expect(onDirtyChange).toHaveBeenLastCalledWith(true); }); }); + +test('clicking delete confirms, calls deleteDocument, and calls onDeleted on success', async () => { + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + }); + vi.mocked(deleteDocument).mockResolvedValue(undefined); + const onDeleted = vi.fn(); + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + render(); + + await screen.findByTestId('monaco-editor-mock'); + const deleteButton = screen.getByRole('button', { name: '删除' }); + await user.click(deleteButton); + + expect(confirmSpy).toHaveBeenCalledWith('确定要删除这篇文档吗?此操作无法撤销。'); + await waitFor(() => { + expect(deleteDocument).toHaveBeenCalledWith('产品文档/架构设计.md'); + }); + await waitFor(() => { + expect(onDeleted).toHaveBeenCalled(); + }); +}); + +test('clicking delete does nothing if the confirmation is declined', async () => { + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + }); + const onDeleted = vi.fn(); + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + await screen.findByTestId('monaco-editor-mock'); + const deleteButton = screen.getByRole('button', { name: '删除' }); + await user.click(deleteButton); + + expect(deleteDocument).not.toHaveBeenCalled(); + expect(onDeleted).not.toHaveBeenCalled(); +}); + +test('shows an inline error and does not call onDeleted when delete fails', async () => { + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + }); + vi.mocked(deleteDocument).mockRejectedValue(new Error('network error')); + const onDeleted = vi.fn(); + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + render(); + + await screen.findByTestId('monaco-editor-mock'); + const deleteButton = screen.getByRole('button', { name: '删除' }); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByTestId('delete-error')).toBeInTheDocument(); + }); + expect(onDeleted).not.toHaveBeenCalled(); +});