# Story: Archive upload sources **Phase:** `49_archive_upload_sources` · **E2E:** `tests/e2e/test_archive_upload_sources.py` ## Narrative As **the admin (owner)**, the "Add a local directory" form makes me type server paths — but the directories I want to index often live on *another machine*. I want to **upload a tarball or zipfile** on the Sources (`/git-sources.html`) page: the server **unpacks it and scans it** (the content is indexed, visible in the RAG catalog immediately after the upload settles). Re-uploading the **same filename** must **replace that source in place** — same folder, same list row, previous content overwritten — never a second folder or a duplicate row. - **Given** I am signed in as admin, on the Sources page, and I have `homelab.tar.gz` (containing `k3s.md`, `gitlab.md`) - **When** I upload it - **Then** it is unpacked to a server folder named after the archive (`homelab`), imported (added/updated/pruned counts shown), appears in the source list with the Local badge, and its documents show up in the RAG catalog (`/sources.html`). - **Given** I later edit the tarball (drop `gitlab.md`, add `caddy.md`) and re-upload **`homelab.tar.gz`** (same name) - **When** the upload settles - **Then** there is still exactly one `homelab` folder and one list row; `gitlab.md` is pruned from the index, `caddy.md` is indexed, `k3s.md` is unchanged. ## Acceptance criteria 1. `POST /api/git-sources/upload` (multipart `file`, admin-only — anonymous 403 like the rest of the router): accepts `.tar`, `.tar.gz`, `.tgz`, `.zip` (else 422 naming the accepted set); derives the source name from the filename minus the archive suffix (`homelab.tar.gz` → `homelab`); rejects empty/unsafe names (422); caps compressed upload AND extracted bytes at `BOR_UPLOAD_MAX_MB` (default 512 — new settings `BOR_UPLOAD_DIR`, default `~/bor-sources/uploads`, and `BOR_UPLOAD_MAX_MB`); blocks zip-slip / tar-slip (absolute members, `..`, symlink/hardlink escapes, device files) with a 422 and **no partial state** — a failed upload never touches an existing folder, row, or the KB. 2. Successful upload: unpack → atomic swap-in of the folder → the `git_sources` row is upserted by path (`kind=local`, no new row when the path exists) → fail-fast model check (phase 41) → `import_sources([folder], prune=True)` (single source) → `regenerate_overview` when the KB changed → **200 with the same count keys as the sync detail** (`files, added, updated, unchanged, pruned, errors, chunks, overview`); one upload at a time (409 while a run is in flight — the phase-32 pattern); a per-upload log line (PLAN §9). 3. Re-upload of the same filename replaces the folder's content in place (temp unpack + rename swap — no missing window) and prunes files that left the archive; no duplicate folder, no duplicate row. 4. The page: the "Add a local directory" form is **removed**; a labeled archive upload form (file input `accept=".tar,.tar.gz,.tgz,.zip"`, inline error `role=alert`, result line `role=status`, never-stale button per §7.4 showing the counts) takes its place; the hint and table caption mention upload+scan and in-place replace; existing Local rows (incl. pre-existing hand-added dirs) still list/remove. The `POST /api/git-sources` `kind=local` API contract is unchanged (the capability survives via the API — no regression). 5. The phase-38 story E2E (`test_local_directory_sources.py`) is rewritten to add local sources via the API (`page.request.post`) instead of the removed form — its other assertions (Local badge, sync-import, prune-remove, 422 naming the path) stand. 6. Unit + integration green, `app/` coverage >90%, story E2E green in isolation, ruff + pyright clean, one `--no-gpg-sign` commit. ## Playwright Mapping Rule `tests/e2e/test_archive_upload_sources.py` — one story, one file, run in isolation: the admin uploads a real (test-built) tarball through the page's file input → counts shown + Local-badged row named after the archive stem + documents visible via the catalog; re-upload of the same filename (modified archive) → still one row/folder, dropped file pruned, new file indexed; a non-archive file gets an inline error; the local-directory form is absent; anonymous still gets the gate.