Adds a folder picker (webkitdirectory) and drag-and-drop directory
support (File System Entries API), preserving relative paths via
a new optional relative_path field on the upload endpoint.
Single-node deployment doesn't benefit from S3 abstraction overhead;
LocalFileClient keeps the same interface DocumentService depends on,
backed by a STORAGE_DIR volume mount instead of a separate Garage service.
The backend no longer mentions MinIO anywhere: MinioClient -> GarageClient
(minio_client.py -> garage_client.py), MINIO_* env vars -> GARAGE_*, and
the `minio` Python package is replaced with `boto3` (AWS's generic S3
client) since Garage is the only storage backend this project targets
now. S3 API semantics are unchanged, so document_service.py's behavior
is identical — this is a naming and dependency swap, not a logic change.
Also updates test fixtures/names (FakeMinioClient -> FakeGarageClient,
FakeMinioSDK -> FakeS3SDK) and storage/README.md, which no longer frames
Garage as a MinIO replacement (that migration note has served its purpose
now that no MinIO reference remains in the codebase).
Backend: 54/54 tests passing. Frontend: 38/38 tests passing (unaffected,
verified for regressions since it talks to the backend only through the
unchanged REST API).
MinIO's community edition has been trimming features and its licensing
direction has grown less predictable. Garage (AGPL, purpose-built for
self-hosting) is a drop-in S3-compatible replacement — no changes needed
to backend/app/minio_client.py, which talks to the S3 API generically.
Deployed independently from the app stack (storage/docker-compose.yml),
same separation MinIO had. Single-node config (replication_factor = 1)
matching this project's learning/small-team scope. init-garage.sh handles
the one-time layout assignment + bucket creation Garage requires that
MinIO didn't (MinIO auto-creates buckets via API; Garage needs an
explicit `garage bucket create` step).
MinIO stays out of this compose file per the design spec — it's deployed
independently, with MINIO_ENDPOINT overridable via env for wherever it
actually lives. Meilisearch uses the official image directly (no
Dockerfile needed); frontend's VITE_API_BASE_URL is left empty since
nginx.conf already proxies /api/ to the backend service.
- Default CORS_ORIGINS to http://localhost:5173 instead of "*" (still overridable)
- Reject ".." and leading "/" in document keys/paths across get/save/delete/upload,
mapped to HTTP 400 via new InvalidPathError
- Add a delete button to DocumentEditor wired to deleteDocument + onDeleted,
giving the existing DELETE API an actual UI entry point
- Share a single lru_cache'd Meilisearch (and Minio) SDK client instance instead
of constructing duplicates in dependencies.py
- Add a startup check that creates the MinIO bucket if missing, tolerating
MinIO being unreachable at boot without crashing the app
Adds frontend/src/styles.css extracted from the confirmed prototype
(paper/ink/pine palette, Fraunces/Inter/IBM Plex Mono type system,
catalog-tag motif) and makes the minimal JSX adjustments needed for
each component to pick up the styling: wordmark heading, search icon,
upload button icon + modal header/footer, folder chevrons + file
icons, doc-path + save-status in the editor, catalog tags and
no-results treatment in search results.
Also adds .gitignore (venv/node_modules/dist/pycache) and commits the
frontend package-lock.json that was previously untracked.
- client.test.ts: use globalThis.fetch instead of global.fetch and drop the
unused describe import, fixing the broken `npm run build`
- DocumentEditor: separate load-error (replaces view) from save-error (shown
inline, editor and unsaved content stay mounted so a failed save no longer
destroys in-progress edits); expose dirty state via onDirtyChange
- App: prompt via window.confirm before switching documents while dirty;
increment a refreshKey on successful upload so FileExplorer re-fetches the
tree; track isSearching so the "no results" overlay doesn't flash during
the search debounce window
- FileExplorer: accept a refreshKey prop to force a root-listing re-fetch
- SearchBar: convert to a controlled component (value prop) so App can clear
the input after jumping to a search result, fixing input/state desync
- UploadButton: surface upload failures inline instead of swallowing the
rejected promise, and keep the modal open for retry
- main.py: remove duplicate /health endpoint definition
- text_extract.py: HTML-escape extracted plain text before indexing so raw
markup in uploaded documents can't render as live HTML via search snippets
(stored XSS fix)
- document_service.py: base64-encode content when UTF-8 decoding fails
instead of raising UnicodeDecodeError (500) on binary files; add upload
validation for file extension (allowlist) and size (10MB max)
- routers/documents.py: map new validation errors to HTTP 400 with a clear
detail message instead of letting them 500
- add/extend tests covering escaping, binary get_document, and upload
validation rejection + acceptance paths