From ce420cbb0e5252e35b9ba8d4113400e05f876fb0 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 09:39:41 +0100 Subject: [PATCH] upload volledige repo --- PLAN.md | 365 ++++++++++++++ container/Containerfile | 27 + project_docs/AGENTS.md | 26 + project_docs/API_GOLDEN.md | 137 +++++ project_docs/BACKEND_V1_CONSOLIDATION.md | 87 ++++ project_docs/BOOKMARKS_V1_CONSOLIDATION.md | 21 + project_docs/CHANGE_POLICY.md | 22 + project_docs/COPY_MOVE_TASKS_DESIGN.md | 274 ++++++++++ project_docs/COPY_V1_CONSOLIDATION.md | 50 ++ project_docs/MKDIR_RENAME_CONSOLIDATION.md | 49 ++ project_docs/PRODUCT_SPEC.md | 123 +++++ project_docs/SAFE_FILES.md | 20 + project_docs/STEP1_5_CONSOLIDATION.md | 72 +++ project_docs/TEST_STRATEGY.md | 34 ++ project_docs/UI_DUAL_PANE_DESIGN.md | 233 +++++++++ project_docs/UI_VISION_MC.md | 141 ++++++ webui/backend/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 179 bytes .../backend/__pycache__/main.cpython-313.pyc | Bin 0 -> 191 bytes webui/backend/app/__init__.py | 1 + .../app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 187 bytes .../app/__pycache__/config.cpython-313.pyc | Bin 0 -> 1845 bytes .../__pycache__/dependencies.cpython-313.pyc | Bin 0 -> 3811 bytes .../app/__pycache__/logging.cpython-313.pyc | Bin 0 -> 477 bytes .../app/__pycache__/main.cpython-313.pyc | Bin 0 -> 2580 bytes .../__pycache__/tasks_runner.cpython-313.pyc | Bin 0 -> 4567 bytes webui/backend/app/api/__init__.py | 1 + .../api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 194 bytes .../api/__pycache__/errors.cpython-313.pyc | Bin 0 -> 682 bytes .../routes_bookmarks.cpython-313.pyc | Bin 0 -> 1765 bytes .../__pycache__/routes_browse.cpython-313.pyc | Bin 0 -> 967 bytes .../__pycache__/routes_copy.cpython-313.pyc | Bin 0 -> 1043 bytes .../__pycache__/routes_files.cpython-313.pyc | Bin 0 -> 1892 bytes .../__pycache__/routes_move.cpython-313.pyc | Bin 0 -> 1043 bytes .../__pycache__/routes_tasks.cpython-313.pyc | Bin 0 -> 1162 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 5631 bytes webui/backend/app/api/errors.py | 11 + webui/backend/app/api/routes_bookmarks.py | 35 ++ webui/backend/app/api/routes_browse.py | 18 + webui/backend/app/api/routes_copy.py | 17 + webui/backend/app/api/routes_files.py | 33 ++ webui/backend/app/api/routes_move.py | 17 + webui/backend/app/api/routes_tasks.py | 19 + webui/backend/app/api/schemas.py | 126 +++++ webui/backend/app/config.py | 39 ++ webui/backend/app/db/__init__.py | 1 + .../db/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 162 bytes .../bookmark_repository.cpython-313.pyc | Bin 0 -> 5406 bytes .../task_repository.cpython-313.pyc | Bin 0 -> 10910 bytes webui/backend/app/db/bookmark_repository.py | 94 ++++ webui/backend/app/db/task_repository.py | 241 +++++++++ webui/backend/app/dependencies.py | 75 +++ webui/backend/app/fs/__init__.py | 1 + .../fs/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 194 bytes .../filesystem_adapter.cpython-313.pyc | Bin 0 -> 4357 bytes webui/backend/app/fs/filesystem_adapter.py | 61 +++ webui/backend/app/logging.py | 8 + webui/backend/app/main.py | 51 ++ webui/backend/app/security/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 193 bytes .../__pycache__/path_guard.cpython-313.pyc | Bin 0 -> 6456 bytes webui/backend/app/security/path_guard.py | 139 ++++++ webui/backend/app/services/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 190 bytes .../bookmark_service.cpython-313.pyc | Bin 0 -> 2882 bytes .../browse_service.cpython-313.pyc | Bin 0 -> 2168 bytes .../copy_task_service.cpython-313.pyc | Bin 0 -> 3868 bytes .../file_ops_service.cpython-313.pyc | Bin 0 -> 5934 bytes .../move_task_service.cpython-313.pyc | Bin 0 -> 3926 bytes .../__pycache__/task_service.cpython-313.pyc | Bin 0 -> 2246 bytes .../backend/app/services/bookmark_service.py | 53 ++ webui/backend/app/services/browse_service.py | 36 ++ .../backend/app/services/copy_task_service.py | 77 +++ .../backend/app/services/file_ops_service.py | 134 +++++ .../backend/app/services/move_task_service.py | 77 +++ webui/backend/app/services/task_service.py | 42 ++ webui/backend/app/tasks_runner.py | 125 +++++ webui/backend/main.py | 3 + webui/backend/requirements.txt | 6 + webui/backend/tests/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 154 bytes webui/backend/tests/golden/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 161 bytes .../test_api_bookmarks_golden.cpython-313.pyc | Bin 0 -> 8593 bytes .../test_api_browse_golden.cpython-313.pyc | Bin 0 -> 6276 bytes .../test_api_copy_golden.cpython-313.pyc | Bin 0 -> 12915 bytes .../test_api_errors_golden.cpython-313.pyc | Bin 0 -> 5902 bytes .../test_api_file_ops_golden.cpython-313.pyc | Bin 0 -> 13562 bytes .../test_api_move_golden.cpython-313.pyc | Bin 0 -> 13793 bytes .../test_api_tasks_golden.cpython-313.pyc | Bin 0 -> 10213 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 0 -> 3581 bytes .../tests/golden/test_api_bookmarks_golden.py | 157 ++++++ .../tests/golden/test_api_browse_golden.py | 105 ++++ .../tests/golden/test_api_copy_golden.py | 211 ++++++++ .../tests/golden/test_api_errors_golden.py | 110 ++++ .../tests/golden/test_api_file_ops_golden.py | 323 ++++++++++++ .../tests/golden/test_api_move_golden.py | 215 ++++++++ .../tests/golden/test_api_tasks_golden.py | 261 ++++++++++ .../tests/golden/test_ui_smoke_golden.py | 49 ++ webui/backend/tests/unit/__init__.py | 0 .../unit/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 159 bytes .../test_bookmark_repository.cpython-313.pyc | Bin 0 -> 2956 bytes .../test_path_guard.cpython-313.pyc | Bin 0 -> 4410 bytes .../test_task_repository.cpython-313.pyc | Bin 0 -> 3071 bytes .../tests/unit/test_bookmark_repository.py | 38 ++ webui/backend/tests/unit/test_path_guard.py | 57 +++ .../tests/unit/test_task_repository.py | 63 +++ webui/html/app.js | 468 ++++++++++++++++++ webui/html/index.html | 78 +++ webui/html/style.css | 300 +++++++++++ 110 files changed, 5660 insertions(+) create mode 100644 PLAN.md create mode 100644 container/Containerfile create mode 100644 project_docs/AGENTS.md create mode 100644 project_docs/API_GOLDEN.md create mode 100644 project_docs/BACKEND_V1_CONSOLIDATION.md create mode 100644 project_docs/BOOKMARKS_V1_CONSOLIDATION.md create mode 100644 project_docs/CHANGE_POLICY.md create mode 100644 project_docs/COPY_MOVE_TASKS_DESIGN.md create mode 100644 project_docs/COPY_V1_CONSOLIDATION.md create mode 100644 project_docs/MKDIR_RENAME_CONSOLIDATION.md create mode 100644 project_docs/PRODUCT_SPEC.md create mode 100644 project_docs/SAFE_FILES.md create mode 100644 project_docs/STEP1_5_CONSOLIDATION.md create mode 100644 project_docs/TEST_STRATEGY.md create mode 100644 project_docs/UI_DUAL_PANE_DESIGN.md create mode 100644 project_docs/UI_VISION_MC.md create mode 100644 webui/backend/__init__.py create mode 100644 webui/backend/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/__pycache__/main.cpython-313.pyc create mode 100644 webui/backend/app/__init__.py create mode 100644 webui/backend/app/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/app/__pycache__/config.cpython-313.pyc create mode 100644 webui/backend/app/__pycache__/dependencies.cpython-313.pyc create mode 100644 webui/backend/app/__pycache__/logging.cpython-313.pyc create mode 100644 webui/backend/app/__pycache__/main.cpython-313.pyc create mode 100644 webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc create mode 100644 webui/backend/app/api/__init__.py create mode 100644 webui/backend/app/api/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/errors.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/routes_bookmarks.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/routes_browse.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/routes_copy.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/routes_tasks.cpython-313.pyc create mode 100644 webui/backend/app/api/__pycache__/schemas.cpython-313.pyc create mode 100644 webui/backend/app/api/errors.py create mode 100644 webui/backend/app/api/routes_bookmarks.py create mode 100644 webui/backend/app/api/routes_browse.py create mode 100644 webui/backend/app/api/routes_copy.py create mode 100644 webui/backend/app/api/routes_files.py create mode 100644 webui/backend/app/api/routes_move.py create mode 100644 webui/backend/app/api/routes_tasks.py create mode 100644 webui/backend/app/api/schemas.py create mode 100644 webui/backend/app/config.py create mode 100644 webui/backend/app/db/__init__.py create mode 100644 webui/backend/app/db/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/app/db/__pycache__/bookmark_repository.cpython-313.pyc create mode 100644 webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc create mode 100644 webui/backend/app/db/bookmark_repository.py create mode 100644 webui/backend/app/db/task_repository.py create mode 100644 webui/backend/app/dependencies.py create mode 100644 webui/backend/app/fs/__init__.py create mode 100644 webui/backend/app/fs/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc create mode 100644 webui/backend/app/fs/filesystem_adapter.py create mode 100644 webui/backend/app/logging.py create mode 100644 webui/backend/app/main.py create mode 100644 webui/backend/app/security/__init__.py create mode 100644 webui/backend/app/security/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/app/security/__pycache__/path_guard.cpython-313.pyc create mode 100644 webui/backend/app/security/path_guard.py create mode 100644 webui/backend/app/services/__init__.py create mode 100644 webui/backend/app/services/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/app/services/__pycache__/bookmark_service.cpython-313.pyc create mode 100644 webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc create mode 100644 webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc create mode 100644 webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc create mode 100644 webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc create mode 100644 webui/backend/app/services/__pycache__/task_service.cpython-313.pyc create mode 100644 webui/backend/app/services/bookmark_service.py create mode 100644 webui/backend/app/services/browse_service.py create mode 100644 webui/backend/app/services/copy_task_service.py create mode 100644 webui/backend/app/services/file_ops_service.py create mode 100644 webui/backend/app/services/move_task_service.py create mode 100644 webui/backend/app/services/task_service.py create mode 100644 webui/backend/app/tasks_runner.py create mode 100644 webui/backend/main.py create mode 100644 webui/backend/requirements.txt create mode 100644 webui/backend/tests/__init__.py create mode 100644 webui/backend/tests/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__init__.py create mode 100644 webui/backend/tests/golden/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_bookmarks_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_errors_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_bookmarks_golden.py create mode 100644 webui/backend/tests/golden/test_api_browse_golden.py create mode 100644 webui/backend/tests/golden/test_api_copy_golden.py create mode 100644 webui/backend/tests/golden/test_api_errors_golden.py create mode 100644 webui/backend/tests/golden/test_api_file_ops_golden.py create mode 100644 webui/backend/tests/golden/test_api_move_golden.py create mode 100644 webui/backend/tests/golden/test_api_tasks_golden.py create mode 100644 webui/backend/tests/golden/test_ui_smoke_golden.py create mode 100644 webui/backend/tests/unit/__init__.py create mode 100644 webui/backend/tests/unit/__pycache__/__init__.cpython-313.pyc create mode 100644 webui/backend/tests/unit/__pycache__/test_bookmark_repository.cpython-313.pyc create mode 100644 webui/backend/tests/unit/__pycache__/test_path_guard.cpython-313.pyc create mode 100644 webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc create mode 100644 webui/backend/tests/unit/test_bookmark_repository.py create mode 100644 webui/backend/tests/unit/test_path_guard.py create mode 100644 webui/backend/tests/unit/test_task_repository.py create mode 100644 webui/html/app.js create mode 100644 webui/html/index.html create mode 100644 webui/html/style.css diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..4f99fcd --- /dev/null +++ b/PLAN.md @@ -0,0 +1,365 @@ +# PLAN.md + +Notitie: `project_docs/SPARRING_GPT_PROJECT_PROMPT.md` is niet gevonden in deze repository op 10 maart 2026. Dit plan is opgesteld op basis van de overige documenten in `project_docs/`. + +## 1. Doel van de applicatie + +De applicatie is een webgebaseerde storage manager voor self-hosted omgevingen, met een veilige browserinterface om bestanden te beheren binnen strikt whitelisted root directories. + +Kernwaarde van v1: +- veilige en voorspelbare filesystem-operaties binnen whitelist +- transparant taakmodel voor langlopende copy/move acties +- stabiele API-contracten die met golden tests bewaakt worden + +## 2. V1 scope en out-of-scope + +In scope voor v1: +- directory browsing binnen whitelist roots +- file-operaties: `rename`, `delete`, `mkdir` +- task-based operaties: `copy`, `move` +- task status/progress polling +- bookmarks CRUD (minimaal: list/create/delete) +- history logging van uitgevoerde operaties +- security path controls: canonicalisatie, traversal-blocking, symlink escape blocking + +Out-of-scope voor v1: +- user management en authenticatie/autorisatie +- fijnmazig permissions management +- cloud storage integraties +- distributed/multi-node storage +- geavanceerde job scheduling (prioriteiten, parallel queue tuning, retry policy) +- recycle bin/undo functionaliteit + +## 3. Voorgestelde architectuur voor eerste versie + +Technische stack: +- Backend: Python + FastAPI +- Frontend: lichte JavaScript UI (zonder zwaar framework) +- Opslag: SQLite voor tasks, bookmarks, history + +Architectuur v1 (monolithisch, modulair): +- `API-laag` (FastAPI routes): validatie, response-shaping, HTTP foutcodes +- `Service-laag`: use-case logica (browse, file ops, tasks, bookmarks) +- `Filesystem-laag`: gecapsuleerde filesystem calls +- `Security/path-guard`: centrale pad-resolutie en whitelist enforcement +- `Task-runner`: background worker voor copy/move met persistente status +- `Repository-laag`: SQLite toegang voor tasks/bookmarks/history + +Datastroom copy/move: +1. API ontvangt request en valideert payload. +2. `path_guard` valideert source/destination tegen whitelist en security regels. +3. Service maakt task-record aan met status `queued`. +4. Worker pakt task op, zet status op `running`, voert operatie uit, werkt progress bij. +5. Bij afronding: status `completed` of `failed`; resultaat en fouten worden opgeslagen. + +## 4. Voorgestelde backend projectstructuur + +```text +backend/ + app/ + main.py + config.py + logging.py + api/ + schemas.py + errors.py + routes_browse.py + routes_files.py + routes_tasks.py + routes_bookmarks.py + services/ + browse_service.py + file_ops_service.py + task_service.py + bookmark_service.py + history_service.py + security/ + path_guard.py + fs/ + filesystem_adapter.py + tasks/ + worker.py + progress.py + transitions.py + db/ + sqlite.py + models.py + migrations/ + 001_init.sql + repositories/ + task_repo.py + bookmark_repo.py + history_repo.py + tests/ + unit/ + test_path_guard.py + test_task_transitions.py + feature/ + test_browse_flow.py + test_file_ops_flow.py + test_task_flow.py + regression/ + test_path_traversal_blocked.py + test_unicode_filenames.py + test_large_directory_listing.py + golden/ + test_api_browse_golden.py + test_api_errors_golden.py +``` + +Richtlijnen: +- security checks alleen via `path_guard.py` (geen ad-hoc checks in routes) +- filesystem calls alleen via `filesystem_adapter.py` +- API shapes blijven stabiel en worden bewaakt met golden tests + +## 5. Belangrijkste API endpoints voor versie 1 + +Browse: +- `GET /api/browse?path=` + +File-operaties: +- `POST /api/files/rename` +- `POST /api/files/delete` +- `POST /api/files/mkdir` +- `POST /api/files/copy` (task-based) +- `POST /api/files/move` (task-based) + +Tasks: +- `GET /api/tasks/{task_id}` +- `GET /api/tasks` + +Bookmarks: +- `GET /api/bookmarks` +- `POST /api/bookmarks` +- `DELETE /api/bookmarks/{id}` + +## 6. Browse contract (v1 aangescherpt) + +Padmodel: +- API accepteert in v1 alleen `root-relative` paden met expliciete root alias. +- Voorstel requestvorm: `GET /api/browse?path=/` +- Voorbeelden: `storage1/`, `storage1/series`, `archive/docs/2026` +- Absolute host paths worden niet geaccepteerd in publieke API om ambiguiteit te vermijden. + +Succesresponse: +```json +{ + "path": "storage1/series", + "directories": [ + { + "name": "ShowA", + "path": "storage1/series/ShowA", + "modified": "2026-03-10T12:00:00Z" + } + ], + "files": [ + { + "name": "episode.mkv", + "path": "storage1/series/episode.mkv", + "size": 734003200, + "modified": "2026-03-10T11:30:00Z" + } + ] +} +``` + +Metadata velden (v1): +- voor directories: `name`, `path`, `modified` +- voor files: `name`, `path`, `size`, `modified` +- `modified` als UTC ISO-8601 string +- geen checksum/mime in v1 + +Hidden files beleid: +- default: hidden entries (`.`-prefixed) niet tonen. +- optioneel query-flag: `show_hidden=true` voor expliciete opname. +- `.` en `..` worden nooit teruggegeven. + +Foutresponses: +- `400` bij ongeldige `path` parameter of malformed input +- `403` bij pad buiten whitelist / security-blokkade +- `404` als pad niet bestaat +- `409` als padtype mismatch (bijv. file i.p.v. directory) +- `500` bij onverwachte I/O fout + +## 7. Delete gedrag (veiligheidsuitwerking v1) + +Endpoint: +- `POST /api/files/delete` +- request: `{ "path": "", "recursive": false }` + +Gedrag files vs directories: +- file delete: direct verwijderen indien pad valide is +- directory delete: + - standaard alleen lege directory (`recursive=false`) + - non-empty directory geeft `409 directory_not_empty` + - recursive delete alleen bij expliciet `recursive=true` + +Non-empty directories: +- zonder recursive: blokkeren met heldere foutmelding +- met recursive: toegestaan binnen whitelist, met strikte guard tegen symlink escapes tijdens traversal + +Foutafhandeling: +- `404` target bestaat niet +- `403` security/path violations +- `409` directory non-empty zonder recursive of operation conflict +- `500` onverwachte filesystem fout + +Bevestigingsaannames: +- backend voert geen interactieve confirm uit +- frontend moet destructive acties bevestigen voordat endpoint wordt aangeroepen +- voorstel frontend: extra bevestigingstekst voor recursive delete + +## 8. Task/progress model (concreet v1) + +Progress model: +- primaire metriek: bytes (`done_bytes`, `total_bytes`) voor copy/move van files +- secundaire metriek voor directory-operaties: `done_items`, `total_items` +- API retourneert beide waar beschikbaar + +Als totaal onbekend is: +- `total_bytes` en/of `total_items` blijven `null` +- client toont indeterminate progress state +- `done_*` kan wel oplopen + +Partial failure gedrag: +- v1 policy: fail-fast per task +- eerste fatale fout zet task op `failed` +- reeds verplaatste/gekopieerde items blijven staan (geen rollback in v1) +- response bevat `failed_item` en foutdetails + +Eindstatussen v1: +- `completed` +- `failed` +- `queued` en `running` als tussenstatussen +- `canceled` nog niet in v1 + +## 9. Voorstel SQLite schema (v1) + +Tabel `tasks`: +- `id TEXT PRIMARY KEY` +- `operation TEXT NOT NULL` (`copy`/`move`) +- `source_path TEXT NOT NULL` +- `destination_path TEXT NOT NULL` +- `status TEXT NOT NULL` (`queued`/`running`/`completed`/`failed`) +- `done_bytes INTEGER NULL` +- `total_bytes INTEGER NULL` +- `done_items INTEGER NULL` +- `total_items INTEGER NULL` +- `current_item TEXT NULL` +- `error_code TEXT NULL` +- `error_message TEXT NULL` +- `failed_item TEXT NULL` +- `created_at TEXT NOT NULL` +- `started_at TEXT NULL` +- `finished_at TEXT NULL` + +Indexen: +- `idx_tasks_status_created_at(status, created_at DESC)` +- `idx_tasks_created_at(created_at DESC)` + +Tabel `bookmarks`: +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `label TEXT NOT NULL` +- `path TEXT NOT NULL UNIQUE` +- `created_at TEXT NOT NULL` +- `updated_at TEXT NOT NULL` + +Tabel `history`: +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `operation TEXT NOT NULL` +- `path TEXT NULL` +- `source_path TEXT NULL` +- `destination_path TEXT NULL` +- `status TEXT NOT NULL` +- `task_id TEXT NULL` +- `error_code TEXT NULL` +- `error_message TEXT NULL` +- `created_at TEXT NOT NULL` + +Indexen: +- `idx_history_created_at(created_at DESC)` +- `idx_history_operation_created_at(operation, created_at DESC)` +- `idx_history_task_id(task_id)` + +## 10. API error model (v1) + +Standaard error shape: +```json +{ + "error": { + "code": "path_outside_whitelist", + "message": "Requested path is outside allowed roots", + "details": { + "path": "storage1/../../etc" + } + } +} +``` + +Errorvelden: +- `code`: machine-readable, stabiel voor clientlogica +- `message`: mens-leesbare samenvatting +- `details`: optioneel object met context (geen gevoelige hostpaths) + +Voorgestelde error codes: +- security/path: + - `path_outside_whitelist` + - `path_traversal_detected` + - `symlink_escape_detected` + - `invalid_root_alias` +- not found/conflict/validation: + - `path_not_found` + - `already_exists` + - `directory_not_empty` + - `invalid_request` + - `validation_error` +- operationeel: + - `io_error` + - `internal_error` + +HTTP mapping: +- `400`: `invalid_request`, `validation_error` +- `403`: security/path errors +- `404`: `path_not_found`, `task_not_found`, `bookmark_not_found` +- `409`: `already_exists`, `directory_not_empty`, type conflicts +- `500`: `io_error`, `internal_error` + +## 11. Minimale frontend-notitie voor v1 + +Hoofdschermen: +- `Browser view`: directory listing + acties (rename/delete/mkdir/copy/move) +- `Tasks view`: lijst met actieve/recente taken en detailstatus +- `Bookmarks`: snelle navigatie naar opgeslagen paden + +Task polling: +- bij actieve taken elke 1-2 seconden `GET /api/tasks/{id}` of batch `GET /api/tasks` +- polling stopt automatisch bij eindstatus (`completed`/`failed`) +- UI toont determinate progress bij bekende totalen, anders indeterminate indicator + +Foutweergave: +- fouten tonen met `error.message` +- client-logica kan op `error.code` beslissen (bijv. specifieke melding voor `directory_not_empty`) +- destructive acties (delete, vooral recursive) krijgen expliciete confirm-dialog + +## 12. Implementatieplan in kleine stappen + +1. Backend skeleton opzetten (FastAPI app, routers, config, logging). +2. `path_guard` implementeren met root-alias model en centrale security checks. +3. Unit tests toevoegen voor whitelist/traversal/symlink scenario's. +4. Browse endpoint bouwen volgens aangescherpt contract incl. hidden files beleid. +5. Golden tests toevoegen voor browse success + browse error responses. +6. `rename`, `mkdir`, `delete` implementeren met veilig delete-gedrag (recursive policy). +7. Feature tests toevoegen voor file-operaties incl. non-empty directory fouten. +8. SQLite schema (`tasks`, `bookmarks`, `history`) toevoegen met repositories. +9. Task statusmachine en transitions implementeren + unit tests. +10. Worker voor copy/move implementeren met bytes/items progress. +11. Task endpoints implementeren (`create`, `get`, `list`) met eindstatussen. +12. Feature tests voor copy/move + partial failure gedrag + polling flow. +13. Bookmarks endpoints implementeren + basis tests. +14. Regression tests voor path traversal, unicode, grote/nested directories. +15. Frontend v1 basis flows koppelen (browse, acties, tasks polling, foutweergave). +16. Eindcontrole: volledige test run + golden contract verificatie. + +## 13. Governance-notitie + +Volgens `CHANGE_POLICY.md` en `SAFE_FILES.md` vallen API-wijzigingen en DB schema-wijzigingen onder "eerst voorstel nodig". Deze aangescherpte `PLAN.md` is het voorstel dat eerst goedgekeurd moet worden. Na expliciete goedkeuring kan implementatie starten. diff --git a/container/Containerfile b/container/Containerfile new file mode 100644 index 0000000..dffc74b --- /dev/null +++ b/container/Containerfile @@ -0,0 +1,27 @@ +# Gebruik Debian Trixie (13) als stabiele basis +FROM debian:trixie-slim + +# Installeren van benodigde tools +# rsync voor data, python3 voor de backend, sqlite3 voor dataopslag +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + python3-full \ + rsync \ + sqlite3 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Maak de mappenstructuur aan zoals voorgesteld +WORKDIR /app +RUN mkdir -p /app/backend /app/html /app/conf /Volumes/8TB /Volumes/8TB_RAID1 + +# Installeer een lichtgewicht Python API framework (FastAPI) +# We gebruiken --break-system-packages omdat we in een container zitten +RUN pip3 install fastapi uvicorn --break-system-packages + +# Exposeer de poort voor de webinterface +EXPOSE 8030 + +# Startscript (placeholder totdat de GPT de main.py heeft gemaakt) +CMD ["python3", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] diff --git a/project_docs/AGENTS.md b/project_docs/AGENTS.md new file mode 100644 index 0000000..0543173 --- /dev/null +++ b/project_docs/AGENTS.md @@ -0,0 +1,26 @@ +# AGENTS.md + +Dit document beschrijft hoe AI agents in dit project moeten werken. + +## Rollen + +### GPT + +Verantwoordelijk voor: + +- architectuur +- analyse +- taakopdeling +- teststrategie +- Codex prompts +- review van wijzigingen + +### Codex + +Verantwoordelijk voor: + +- implementeren van kleine wijzigingen +- toevoegen van tests +- aanpassen van documentatie + +Codex mag **geen architectuur wijzigen** zonder instructie. diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md new file mode 100644 index 0000000..0c5c015 --- /dev/null +++ b/project_docs/API_GOLDEN.md @@ -0,0 +1,137 @@ +# API_GOLDEN.md + +Dit document definieert stabiele API responses. +Velden mogen niet wijzigen zonder golden tests te updaten. + +## Browse + +### `GET /api/browse` +Response shape: +```json +{ + "path": "storage1", + "directories": [], + "files": [] +} +``` + +## File Ops (direct) + +### `POST /api/files/mkdir` +Success: +```json +{ "path": "storage1/parent/new_dir" } +``` + +Conflict (`already_exists`): +```json +{ + "error": { + "code": "already_exists", + "message": "Target path already exists", + "details": { "path": "storage1/parent/new_dir" } + } +} +``` + +Invalid name (`invalid_request`): +```json +{ + "error": { + "code": "invalid_request", + "message": "Invalid name", + "details": { "name": "bad/name" } + } +} +``` + +### `POST /api/files/rename` +Success: +```json +{ "path": "storage1/parent/new_name.ext" } +``` + +Conflict (`already_exists`) + invalid name (`invalid_request`) gebruiken dezelfde error-shape als mkdir. + +### `POST /api/files/delete` +Success: +```json +{ "path": "storage1/parent/file_or_empty_dir" } +``` + +Non-empty directory: +```json +{ + "error": { + "code": "directory_not_empty", + "message": "Directory is not empty", + "details": { "path": "storage1/parent/non_empty" } + } +} +``` + +## Task-based create endpoints + +### `POST /api/files/copy` +### `POST /api/files/move` +Success (202): +```json +{ + "task_id": "", + "status": "queued" +} +``` + +## Tasks read endpoints + +### `GET /api/tasks` +Response shape: +```json +{ + "items": [ + { + "id": "", + "operation": "copy", + "status": "running", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "created_at": "2026-03-10T10:00:00Z", + "finished_at": null + } + ] +} +``` + +### `GET /api/tasks/{task_id}` +Response shape: +```json +{ + "id": "", + "operation": "move", + "status": "running", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "done_bytes": 1024, + "total_bytes": 4096, + "done_items": null, + "total_items": null, + "current_item": "storage1/a.txt", + "failed_item": null, + "error_code": null, + "error_message": null, + "created_at": "2026-03-10T10:00:00Z", + "started_at": "2026-03-10T10:00:01Z", + "finished_at": null +} +``` + +Task not found: +```json +{ + "error": { + "code": "task_not_found", + "message": "Task was not found", + "details": { "task_id": "task-missing" } + } +} +``` diff --git a/project_docs/BACKEND_V1_CONSOLIDATION.md b/project_docs/BACKEND_V1_CONSOLIDATION.md new file mode 100644 index 0000000..d171190 --- /dev/null +++ b/project_docs/BACKEND_V1_CONSOLIDATION.md @@ -0,0 +1,87 @@ +# BACKEND_V1_CONSOLIDATION.md + +## Doel +Consolidatie van de huidige backend v1 contracten en beperkingen. + +## Huidige endpoints + +Directe endpoints (geen task-creatie): +- `GET /api/browse` +- `POST /api/files/mkdir` +- `POST /api/files/rename` +- `POST /api/files/delete` +- `GET /api/tasks` +- `GET /api/tasks/{task_id}` + +Task-based create endpoints: +- `POST /api/files/copy` +- `POST /api/files/move` + +## Semantiek per endpoint + +### `GET /api/browse` +- Browse van een directory binnen whitelist roots. +- Hidden files standaard verborgen, optioneel via `show_hidden=true`. + +### `POST /api/files/mkdir` +- Maakt directory op exact `parent_path + name`. +- Geen impliciete pad-normalisatie buiten `path_guard` validatie. + +### `POST /api/files/rename` +- Hernoemt binnen dezelfde parent directory. +- Geen verborgen move naar andere map. + +### `POST /api/files/delete` +- Verwijdert file direct. +- Verwijdert alleen lege directory. +- Non-empty directory => conflict. + +### `POST /api/files/copy` +- File-only copy. +- `destination` is volledig doelpad (geen "copy into directory"). +- Task wordt aangemaakt (`202`, `task_id`, `queued`) en daarna uitgevoerd. + +### `POST /api/files/move` +- File-only move. +- `destination` is volledig doelpad. +- Task wordt aangemaakt (`202`, `task_id`, `queued`) en daarna uitgevoerd. +- Same-root: native move. +- Cross-root: copy + delete binnen dezelfde task. + +### `GET /api/tasks` +- Lijst van tasks, gesorteerd op `created_at DESC`. +- Item bevat minimaal: `id`, `operation`, `status`, `source`, `destination`, `created_at`, `finished_at`. + +### `GET /api/tasks/{task_id}` +- Detailstatus inclusief progress/foutvelden. +- Statusset: `queued`, `running`, `completed`, `failed`. + +## Foutmodel per endpointgroep + +### Browse/file-validatie +- `invalid_request` +- `path_traversal_detected` +- `path_outside_whitelist` +- `invalid_root_alias` +- `path_not_found` +- `type_conflict` +- `already_exists` +- `directory_not_empty` (delete) + +### Task create runtime +- validatiefouten vóór task-creatie: directe API-fout, geen task +- runtime-fouten tijdens task-uitvoering: task naar `failed` met `io_error` + +### Tasks read +- `task_not_found` bij onbekend task id + +## Expliciete v1-beperkingen + +- copy: file-only +- move: file-only +- delete: alleen file + lege directory +- geen recursive delete +- geen directory copy/move +- geen rollback +- geen cancel/retry +- geen batch-operaties diff --git a/project_docs/BOOKMARKS_V1_CONSOLIDATION.md b/project_docs/BOOKMARKS_V1_CONSOLIDATION.md new file mode 100644 index 0000000..e12f493 --- /dev/null +++ b/project_docs/BOOKMARKS_V1_CONSOLIDATION.md @@ -0,0 +1,21 @@ +# BOOKMARKS_V1_CONSOLIDATION.md + +## Endpoints +- `POST /api/bookmarks` +- `GET /api/bookmarks` +- `DELETE /api/bookmarks/{bookmark_id}` + +## Duplicate policy +- Een bookmark is uniek op `path`. +- Een tweede create met hetzelfde pad geeft `409 already_exists`. + +## Validatie +- `path` wordt centraal via `path_guard.resolve_path(...)` gevalideerd. +- Dit dekt whitelist, traversal en root-alias validatie. +- `label` mag niet leeg zijn (`trim()`), anders `400 invalid_request`. + +## Model v1 +- `id` +- `path` +- `label` +- `created_at` diff --git a/project_docs/CHANGE_POLICY.md b/project_docs/CHANGE_POLICY.md new file mode 100644 index 0000000..5852afa --- /dev/null +++ b/project_docs/CHANGE_POLICY.md @@ -0,0 +1,22 @@ +# CHANGE_POLICY.md + +## Toegestaan zonder toestemming + +- documentatie verbeteren +- tests toevoegen +- logging verbeteren +- kleine bugfixes + +## Eerst voorstel nodig + +- API wijzigingen +- dependency toevoegen +- database schema wijziging +- frontend flow aanpassen + +## Expliciete goedkeuring vereist + +- security model aanpassen +- whitelist wijzigen +- directory structuur aanpassen +- destructieve acties veranderen diff --git a/project_docs/COPY_MOVE_TASKS_DESIGN.md b/project_docs/COPY_MOVE_TASKS_DESIGN.md new file mode 100644 index 0000000..4a04d35 --- /dev/null +++ b/project_docs/COPY_MOVE_TASKS_DESIGN.md @@ -0,0 +1,274 @@ +# COPY_MOVE_TASKS_DESIGN.md + +## Doel +Ontwerpvoorstel voor de volgende implementatieslice: `copy`, `move` en `tasks`. + +Dit document bevat alleen ontwerpkeuzes. Er is geen code-implementatie in deze stap. + +## 1. Destination-semantiek (expliciet) + +V1 kiest een strikte semantiek: +- `destination` is altijd het volledige beoogde eindpad (inclusief bestandsnaam). +- `destination` mag in v1 **niet** geïnterpreteerd worden als "copy/move into existing directory". +- Als `destination` al bestaat (file of directory) -> `already_exists` (409). + +Voorbeeld v1: +- source: `storage1/a/file.txt` +- destination: `storage2/b/file_copy.txt` + +Niet toegestaan als impliciete directory-target in v1: +- destination: `storage2/b/` met verwachting dat `file.txt` automatisch eronder komt. + +Reden: +- voorkomt ambigu gedrag +- eenvoudiger API-contract +- minder regressierisico + +## 2. Scopevoorstel `copy` v1 + +### Keuze +- `copy` ondersteunt in v1 **alleen files**. +- directory-recursie schuift door naar een latere fase. + +### Motivatie +- Complexiteit: recursieve copy voegt veel edge-cases toe (diepe bomen, symlinks, partial failures). +- Testbaarheid: file-only maakt golden/regressie tests kleiner en deterministischer. +- Regressierisico: lagere kans op onverwachte performance/security regressies. + +### Conflictgedrag +- destination bestaat al -> `already_exists` (409). +- geen overwrite/merge in v1. + +### Uitvoering +- altijd task-based (async). +- create response: `task_id` + `queued`. + +### Foutmodel copy +- `invalid_request` (400) +- `path_traversal_detected` (403) +- `path_outside_whitelist` (403) +- `invalid_root_alias` (403) +- `path_not_found` (404) +- `type_conflict` (409) (source is geen file) +- `already_exists` (409) +- `io_error` (500) + +## 3. Scopevoorstel `move` v1 + +### Rename vs move +- `rename` blijft naamwijziging binnen dezelfde parent directory. +- `move` is padwijziging naar een ander eindpad (zelfde root of cross-root). + +### Cross-root gedrag +- toegestaan als source en destination beide binnen whitelist vallen. +- zelfde root: native rename/move waar mogelijk. +- cross-root: copy+delete binnen dezelfde task. + +### Keuze +- `move` ondersteunt in v1 **alleen files**. +- directory-move buiten scope in v1. + +### Conflictgedrag +- destination bestaat al -> `already_exists` (409). +- geen overwrite in v1. + +### Foutmodel move +- `invalid_request` (400) +- `path_traversal_detected` (403) +- `path_outside_whitelist` (403) +- `invalid_root_alias` (403) +- `path_not_found` (404) +- `type_conflict` (409) (source is geen file) +- `already_exists` (409) +- `io_error` (500) + +## 4. Symlinkbeleid (copy/move) + +### Source +- v1 file-only: source mag geen symlink zijn. +- als source symlink resolve’t naar pad buiten whitelist -> geblokkeerd (`path_outside_whitelist`). +- als source symlink resolve’t binnen whitelist: in v1 alsnog afwijzen als `type_conflict` om semantiek simpel te houden. + +### Destination +- destination wordt via `path_guard` canoniek gevalideerd. +- destination parent moet binnen whitelist liggen. +- destination parent die via symlink buiten whitelist valt -> blokkeren (`path_outside_whitelist`). + +### Recursieve escapes +- Niet van toepassing in v1 (geen directory-recursie). +- Voor latere directory-copy geldt: elk bezocht pad moet per entry containment-check krijgen. + +## 5. Task persistence en history + +### Relatie tasks vs history +- In v1 zijn `tasks` en `history` aparte modellen/tabelrollen: + - `tasks`: actuele en afgeronde task-status/progress + - `history`: audit-log van uitgevoerde operaties +- history kan vanuit task-completion gevuld worden, maar is niet hetzelfde model. + +### Retentie +- v1 bewaart `completed` en `failed` tasks persistent (geen automatische cleanup in scope). + +### Sortering GET /api/tasks +- standaard sortering: `created_at DESC` (nieuwste eerst). + +## 6. Taskmodel + +### Wanneer task verplicht is +- `copy` en `move` altijd task-based. + +### Statussen +- `queued` +- `running` +- `completed` +- `failed` + +### Progress +- file-only v1: + - `done_bytes` + - `total_bytes` +- `done_items`/`total_items` optioneel en kunnen `null` blijven in v1. + +### Partial failure +- fail-fast. +- eerste fatale fout -> `failed`. +- geen rollback in v1. +- taskdetail bevat `failed_item`, `error_code`, `error_message`. + +### Duplicate create requests (correctie) +- v1 gedrag is **niet idempotent**. +- twee identieke requests mogen twee verschillende tasks creëren. + +## 7. API-voorstel + +### Endpoints +- `POST /api/files/copy` +- `POST /api/files/move` +- `GET /api/tasks/{task_id}` +- `GET /api/tasks` + +### Request/response shapes + +#### POST /api/files/copy +Request: +```json +{ + "source": "storage1/path/file.txt", + "destination": "storage2/path/file_copy.txt" +} +``` +Response (202): +```json +{ + "task_id": "", + "status": "queued" +} +``` + +#### POST /api/files/move +Request: +```json +{ + "source": "storage1/path/file.txt", + "destination": "storage2/path/file_moved.txt" +} +``` +Response (202): +```json +{ + "task_id": "", + "status": "queued" +} +``` + +#### GET /api/tasks/{task_id} +Response (200): +```json +{ + "id": "", + "operation": "copy", + "status": "running", + "source": "storage1/a/file.txt", + "destination": "storage2/b/file.txt", + "done_bytes": 1024, + "total_bytes": 4096, + "done_items": null, + "total_items": null, + "current_item": "storage1/a/file.txt", + "failed_item": null, + "error_code": null, + "error_message": null, + "created_at": "2026-03-10T00:00:00Z", + "started_at": "2026-03-10T00:00:01Z", + "finished_at": null +} +``` + +#### GET /api/tasks +Response (200): +```json +{ + "items": [ + { + "id": "", + "operation": "move", + "status": "completed", + "source": "storage1/a/file.txt", + "destination": "storage2/b/file.txt", + "created_at": "2026-03-10T00:00:00Z", + "finished_at": "2026-03-10T00:00:05Z" + } + ] +} +``` + +### Waarom `source` en `destination` ook in task-list +- Ja, opnemen in `GET /api/tasks`. +- Reden: operator kan zonder extra detail-calls direct zien wat elke task doet. +- Dit maakt UI en troubleshooting eenvoudiger. + +### Error codes (versmald) +- `invalid_request` (400) +- `path_traversal_detected` (403) +- `path_outside_whitelist` (403) +- `invalid_root_alias` (403) +- `path_not_found` (404) +- `task_not_found` (404) +- `type_conflict` (409) +- `already_exists` (409) +- `io_error` (500) + +`internal_error` wordt in v1 niet als aparte publieke code gebruikt; onverwachte fouten worden genormaliseerd naar `io_error` of framework-500. + +## 8. Teststrategie + +### Golden tests +- `POST /api/files/copy` success (task create shape) +- `POST /api/files/move` success (task create shape) +- `already_exists` voor copy/move +- `type_conflict` als source geen file is +- `invalid_request` voor ongeldige payload +- traversal/root errors voor source en destination +- `GET /api/tasks/{task_id}` shapes voor `queued/running/completed/failed` +- `GET /api/tasks` list shape inclusief `source` en `destination` + +### Regressietests +- cross-root move gebruikt copy+delete pad correct +- file copy/move met unicode namen +- grote files progress updates blijven monotone stijging +- duplicate create requests leveren 2 verschillende `task_id`s (niet-idempotent) + +### Securitytests +- traversal blokkade op source/destination +- destination outside whitelist blokkade +- invalid root alias blokkade +- symlink source wordt afgewezen +- symlinked destination parent buiten whitelist wordt afgewezen + +## Implementatiegrenzen voor volgende stap +- Geen wijziging aan bestaande endpoints: + - browse + - mkdir + - rename + - delete +- Geen nieuwe dependencies zonder expliciete motivatie. diff --git a/project_docs/COPY_V1_CONSOLIDATION.md b/project_docs/COPY_V1_CONSOLIDATION.md new file mode 100644 index 0000000..ccf19fd --- /dev/null +++ b/project_docs/COPY_V1_CONSOLIDATION.md @@ -0,0 +1,50 @@ +# COPY_V1_CONSOLIDATION.md + +## Task lifecycle (copy v1) + +`POST /api/files/copy`: +1. Request wordt gevalideerd. +2. Bij geldige input wordt direct een task aangemaakt met status `queued`. +3. Een achtergrond-runner zet de task op `running` en vult progress (`done_bytes`, `total_bytes`). +4. Eindstatus: + - `completed` bij succesvolle file copy + - `failed` bij runtime I/O fout (`error_code = io_error`) + +## Validatiefouten vs runtime-fouten + +Validatiefouten (voor task-creatie): +- `invalid_request` +- `path_traversal_detected` +- `path_outside_whitelist` +- `invalid_root_alias` +- `path_not_found` +- `type_conflict` +- `already_exists` + +Gedrag: +- request faalt direct met error response +- er wordt geen task aangemaakt + +Runtime-fouten (na task-creatie): +- `io_error` + +Gedrag: +- request zelf retourneert `202` met `task_id` +- task gaat naar `failed` +- foutdetails verschijnen via `GET /api/tasks/{task_id}` + +## Copy metadata in v1 (`copy_file(...)`) + +V1 kopieert: +- file-inhoud (byte stream) +- basic filesystem metadata via `copystat` (mtime/atime/mode waar ondersteund) + +V1 doet niet expliciet: +- ownership/ACL normalisatie +- extended attributes beleid + +## Destination-semantiek (expliciet) + +`destination` blijft in v1 altijd het volledige doelpad. +- Geen impliciete "copy into existing directory" interpretatie. +- Als destination al bestaat: `already_exists`. diff --git a/project_docs/MKDIR_RENAME_CONSOLIDATION.md b/project_docs/MKDIR_RENAME_CONSOLIDATION.md new file mode 100644 index 0000000..3db7b48 --- /dev/null +++ b/project_docs/MKDIR_RENAME_CONSOLIDATION.md @@ -0,0 +1,49 @@ +# MKDIR_RENAME_CONSOLIDATION.md + +## Gedragsbevestiging + +- `POST /api/files/rename` werkt alleen binnen dezelfde parent directory. + - De target wordt opgebouwd als `parent(source_path) + new_name`. +- `rename` laat geen verborgen move toe. + - `new_name` accepteert geen padsegmenten (`/`, `\\`, `..`), dus geen directorywissel. +- `validate_name` wordt toegepast op: + - `mkdir.name` + - `rename.new_name` + +## Error model voor mkdir/rename + +Standaard error shape: + +```json +{ + "error": { + "code": "...", + "message": "...", + "details": {"...": "..."} + } +} +``` + +Ondersteunde codes in deze slice: + +- `invalid_request` + - ongeldige naam (`..`, leeg, of met `/`/`\\`) + - HTTP `400` +- `path_traversal_detected` + - traversal in aangeleverd pad (`../`) + - HTTP `403` +- `path_not_found` + - bronpad of parent pad bestaat niet + - HTTP `404` +- `already_exists` + - doelpad bestaat al (file of directory) + - HTTP `409` +- `io_error` + - onverwachte filesystem fout tijdens operatie + - HTTP `500` + +## Scopebevestiging + +Deze consolidatie wijzigt niet: +- browse endpoint +- delete/copy/move/tasks/frontend diff --git a/project_docs/PRODUCT_SPEC.md b/project_docs/PRODUCT_SPEC.md new file mode 100644 index 0000000..5c69bd8 --- /dev/null +++ b/project_docs/PRODUCT_SPEC.md @@ -0,0 +1,123 @@ +# PRODUCT_SPEC.md + +## Project + +Web-based Storage Manager + +Een webapplicatie waarmee gebruikers bestanden op +whitelisted storage volumes kunnen beheren. + +De applicatie draait in een containeromgeving. + +--- + +# Doel + +Een veilige webinterface bouwen voor filesystem beheer. + +Gebruikers moeten via een browser: + +- directories kunnen browsen +- bestanden kunnen kopiëren +- bestanden kunnen verplaatsen +- bestanden kunnen hernoemen +- bestanden kunnen verwijderen +- mappen kunnen aanmaken +- bookmarks kunnen opslaan + +De applicatie moet geschikt zijn voor gebruik binnen een +self-hosted infrastructuur. + +--- + +# Architectuur uitgangspunten + +Backend + +Python + FastAPI + +Frontend + +lichte JS UI zonder zware frameworks. + +Database + +SQLite voor: + +- tasks +- bookmarks +- history + +--- + +# Storage model + +De applicatie mag alleen werken binnen +**whitelisted root directories**. + +Voorbeeld: + +/mnt/storage1 +/mnt/storage2 +/media/archive + +Alle paden moeten binnen deze roots blijven. + +Traversal en symlink escapes moeten geblokkeerd worden. + +--- + +# Functionaliteiten + +## Directory browsing + +De UI moet directories kunnen tonen. + +Response moet bevatten: + +- directories +- files +- metadata (size, modified) + +--- + +## File operations + +Ondersteunde acties: + +rename +move +copy +delete +create directory + +--- + +## Task system + +Langlopende acties (copy/move): + +- krijgen een task id +- status wordt opgeslagen +- progress kan opgehaald worden via API + +--- + +# Security eisen + +- path validation +- whitelist enforcement +- symlink protection +- traversal protection + +--- + +# Out of scope + +Deze functionaliteit hoort **niet** bij versie 1: + +- user management +- permissions management +- cloud storage +- distributed storage +- multi-node clusters diff --git a/project_docs/SAFE_FILES.md b/project_docs/SAFE_FILES.md new file mode 100644 index 0000000..6622fbd --- /dev/null +++ b/project_docs/SAFE_FILES.md @@ -0,0 +1,20 @@ +# SAFE_FILES.md + +## Safe to edit + +- backend code +- frontend js +- tests +- documentatie + +## Ask first + +- database schema +- container config +- security modules + +## Do not edit + +- deployment configs +- storage volume mappings +- whitelist configuration diff --git a/project_docs/STEP1_5_CONSOLIDATION.md b/project_docs/STEP1_5_CONSOLIDATION.md new file mode 100644 index 0000000..7108976 --- /dev/null +++ b/project_docs/STEP1_5_CONSOLIDATION.md @@ -0,0 +1,72 @@ +# STEP1_5_CONSOLIDATION.md + +## Doel +Consolidatie van stap 1 t/m 5 (backend skeleton, path_guard, unit tests, browse endpoint, golden tests). + +## 1. Test-run context + +Tests draaien vanuit repository root met: +- `PYTHONPATH=webui` + +Definitieve testcommando's voor deze fase: +- `PYTHONPATH=webui python3 -m unittest discover -s webui/backend/tests/unit -p "test_*.py" -v` +- `PYTHONPATH=webui python3 -m unittest discover -s webui/backend/tests/golden -p "test_*.py" -v` +- `PYTHONPATH=webui python3 -m unittest discover -s webui/backend/tests -p "test_*.py" -v` + +## 2. Minimale dependencies voor stap 1-5 + +Vastgelegd in: +- `webui/backend/requirements.txt` + +Benodigd voor deze fase: +- FastAPI app + browse endpoint +- unit tests +- golden tests + +Dependencies: +- `fastapi==0.111.0` +- `starlette==0.37.2` +- `pydantic==2.12.5` +- `httpx==0.27.2` +- `anyio==4.4.0` +- `sniffio==1.3.1` + +## 3. Minimale wijzigingen in deze fase + +Functioneel gewijzigde bestanden: +- `webui/backend/app/api/routes_browse.py` +- `webui/backend/app/dependencies.py` +- `webui/backend/app/main.py` + +Testmatig gewijzigde bestanden: +- `webui/backend/tests/golden/test_api_browse_golden.py` +- `webui/backend/tests/golden/test_api_errors_golden.py` + +Waarom golden tests van TestClient naar `httpx.ASGITransport` zijn omgezet: +- In deze omgeving blokkeerde de TestClient-runner op sync execution pad. +- `httpx.ASGITransport` voert requests in-memory tegen de ASGI app uit zonder die blokkade. +- Dit is een testuitvoerbaarheidsfix, geen API-contractwijziging. + +Waarom async wijzigingen nodig waren: +- Error-pad en dependency-pad moesten async blijven om runtime-blokkade in deze omgeving te vermijden. +- De wijzigingen zijn intern uitvoeringsgericht; het HTTP-contract blijft gelijk. + +## 4. Contractbehoud (expliciet) + +Door deze consolidatiestap is niets gewijzigd aan: +- endpointpad: `GET /api/browse` +- query parameters: `path`, `show_hidden` +- response shape: `path`, `directories[]`, `files[]` met dezelfde velden +- error codes: o.a. `invalid_root_alias`, `path_traversal_detected`, `path_not_found`, `path_type_conflict` +- hidden files policy: default hidden uit, optioneel via `show_hidden=true` + +## 5. Scopebevestiging + +Niet geïmplementeerd in deze consolidatiestap: +- rename +- mkdir +- delete +- copy +- move +- tasks/worker +- frontend diff --git a/project_docs/TEST_STRATEGY.md b/project_docs/TEST_STRATEGY.md new file mode 100644 index 0000000..57f4576 --- /dev/null +++ b/project_docs/TEST_STRATEGY.md @@ -0,0 +1,34 @@ +# TEST_STRATEGY.md + +## Unit tests + +Test individuele functies: + +- path validation +- whitelist checks +- helper functions +- task status transitions + +## Feature tests + +Test volledige flows: + +- browse directory +- copy +- move +- rename +- delete +- create folder + +## Regression tests + +Bescherm tegen bekende problemen: + +- path traversal +- unicode filenames +- grote directories +- nested directories + +## API Golden tests + +Controleer dat response formats niet veranderen. diff --git a/project_docs/UI_DUAL_PANE_DESIGN.md b/project_docs/UI_DUAL_PANE_DESIGN.md new file mode 100644 index 0000000..b2b34ab --- /dev/null +++ b/project_docs/UI_DUAL_PANE_DESIGN.md @@ -0,0 +1,233 @@ +# UI_DUAL_PANE_DESIGN.md + +## Doel van deze notitie + +Deze notitie beschrijft de ontwerpstap voor **Dual-pane UI v2** op basis van de huidige v1.1 UI en `UI_VISION_MC.md`. + +Randvoorwaarden voor deze stap: +- geen backendwijzigingen +- geen nieuwe dependencies +- geen multi-select +- geen viewer/editor +- geen keyboard-uitbreiding (behalve als latere notitie) + +--- + +## 1) Ombouw van single-page UI naar twee panelen + +### Huidige situatie (v1.1) +- één browsepaneel met één `currentPath` +- globale selectie voor dat paneel +- acties (mkdir/rename/delete/copy/move) vanuit die ene context + +### Doelsituatie (v2) +- twee browsepanelen naast elkaar: `left` en `right` +- elk paneel heeft: + - eigen path input + go + - eigen breadcrumbs + - eigen directory/file lijst + - eigen geselecteerd item +- één paneel is altijd **actief** +- acties worden contextueel uitgevoerd vanuit actief paneel +- tasks- en bookmarks-sectie blijven gedeeld (onder of naast de panelen) + +### UI-structuur op hoog niveau +- Header: algemene status +- Main layout: + - Paneel links (`left-pane`) + - Paneel rechts (`right-pane`) + - Utility-kolom (tasks + bookmarks) +- Actieknoppen: + - per paneel (mkdir) + - actieve-paneelacties (rename/delete/copy/move) + +### Bookmark-open gedrag (expliciete keuze) +- Een klik op een bookmark opent altijd in het **actieve paneel**. +- Motivatie (kort): dit is het meest voorspelbaar, sluit aan op het active/inactive-pane model, en voorkomt verborgen side-effects in het niet-actieve paneel. + +--- + +## 2) Benodigde state per paneel + +## State-model + +```js +state = { + panes: { + left: { + currentPath: "storage1", + showHidden: false, + selectedItem: null, // { path, name, kind: "file"|"directory" } + entries: { directories: [], files: [] } + }, + right: { + currentPath: "storage1", + showHidden: false, + selectedItem: null, + entries: { directories: [], files: [] } + } + }, + activePane: "left", // "left" | "right" + selectedTaskId: null, + pollHandle: null +} +``` + +### Verplichte kernstate +- `current path` per paneel +- `selected item` per paneel +- `active pane` globaal + +### Afgeleide helperstate +- `inactivePane = activePane === "left" ? "right" : "left"` +- `canRename/canDelete` op basis van selectie actief paneel +- `canCopy/canMove` alleen als selectie actief paneel een file is + +--- + +## 3) Copy/move van actief paneel naar ander paneel + +## Semantiek v2 +- Bron komt altijd uit `selectedItem` van `activePane`. +- Doelcontext is standaard het **andere paneel**. +- `destination` blijft volledig doelpad (geen impliciete shell-achtige "copy into" semantics). + +### Destination-bepaling +- `targetBasePath = panes[inactivePane].currentPath` +- `destination = targetBasePath + "/" + sourceItem.name` +- UI toont dit voorstel in prompt/confirm en laat aanpassen toe. + +### Copy flow +1. User selecteert file in actief paneel. +2. User kiest `Copy`. +3. UI bouwt default destination op basis van inactieve paneel-path + bestandsnaam. +4. UI roept `POST /api/files/copy` met `{source, destination}`. +5. Bij `202`: tasks refresh + taskdetail naar nieuw task_id. +6. Na start: beide panelen refresh (best effort), zodat user contextueel resultaat ziet. + +### Move flow +1. Zelfde als copy, maar endpoint `POST /api/files/move`. +2. Bij `202`: tasks refresh + taskdetail selecteren. +3. Beide panelen refreshen (source kan verdwijnen, destination verschijnen). + +### Refresh-semantiek per actie (expliciet) +- `mkdir`: refresh alleen het actieve paneel. +- `rename`: refresh alleen het actieve paneel. +- `delete`: refresh alleen het actieve paneel. +- `copy`: refresh beide panelen. +- `move`: refresh beide panelen. + +Rationale (kort): +- Lokale mutaties (`mkdir/rename/delete`) zijn paneelgebonden en blijven daardoor snel en voorspelbaar. +- Transfer-acties (`copy/move`) raken bron- en doelcontext, dus beide panelen verversen voorkomt stale state. + +### Foutafhandeling +- Validatiefouten vóór task-creatie direct tonen op actiegebied actief paneel. +- Runtime-fouten via task-status (`failed`) zichtbaar in tasklijst/detail. + +--- + +## 4) Bestaande backend-endpoints die hergebruikt worden + +Geen contractwijzigingen; hergebruik van bestaande endpoints: + +Browse/file ops: +- `GET /api/browse` +- `POST /api/files/mkdir` +- `POST /api/files/rename` +- `POST /api/files/delete` +- `POST /api/files/copy` +- `POST /api/files/move` + +Tasks: +- `GET /api/tasks` +- `GET /api/tasks/{task_id}` + +Bookmarks: +- `GET /api/bookmarks` +- `POST /api/bookmarks` +- `DELETE /api/bookmarks/{bookmark_id}` + +--- + +## 5) Waarschijnlijk te wijzigen bestanden + +Primair frontend: +- `webui/html/index.html` + - layout naar dual-pane + - pane-specifieke controls/containers +- `webui/html/app.js` + - state refactor naar `panes.left/right` + - browse/render/actions per pane + - active-pane switching + - copy/move destination vanuit ander pane +- `webui/html/style.css` + - twee panelen visueel gelijkwaardig + - actieve-paneel-highlight + - responsieve fallback (mobiel: gestapeld) + +Tests: +- `webui/backend/tests/golden/test_ui_smoke_golden.py` + - assertions bijwerken naar dual-pane markup/ids + +Niet gepland in deze stap: +- backend python-bestanden +- API schemas/routes/services + +--- + +## 6) Regressierisico + +## Hoog risico +- Stateverwarring tussen links/rechts paneel (actie op verkeerde paneelcontext). +- Onjuiste destination-opbouw bij copy/move (per ongeluk vanuit actief i.p.v. inactief pad). + +## Middel risico +- Disable/enable logica van knoppen niet synchroon met actieve paneelselectie. +- Refresh-volgorde na acties waardoor UI tijdelijk stale data toont. +- Bookmarks die per ongeluk in het verkeerde paneel openen als `activePane` niet consequent wordt gebruikt. + +## Laag risico +- CSS regressies in mobile layout. +- Kleine tekst/label inconsistenties in error/statusweergave. + +## Mitigatie +- Duidelijke pane-identifiers in DOM en handlers (`left`/`right`). +- Eén centrale helper voor `getActivePane()` en `getInactivePane()`. +- Eén centrale helper voor copy/move destination-opbouw. +- Smoke tests expliciet op dual-pane hoofdstructuur. + +--- + +## 7) Aan te passen/toe te voegen UI smoke tests + +## Aanpassen bestaande smoke test +`test_ui_smoke_golden.py`: +- huidige checks op enkel browsepaneel vervangen door dual-pane checks. + +Nieuwe/gewijzigde assertions: +- UI mount bestaat op `/ui`. +- HTML bevat beide hoofdpanelen: + - `id="left-pane"` + - `id="right-pane"` +- HTML bevat actieve-paneel-indicator/container (bijv. class of data-attribute). +- Assets blijven gemapt: + - `/ui/app.js` + - `/ui/style.css` + +## Kleine extra smoke checks (zonder backenduitbreiding) +- basisactieknoppen aanwezig voor actieve-paneelacties. +- tasks- en bookmarks-panelen blijven aanwezig in HTML. + +Geen end-to-end browserautomatisering in deze stap. + +--- + +## Niet in scope voor deze implementatieslice + +- multi-select +- viewer/editor +- cancel/retry +- history UI +- nieuwe backend endpoints +- keyboard mapping uitbreiding (eventueel later) diff --git a/project_docs/UI_VISION_MC.md b/project_docs/UI_VISION_MC.md new file mode 100644 index 0000000..8886914 --- /dev/null +++ b/project_docs/UI_VISION_MC.md @@ -0,0 +1,141 @@ +# UI_VISION_MC.md + +## Doel + +De webapp moet geleidelijk evolueren naar een web-based bestandsbeheerder +met een workflow die geïnspireerd is op Midnight Commander. + +Het doel is **niet** om Midnight Commander exact te klonen, +maar om de belangrijkste werkprincipes over te nemen: + +- twee panelen naast elkaar +- snel wisselen tussen panelen +- actief paneel en inactief paneel +- copy/move van actief paneel naar ander paneel +- keyboard-efficiënte bediening +- focus op snelheid en weinig muisafhankelijkheid + +--- + +## Ontwerpprincipes + +- web-native uitvoering, geen letterlijke terminal-clone +- behoud van bestaande backend API-contracten +- incrementele UI-ontwikkeling in kleine stappen +- veiligheid en voorspelbaarheid blijven leidend +- bestaande werkende functionaliteit mag niet regressief kapot gaan + +--- + +## Doelbeeld + +De UI moet uiteindelijk bestaan uit: + +### 1. Dubbel paneel +- een linker paneel +- een rechter paneel +- elk paneel heeft een eigen current path +- elk paneel toont directories en files +- elk paneel heeft een eigen selectie + +### 2. Actief paneel +- één paneel is actief +- acties worden gestart vanuit het actieve paneel +- het andere paneel is standaard de doelcontext voor copy/move + +### 3. Navigatie +- pad boven elk paneel zichtbaar +- directorynavigatie per paneel +- later uitbreidbaar met keyboard shortcuts + +### 4. Bestandsacties +- rename +- mkdir +- delete +- copy +- move + +### 5. Taken +- tasklijst zichtbaar +- status polling blijft mogelijk +- copy/move blijven task-based + +--- + +## Buiten scope voor de eerstvolgende UI-stap + +Deze dingen worden nu nog niet gebouwd: + +- multi-select +- Insert-achtige selectie +- viewer +- editor +- menubalk zoals F9 +- chmod/chown/symlink tools +- zoeken +- directory compare +- volledige keyboard mapping + +--- + +## Eerstvolgende UI-doel + +De eerstvolgende UI-stap is: + +### Dual-pane UI v2 + +Deze stap bevat alleen: + +- links en rechts paneel +- per paneel eigen current path +- actief paneel +- visuele actieve paneel-indicatie +- copy/move gebruiken standaard het andere paneel als destination context +- bestaande backend-endpoints blijven ongewijzigd + +Nog niet in deze stap: + +- multi-select +- uitgebreide keyboard controls +- viewer/editor +- history UI +- nieuwe backend features + +--- + +## Verwachte impact + +Waarschijnlijk vooral wijziging in: + +- `webui/html/index.html` +- `webui/html/app.js` +- `webui/html/style.css` + +Backend hergebruik: + +- `GET /api/browse` +- `POST /api/files/mkdir` +- `POST /api/files/rename` +- `POST /api/files/delete` +- `POST /api/files/copy` +- `POST /api/files/move` +- `GET /api/tasks` +- `GET /api/tasks/{task_id}` +- `GET /api/bookmarks` +- `POST /api/bookmarks` +- `DELETE /api/bookmarks/{bookmark_id}` + +--- + +## Acceptatiecriteria voor de volgende UI-stap + +De dual-pane stap is pas klaar als: + +- beide panelen onafhankelijk kunnen browsen +- elk paneel een eigen path toont +- één paneel actief is +- actief paneel visueel herkenbaar is +- copy/move logisch vanuit actief naar ander paneel werken +- bestaande functionaliteit niet stuk gaat +- bestaande tests blijven slagen +- UI smoke tests waar nodig zijn bijgewerkt diff --git a/webui/backend/__init__.py b/webui/backend/__init__.py new file mode 100644 index 0000000..02a9009 --- /dev/null +++ b/webui/backend/__init__.py @@ -0,0 +1 @@ +"""Backend package.""" diff --git a/webui/backend/__pycache__/__init__.cpython-313.pyc b/webui/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1b4f8aea8186c2558d4367bca8eadeb72ab088b GIT binary patch literal 179 zcmey&%ge<81lrCUGR1-PV-N=h7@>^M96-iYhG2#whIB?vrYZra#N_PMycC53Af1?= zs^_Q4c#AzgJ|#anK7J*`XOOmA#`@*?McKtbC8_%5sY$tsc|es#y18WqAZ}@^M96-iYhG2#whIB?vrYaG~f`XjPd3xm7pCh_TsphwKKDB z6x0K^lI9R-n~*~fIkva{3H=j{jEcJ=ZKU+V4MeL-J@w7nMn;O#OGnx_-`n?Q=grJF z-yXHJ_z<-3lRstKC4^pbr{3f$z(;ogJVqMQ@D#d%DUNU~ObJwgUYrtdNK}fT1*sot zk|vMhE(FNajkKniQ2j=%+94`yUaeX44GGkvwP;E=`dp$-TC3)FkvAi#fqkBl_kI@36SVK@Grs6p$ctO&{ zw;-ZPfMgeWsGMn1y^dEiZ98ja*nSfv{L`AwrtEoU)4>RsPaQwa=WSx-vIaAmBf>k! zZyRixq!&rSut^lCB!mTOhOzVAuIWGF~_l=kh?`ASBnG)S;|so=dzY*J%|-n`Re_w#f*gEl}4HJjm&e8ihi1(MTytS0g3>EHEUcGBL zV69-jLN7WZWhqkW2>&95X{esC>W|*a=ZYΠg76Ry!I!l89d)zo-h1m)Uu0WXy}l z=+cP|=L~^O$46_JZ}>d8)t0E8d)?;*ABMa*&p;Y}wtBLijAez(>vII@ih4*2k0rkzm2wiyPIG{(p&LU(M-qFR^ z)+_4LYvYnG9#ZB=$eYTR=?1%om(?Z~EO)BBF>-U~8A zT79tk@XK}SccpDTww2sSR^Etg#2(HZ22e-jpbhyuc9rON$(`<@ayVL&qV7d`HP;JJ z_W#NC26i{Yr`=Y+2|DQOs4#$3>Eziaflas4G_sqp&_E-_POh&mnv7z*$ zDo`%))DK3L=~<3@xruP&$^}0U6}uUX)1!8DexL_oHCd#C5I6@98v?V2_JZN9(Qige zXWlK1=;h$8-QdD@aG@OhY;Af^3GVb=F89TLp59jGO46LWl^`Y_YT89=QNBnh>jHFc z0ctbX%cUX&K`$v6akoxKITn1AQTRF4>h0%<7ws113S(8)T*i4ns4DM=bQn_jde}8E z2O`G!IqH9jnxCV-Lk|*^je%`^>Tf~7od*cak%)w@1K0JV_TEa{z@aR52_+Qz(;vXY Me+>vYf!&Gz1(C#rQUCw| literal 0 HcmV?d00001 diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..325f93fde98654052221aee888574f9232800331 GIT binary patch literal 3811 zcmcgu%}*Og6rZt;F!ejkLJMgDsjb#+8f9Cx!4S)sg}4E)_0~>9 zPf`01^paCAIpo5ve@1Tw87a~VPHk1?7UcAhTi=`Y+Pj9RRh4$NJM-Il^YMOf-g`5R z-d;b$XYS5VrJs8k`;~sI$72HCc)%HZ#1y9RER#9#IAK@TCA+~busiFKyP!+-=| zvfZ)|d;)i6dt^WO1@>koIRF8HyR*G=AM^?A%Le6s=oh#rJ0OQ3B(OglmLm`mSjrB{ zLog(8AUiCNz=*)T*-<$PQGxriWAZqR$Jv@Y!IYrVKhKp3WgyDF_RKX|!Gui~Q$h|| z%qE*u!VcM_O%_)o4q4nLn^FcHvMD8@3{eXtlxbyHplM}B84+kkNh+fPC6$yC6)2@# zP{sthusxoheMXw6xgizGWvyDMmb9{tzdbup%NGm9yK35H_!PBTEtR)*Be1Pj^Lq0S zyM>lD?cQzy?%h%=nqI1Eu!pUEpBHpn%RV;-J}m91`kr1@cb7H`m8uG~#?3QQy)$ISw(NR1V7d8tIkf6^VcubtChU05q+dFYJ^Gx~iw$;6*t|R19IU&kdhtSe;gi8}W;UO6B54p?FU%ZwkDrRuHP? zVoB9Al|7@6wxfbp%x{w!A&TR|_~`^5vfoCNKgJ$@d@MyC&pu6DJ;YbKcHp_zKq+%j zbzy^`N~)Yk6-n?LfHBPW8H`~CqgWV2q`+2dZ}SCnb0LO8>Yx)pR74uDOR)n_Obm6b z{MwAXFO1y1xgiW<+1E!}2CC>Y`KAPhNiShxlS7y|h#t^#VwlEGM55D@qfSfyhkPK8 z%Ud+M5krECPVI+D`;BH4=4}j;*qc~%GMIRL_i5_ALwu!$1JA-)23MI_r1s&dHI=sv9x3w|+Z6`raAsi!2EF1+U=(p`u!S;rTAcZu_>C=fkdDGz0 z)aClrm7|s0^_ASAw07WGJD>FIFPz&Lt8O zdn1wNA&*mcFbn-9D674?L?c8x$7bjZN?()~$!E z{hAoT8#osd2x9eW0udrWJD1lgdW*s(9PB|9Gt4a(w1fU7UkI&^!sFr8!;CjYW9 zVV7=hWLj2fp9MhL8?la#od>WuxTox$w)9N<5NX{s$VA4Z zy`QcYYf!50Wo+r1t|3O!*-PYOy{)0OZ)6B#`ux7mTu$aTQs?$O8>D3SSEz6%!_wZp zV&b!LL7QkeEY)P9XM|BLUgQMEh+SI}>v=N)nP;+aak}@IIY>CWNJMmO!6hmvsRCsj zpnwGO^3HhhE$nKWwH@_3e1cNCYU#gV@!ZWhKVgaA+2kK=?u0F!u=x}A{z(%rp0MQ; z_JJs|{o<*YxuhT7KIDT9my6Fg+{`ufoY1MiE#Q2zDL{1UYYUts9D>ExREy53)_H5L fb^gM~c}|pTqMU!>>*C%Q{aF0j&-ooLZXACB4izW2 literal 0 HcmV?d00001 diff --git a/webui/backend/app/__pycache__/logging.cpython-313.pyc b/webui/backend/app/__pycache__/logging.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..17fb7ca6eaa566bc270987954f875e2594dadab5 GIT binary patch literal 477 zcmYjOze~eF6uwI?@kedNK?|LVP-qaFO%Ozff}lbKGdjpMy@uHQ;;w1Y$<@iJe}s$p zx40;0HzzkCxce@qr3deO-+S-7@9wy~T3tu5_PVdZ3*;|V%$HV@d}rQv6Zy!}o%>F! zO@%iKB4&wZs~xg23u9VV3lcFQs1FiLlSZMD<{;q_9a*|u2@~HBVqaEzR0Li(i3fo% z$(eI{`HXUPsywhK2_K4-dd!}%UPNPX{~J?afv+_!0(+NE75y^~vBVU}@r zQ+D|V_^DTk2EYTFk;*%%zmfW^w*3@7h98?JxuF{-SHA$aH8~q)Ja#ONo6sq?^EJhk ifL8$I>QR*RvydJ08k879Q~`c;jPWO`{h9>-`~3k0cxmSV literal 0 HcmV?d00001 diff --git a/webui/backend/app/__pycache__/main.cpython-313.pyc b/webui/backend/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c9edd7395ec99623bb6825c93d37fbe08579dd4 GIT binary patch literal 2580 zcmbtVO-vg{6rSC+*Iut}FeWx&k}Qx^aY!IhlvI@bkv5626^Oh^dx)!*u?N^4(H-0lBCQPBhbK<5Es94^-~c*6CciP{bO@ zH9Hb=1WfwiGg*7tKnv&(Ilk5&|8D&0_^V)att0W=|IELZIP!e>*|0Oc^?739O+kzV z-g>2=xQQgM_|9a>`etaDqugK~;PzlK*~xDM&`iF{sr(tvNs2H=#pa1AZ_NCJ2#aQm znUljns6=qCpb^8c$Z=Soh0kmS!eV_DY+o+aG?@kMT9*Q8gs5@0ImT(eZam7vl*uHzR<2TQ=2f1 znNp6-n1ucAXzTVGa>GiI9^TK87+;x~h9>OU)lTfAoj2f_$c(z0-TP?T2ckiHa&`Cf zS~6xal^Jz4_w#Dy%o&pg?a9@R?vB7|G6a4yi2C8SAg`K6eUN>l2)n?25hxgw`mc?s z*R@YZGgQ1g+MMl<1Ku;$vZa@>-OQ)}w?Mrm19((`S3tvhIbW<6wnL3TY2gZMpjxwh zh+Q>Qn8M9BX+K$V3<7FCS^ve7QK%O2WpWdEtkakn=%&DN+&b!ggF601$JWuwb=3a` z^{=Cw>u6*hU0O$1Tzd93iu{J6ujQ6y|C7tBa!*6uE@*YO@XL{DQdV3oSiZe6aLz@!`urtloLy#q5jedi=^t z;A-7-^|ctPx1VZ=rx%1ZBrHjb(&OV#6F-=>3qOASb9*Cl`IkEjQUj$xxQf~usBQUD zEmmjf%ns{fO|)5qJFMaFXKmKNf3fP(zS<32?c9#qwzEwU@!nM~*5G2xb1R&(#RoWX hOF(?jCPSNDAYO_u#vex?b#L(;*Ue<%w+z|W@HdbaaLNDx literal 0 HcmV?d00001 diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d152c9d92e27dc58876470471de843d81edeff42 GIT binary patch literal 4567 zcmchb&2JmW6~Je?pIm;3lw^yt6p5lL*(=$SQd@3?I8Nj^jv)bRpd|~nF0@{eOPV&d ztIRHg>gH479*X*6AZRZJdPs!!5TKVH<2ZkSS)>rLh>HS254q8nPzP-feQ%beXqjr` z2JH}d`{wKI%zHDxndM$ArV>cMS^K#Bs~$qW#7rv*fx_**3(7sB6P;ToOC06WsE3wA zGz3q6nO_p9z{-SWaY>>Q)5T?ZNuf%bq)3M7(g~u=^P#{Z4VOZi@)c$@&X1V3?RchF zc5HVA^1{2OcLSdBtm)obwYD6$>^XEBN|IN~70carJ!^BZU~YL9%?Abp*|g5>y$|_& z?*npvr@FE zud-oRZQG(>p>Oki#~5*lkmvS>*$~$fm3HM?y27kTd?cqrCBFbix;mKxp1C)E@TC1F_Hp9{`03}0mWVN}xI?LC3 z*Up_2bWRT~@V1x}SJCRBG~djpNr~+6D_L-br`pp@*@Sy^~yF|oPNZEWm+*d zwml2p64-Vn=m(C5L=$KtY1!{pt*X`Wj^Pf!zEBIBj<~OaxJ|xDp4d%}{V_RK?;F_d zo2>Os)_VuNp-9 zgS?AjXFh$wt8JTpH?Ov92ha+xl-p!KL3)R}`SlM!dX#+U-*~ojb>l{6p3TA@T_@|@ zdT3o(m+z4*S%s>@X%)BMp_JuxE-Pe1u(lDFyw1&Wo3b9x$vKh><+z*xK1c8qd`B75jGc5NzORuq|;OfxDHmV_1|r6lRB$@Ku%rsue6h z7U-KWd9wtNEShjI6mRF^=FRVb^G$n9kbkn4_*hvJ0rcr!L z_<+oDR-1P=wI12IYP>!;3JwlWJ|SXQ zZN$m3p$``CzII1>CdlFV9s$wl?Fq*_;xk!`PJUGUWcpDQv{FaKbS*l~0yhbPYse&a z`Y91&@vrw)k{H+{%5zpu)uN|@D1R|Bc7OcWH|oQg`q23OE486%>>eXA?QcYm#sAUZ zq37RSq~h=A7d7%gQ!kz5ADo@Kl;J^+hkEcpJqP5L)U0{3J5jG}lQqIg-Ncyrg(;MBtK2HS^e>}JfgG*(8|!`O5R)e7oS!+5W1R+=?E2CPHsdX=(m z*^Xh*B)&g`0`FAp+@x$d9WxC0i1NyL!}L5_-l)RokzwqRU^^hEC?2~^;K}0N1@SYo z9}0<+`-&tEG!ij!;4jJkh6vAwL_m{KQ^@hbMi?~}wFqia(w}O?P>T~azTX4d{zyiQ zH?DGL#W*tp&!!R70wbseMo?knIGiZ^-oW#h|R@V`W* zyg_km`XkM6>KXXHo+)h1urJ4hk8u`ff2^}n(VaQ?KsU_b4-B>n0{`UjO^EB@f~Z^w sB6E@c7|QUf>!v|8c#h*9lgyVS@tCCkN){fIH=ao@$Hku#l+41v0EhRqKmY&$ literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/__init__.py b/webui/backend/app/api/__init__.py new file mode 100644 index 0000000..7019960 --- /dev/null +++ b/webui/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API routes and schemas.""" diff --git a/webui/backend/app/api/__pycache__/__init__.cpython-313.pyc b/webui/backend/app/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d885cc224bec2bee05f71a1ad8d9b0c5e6e7ea36 GIT binary patch literal 194 zcmey&%ge<81lrCUGUb5uV-N=h7@>^M96-iYhG2#whIB?vrYdpA08fRY{L+%tVui%K z6oumCjMUu3Vm&`i##`+1@hSPq@$oAeK7(}MveqxpFUl@1NK8)EFHcR%P0UM7Pc72T zEh_+VOEdM85|gu2^HTH^3krZZQ$IdFGcU6wK3=b&@)n0pZhlH>PO4oI8_+C}bBaMu R`M}J`$asrEwulAD0RS98Glu{G literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/__pycache__/errors.cpython-313.pyc b/webui/backend/app/api/__pycache__/errors.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48221a3c18ce9972a82507f6086b560e1e839308 GIT binary patch literal 682 zcmYjOO=}cE5bfTco&9qBfUHPBSQny9Q1_IB5CoMVCbE|~y3+K{be1N&Gh=m+G2%hU zL9ZV2BfR-TJP3j;4T2yZa*K>V!0MTe*icpP)$6XV>MnzzOK^R7^htfj_|uW|<=23g z+XzlbL?XIJb}7(-!;QVhE(10o6LyUZNyOGk2u<1fMRn-@a= zeE&G~-DLZPDF7z}_cylp@86^b&t*D0uVR_If32@_P1i-PI=Q`FKh1quM@rH3O>}3B gQu>_?e~}w!K56v+G(5Wb{-`7vN`{HwE>YF_3zN&U&Hw-a literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/__pycache__/routes_bookmarks.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_bookmarks.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a382acc2f8237de9638901545b1006ff5c78346d GIT binary patch literal 1765 zcmaJ>&2Jl35PxrXy=!}ICvhBF^65GauA$hLNI)SGQcFeYnpVY2Zx+_Zd1>7^ws~)j zpa{u<1N{e(IQB?RaD)^8!bUl?tW>E83Dlb_Q=#5Evmc4EBG&SF-pst;%=~87!(=i} z;CbWIpPQd3g#3wz@QL^e9llKo`JR}>q!nUNPP3>>70HmfY$#mu&T=JUs9ZHPt{G7t zHDWyG*_BG%(7EpEkxIfy@?@6O-owg`QZtT*{Yt>HAX57`Tur6zFi-O|LT<&ys_bk5q zK4+HAs_e5K6Sn(GXuiT)49%hoD9lZS_G>oVb2H)Tqo%OK7|5Kt&FuPC09+T0f8J~` zuup{Xrf<3zFNIM8+-jGzo#q!V7N6ol%KBbso3$F!2t&9f-x5NHRZx~m7B=M;tp@WN zi6gg2b@X1~0xRs@_+!#_={$Ky-k}BQ2GFH+E&GleX<1vWRgm3@hR4rwS9}rx_!J0; z)p$Ug7W67GZ8qy(C|TWZY0GNt0^Oc&b(8)Gc8{RfK?w*Vrx6scvETRs zy@AZPV3;Sx6)4XDia`7IppciX>qNg9~=MMFANBXj(EC;1cP1Ad+(pTY_ z>&@-2|1(P&ZhNI(1m*u{8NQKzvHT2Nz@38afDJt^7{;NGWb=#n$Kh8$3->sRMfeKr zWsK$+yxZOexT?>*$K?ST0ygm& zcB7l{&h}dDJzfACE{^yM#6YH$J|?-p$!q^A5h@J{i027H-+fFj{Siz5aPcsf@2dlq zNYU@+_UG8rp_3k%|Ke z)j>d!iK_4m~cXY=OK>c`IsrROnrZ6JX*h+^o%{(>{H)+=jZe;1iI5*c z87^%AX#X>Ucf=$ntrCL*%?m75B|`?;P(U$MP(zzs)eIf<5G&P~5r=q))oQ{>LNZV4 za)B&F{E(`rYSWQ2vYA?4H}xf{t`>+HTc871sKw7Tr9kRu+I9Id8U4(ZnaGk&Ws{!e z%4{Ty=~F>fq(RblUC+0D$8&j64&t}(uhhJb&mhq6vNm&@JWwBW80=zf<_>r}oYfd_ zV>Cm{#0K-Nwa{vD22Y%Zu%-ub=D@J0mj@Ao0iv_kw%24^rF#u|q+1p7a~web3W6Du z$BTX_)uN_H;>aUv(j{q@EXwP&D6JNyKxx~4D@gK|w_~-OW|O&?O^fo%kiyfDMiJ;y znOr2!=kvi_*>1PXYj)!?HXCBQ?UX^>0gqZMwRa&S7=#d1dFH1yAf24zaVvDv(1}jEV1|=*g7QV^a7<=D(2npXAyx zSv)4oC-I3_)B9RaBU0=|_G$KD<%7GQ?b3yl@yR!hw~3>v+#!3PI?By<=N7-_ZuWGQ pR=y?DJsJ1!=O%k9?sz&g9eR2(qFwkCM_XSe(v9B|BDjR<{{crn?3w@o literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/__pycache__/routes_copy.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_copy.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d6dc33e891c3683e9c9d6f5a81c0c65ef5885624 GIT binary patch literal 1043 zcmYjP&rcIU6rS1L?smH^5Rgd3NJE9%n6wuO!61oZLf9t6bp4S)lg+Z7;_{BzN-cmb&wm&2INGxK}A~7k@w7^nPGG&lW1(e8_i!oCL)zm zj5I0Blf+UjZH_{+oGgv^j=KC>@v>&=R$@*rE0d(G&(Kcp4!uEokCn2FYh+W|q%&P2 z3~VHG182g^(a>;ewR+$LUcJV1QmEg5v|Oq;0tTV>kol}u5m95Y?(de^iw5ICIQ-b* zTZ_P)fRz~ck;kyk=mraH7q{5~rftsPrRR#opx_FM)d}sXg{rsBIHpt|Sk-&I7b^(j z*>};A6b?YQs=ZUMu_YH_l=^wy z6h@W)T^(%EIq4>um8&!-tzb7QuQ$MDp;5uRd$mZYoE#3hkpP!BhC$@R5E>+D(A7%o z>cGYM>2#PWIKE$4cib&hHo}hY6+j3sF6i&({oPQHWE3L7DAJPn@+_J&vQJtGk{mfU zGDk+{%k0;MgN4Ia$4je6ORG&I(=^ta%Gy6JZuNF5OILC#3?l|HoG>DcjwV!r1r4Zy zhE8rUOxSj{fdOXQAd9?Kbx?BO15pTs%Hh>+%Xw@cUoX$y=WNH}ozkLEIM%2=r{LB+50AQIt#w6O|-E236>9w(!*%@!byuK3}62Te<+ zl%9~uQ!;%@7Ej3C6Y}7Q|ViJ>~xJ(?gtN&ux(0|mx;8{lW^Kgg-{>weTh(u vLr_J4nn0*x2vwvAHI7h44{8aaKzkBYK0wL`oboSl^2liEIl$!k1<1hPBdeIl literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f04b648648b16a1655c4c7d5591fb3d83616a14 GIT binary patch literal 1043 zcmZWn&rcIU6rS1L?smH^5Rgc$k%kJjm-Zqd7$i|l2yG;$tIM z1{f()mM4g*n%XRdcsX85_Kv#vn$fam>Sk`)<9)vr?end9YNfH+%+xc8__iRuNI-etn0P*s})XelYyd z=9>$^Y#&41LmtCAqwCDKwsD)~W7^^jc3ej!1_f78%uZ-m%~#zm#xbROz^d+x-AG;# zPrr$dq+kHLRk2)!Z3PC$_8Q!B&~s4t^A$n#h7j5=AxM)H>i&q9x*DACLhy)``gz?H zMwR|u9cc?VH^5rwNR+691J<302eogLFB^_8YF4Z)k^E? zz{UBgRFKZwo|j*4!pocOFkzf>QaeR3O%_-R2=r{LB*?cKNMk~w6O|-OQKeE4kx3r#pWS}uK3}68%;~5lpd3b z6Ebx|7LLiSV{-SGKK$Xvp`PATTPl&FZmnItfbc3JQ-~BJg)zU>@z)3zRUz3|dh+ zHN%h*`6t8B+F&|%B=V)HqnY)3!!~WJQRnG^6S}+nsL*KH3>$wq&PGk9*5nJ6G|M&I00vLedStu%pEtrw2yAqrm7 z(q^N=YGrSHcVb)*c}EECT}EY+=(v(qQaFilVTAYurO0)uN_XjFOdPIZe{Msfr0O-r z#ckb5WzA+Y`_wFN;FPXzHmxj(;d%c~ruhQGq)B$OKnSsp0?ckf9aJ>Z3W#wX ziQ8}tfivg>MZ{xHxKygP@B%B9Kn&=pRg>FDBM3e~{FR(@9>W=A8-G@Ym!Gjsle;?F);QPdz#D{zBWOh5zrdRf- zueE0uJJa`n5lZ!*L;_d40o1xdAG*{|-w@D+{khxiJIkH9CjvSppf`UabPAGK_zw+9 B3`qb0 literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36808e6cedc85fc6747074596f4cdf98fc731110 GIT binary patch literal 5631 zcmb_g-ESMm5x*0U^v z9UDk!0RckQSG@%AzmT{7DFp-=!U63|(Ff(JfUYln>ilN!$g^-_WjF%#yWQXHE@x(E zXXoyuP{=CuSNi&|_LQzD|3oMJOUj0N^1Z4k-zp8Ip{^*)s!(fakFAU?Cq$yATvTS2 zM&h#4(4HhdO2|1vV>U@_D#9k2O%tm}*c7uFVzUu8&1{a?e1z4REf8Cbuo-4c#Fis$ zmf3M)D-kxw>;$pZ2%BeilGv#TTVVDAu@@t3k=aYcUXHLOX0H(YqX=7OcAD6k2s_T~ zRbp!qw!-W+VrL`l1haF*UXQRK)~Zr#$m6yCDM?0Xjp&dufmV{^Aw`y?>$+FGZwssGcE$FCjw`m0 zSw;M|r(qL4t!KI~Je@Y!Ubn55v`k#00jJf!0uF#B99S9d0dGWce1BEifidxR+$G7m z>`0OwsX@Gl5uV)0)U#gZGrMg`W7wlGMoa@{2)u;daXrnkcPw!gB%Hk#YtS`_0a(TX zykXEOt1vL$s{gY)+Hg;(^`t)|^+~YZ#E;tHvJyTd6*FYG`T|9fkZebPx(;b{W(tNyns8uN!>@P{3A&=L& zMv@Ua56*s|X>j)6r&+8jht~+d-oZcsW}Ku6*sq~pki_ft*1jw}vT$}oXWpijhZ=f% z$NGj-_Xu4-#1sJLI`DWH1H)qsalFDclAObl#Zb+_lRu$pLp?`m_cIIx@C*ioDO2yH zQQ^MTw%mc^K02>LpI{(>XABhr^-c;ELxlnteSBVxeu;qqo-x!2RJ=ydNHSv6;_eV| zr?W6PoilTW>W?h-a@@;P{gJ5`y!@A@v-yzH){<*&9kZHz&d2PQm+AH_!G(~QLP5}X z=-KUyrbRi8(t_QQ!faDmrfapRTv+>WmMWC6K*_D!9ou=q5{{pG`s^!si42athd+e? z0PP&)3krTcx3_pys_d;DUZU!j=-fF(}gZL%jf{aTxr%0d*c>_12{LfP}igieu= zp-9MbBv2*FJsB6eNOLbA$aL-%f?kGRK(t0F=w-QA4thE6jR(Cv_bS|jUK0UdV7?mk zirkyz-jpb9==BR;1@64hmF>XU>@s*tECB8S?gJhGJ|kG$$x$|H5pB!q}kNB*eUSM$QpjUh#zx5TGyVvb)hZpGA zw646E2HQX4c6x%*08Bdxst`^ZG#Xvq?;TR}^TIRK|MPH-?q*Z|~}5!P%%%s`Fsb%?LUz@)upI-mPr zNV_mF-mV%+M%fj-Vpl`WQ7jAsdz&T)w_qrTWlzN_2}UU2ff#@V#2SEu2Gl#LITfmL zs#5zCeq!kAvk%@&3-(C4l?iQr{?(rd7Tsw2U%C*<0;8p;f**SMTLzhn(f>Ule`;S$HB~mqf zwD9y_$ZtP)MW4DV{X1#Ge~hp#8p%E4ps z6+f?0o5s+?FDBG0lE32T$JFA%$G$=jKdVxk=N5f^jK))&9+IbZNbKv;F=&T;TAjl> z^zf^yI&-j%@iV?&R_9(_@)dgcH^xY}I@X-@vtw!*JU!5k@}*b&yCpxXsa32=55Gu@ Ty`na+sjB%`kN-pMJUi-tNFzGI literal 0 HcmV?d00001 diff --git a/webui/backend/app/api/errors.py b/webui/backend/app/api/errors.py new file mode 100644 index 0000000..6b16816 --- /dev/null +++ b/webui/backend/app/api/errors.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class AppError(Exception): + code: str + message: str + status_code: int + details: dict[str, str] | None = None diff --git a/webui/backend/app/api/routes_bookmarks.py b/webui/backend/app/api/routes_bookmarks.py new file mode 100644 index 0000000..1781047 --- /dev/null +++ b/webui/backend/app/api/routes_bookmarks.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from backend.app.api.schemas import ( + BookmarkCreateRequest, + BookmarkDeleteResponse, + BookmarkItem, + BookmarkListResponse, +) +from backend.app.dependencies import get_bookmark_service +from backend.app.services.bookmark_service import BookmarkService + +router = APIRouter(prefix="/bookmarks") + + +@router.post("", response_model=BookmarkItem) +async def create_bookmark( + request: BookmarkCreateRequest, + service: BookmarkService = Depends(get_bookmark_service), +) -> BookmarkItem: + return service.create_bookmark(path=request.path, label=request.label) + + +@router.get("", response_model=BookmarkListResponse) +async def list_bookmarks(service: BookmarkService = Depends(get_bookmark_service)) -> BookmarkListResponse: + return service.list_bookmarks() + + +@router.delete("/{bookmark_id}", response_model=BookmarkDeleteResponse) +async def delete_bookmark( + bookmark_id: int, + service: BookmarkService = Depends(get_bookmark_service), +) -> BookmarkDeleteResponse: + return service.delete_bookmark(bookmark_id) diff --git a/webui/backend/app/api/routes_browse.py b/webui/backend/app/api/routes_browse.py new file mode 100644 index 0000000..2169806 --- /dev/null +++ b/webui/backend/app/api/routes_browse.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from backend.app.api.schemas import BrowseResponse +from backend.app.dependencies import get_browse_service +from backend.app.services.browse_service import BrowseService + +router = APIRouter() + + +@router.get("/browse", response_model=BrowseResponse) +async def browse( + path: str = Query(...), + show_hidden: bool = Query(False), + service: BrowseService = Depends(get_browse_service), +) -> BrowseResponse: + return service.browse(path=path, show_hidden=show_hidden) diff --git a/webui/backend/app/api/routes_copy.py b/webui/backend/app/api/routes_copy.py new file mode 100644 index 0000000..d547859 --- /dev/null +++ b/webui/backend/app/api/routes_copy.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from backend.app.api.schemas import CopyRequest, TaskCreateResponse +from backend.app.dependencies import get_copy_task_service +from backend.app.services.copy_task_service import CopyTaskService + +router = APIRouter(prefix="/files") + + +@router.post("/copy", response_model=TaskCreateResponse, status_code=202) +async def copy_file( + request: CopyRequest, + service: CopyTaskService = Depends(get_copy_task_service), +) -> TaskCreateResponse: + return service.create_copy_task(source=request.source, destination=request.destination) diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py new file mode 100644 index 0000000..35fa518 --- /dev/null +++ b/webui/backend/app/api/routes_files.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse +from backend.app.dependencies import get_file_ops_service +from backend.app.services.file_ops_service import FileOpsService + +router = APIRouter(prefix="/files") + + +@router.post("/mkdir", response_model=MkdirResponse) +async def mkdir( + request: MkdirRequest, + service: FileOpsService = Depends(get_file_ops_service), +) -> MkdirResponse: + return service.mkdir(parent_path=request.parent_path, name=request.name) + + +@router.post("/rename", response_model=RenameResponse) +async def rename( + request: RenameRequest, + service: FileOpsService = Depends(get_file_ops_service), +) -> RenameResponse: + return service.rename(path=request.path, new_name=request.new_name) + + +@router.post("/delete", response_model=DeleteResponse) +async def delete( + request: DeleteRequest, + service: FileOpsService = Depends(get_file_ops_service), +) -> DeleteResponse: + return service.delete(path=request.path) diff --git a/webui/backend/app/api/routes_move.py b/webui/backend/app/api/routes_move.py new file mode 100644 index 0000000..ea42f85 --- /dev/null +++ b/webui/backend/app/api/routes_move.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from backend.app.api.schemas import MoveRequest, TaskCreateResponse +from backend.app.dependencies import get_move_task_service +from backend.app.services.move_task_service import MoveTaskService + +router = APIRouter(prefix="/files") + + +@router.post("/move", response_model=TaskCreateResponse, status_code=202) +async def move_file( + request: MoveRequest, + service: MoveTaskService = Depends(get_move_task_service), +) -> TaskCreateResponse: + return service.create_move_task(source=request.source, destination=request.destination) diff --git a/webui/backend/app/api/routes_tasks.py b/webui/backend/app/api/routes_tasks.py new file mode 100644 index 0000000..945d182 --- /dev/null +++ b/webui/backend/app/api/routes_tasks.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from backend.app.api.schemas import TaskDetailResponse, TaskListResponse +from backend.app.dependencies import get_task_service +from backend.app.services.task_service import TaskService + +router = APIRouter(prefix="/tasks") + + +@router.get("", response_model=TaskListResponse) +async def list_tasks(service: TaskService = Depends(get_task_service)) -> TaskListResponse: + return service.list_tasks() + + +@router.get("/{task_id}", response_model=TaskDetailResponse) +async def get_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse: + return service.get_task(task_id) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py new file mode 100644 index 0000000..f02d2fc --- /dev/null +++ b/webui/backend/app/api/schemas.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class ErrorBody(BaseModel): + code: str + message: str + details: dict[str, str] | None = None + + +class ErrorResponse(BaseModel): + error: ErrorBody + + +class DirectoryEntry(BaseModel): + name: str + path: str + modified: str + + +class FileEntry(BaseModel): + name: str + path: str + size: int + modified: str + + +class BrowseResponse(BaseModel): + path: str + directories: list[DirectoryEntry] + files: list[FileEntry] + + +class MkdirRequest(BaseModel): + parent_path: str + name: str + + +class MkdirResponse(BaseModel): + path: str + + +class RenameRequest(BaseModel): + path: str + new_name: str + + +class RenameResponse(BaseModel): + path: str + + +class DeleteRequest(BaseModel): + path: str + + +class DeleteResponse(BaseModel): + path: str + + +class TaskListItem(BaseModel): + id: str + operation: str + status: str + source: str + destination: str + created_at: str + finished_at: str | None = None + + +class TaskListResponse(BaseModel): + items: list[TaskListItem] + + +class TaskDetailResponse(BaseModel): + id: str + operation: str + status: str + source: str + destination: str + done_bytes: int | None = None + total_bytes: int | None = None + done_items: int | None = None + total_items: int | None = None + current_item: str | None = None + failed_item: str | None = None + error_code: str | None = None + error_message: str | None = None + created_at: str + started_at: str | None = None + finished_at: str | None = None + + +class CopyRequest(BaseModel): + source: str + destination: str + + +class TaskCreateResponse(BaseModel): + task_id: str + status: str + + +class MoveRequest(BaseModel): + source: str + destination: str + + +class BookmarkCreateRequest(BaseModel): + path: str + label: str + + +class BookmarkItem(BaseModel): + id: int + path: str + label: str + created_at: str + + +class BookmarkListResponse(BaseModel): + items: list[BookmarkItem] + + +class BookmarkDeleteResponse(BaseModel): + id: int diff --git a/webui/backend/app/config.py b/webui/backend/app/config.py new file mode 100644 index 0000000..bf58af2 --- /dev/null +++ b/webui/backend/app/config.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Settings: + root_aliases: dict[str, str] + task_db_path: str + + +DEFAULT_ROOT_ALIASES = { + "storage1": "/Volumes/8TB", + "storage2": "/Volumes/8TB_RAID1", +} + + +def _load_root_aliases() -> dict[str, str]: + # Minimal env override format: storage1=/path1,storage2=/path2 + raw = os.getenv("WEBMANAGER_ROOT_ALIASES", "").strip() + if not raw: + return dict(DEFAULT_ROOT_ALIASES) + + parsed: dict[str, str] = {} + for entry in raw.split(","): + if "=" not in entry: + continue + alias, root = entry.split("=", 1) + alias = alias.strip() + root = root.strip() + if alias and root: + parsed[alias] = root + return parsed or dict(DEFAULT_ROOT_ALIASES) + + +def get_settings() -> Settings: + task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", "webui/backend/data/tasks.db").strip() + return Settings(root_aliases=_load_root_aliases(), task_db_path=task_db_path) diff --git a/webui/backend/app/db/__init__.py b/webui/backend/app/db/__init__.py new file mode 100644 index 0000000..a81230d --- /dev/null +++ b/webui/backend/app/db/__init__.py @@ -0,0 +1 @@ +"""Database utilities.""" diff --git a/webui/backend/app/db/__pycache__/__init__.cpython-313.pyc b/webui/backend/app/db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d6901c8d49e743b63cc13b87c9fac5526a921bb8 GIT binary patch literal 162 zcmey&%ge<81p7laWJ&?)#~=<2FhUuhIe?6*48aUV4C#!TOjW`zi6x0iiN&c3r6rj; znI)O2#d?04jJMe1<5TjJH{+)BjYUw$s!gY2LSwvC=CDr literal 0 HcmV?d00001 diff --git a/webui/backend/app/db/__pycache__/bookmark_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/bookmark_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..221eb8fba2911fc3c22b50a5c297930256f6d20a GIT binary patch literal 5406 zcma)ATWlQF89sB{o4psW?>4c=n;^1@u~Q|`LW0XW*%aT_>CP@m8q?8wcbqI)&zdvi z60D>_sG@cN84E>mDx`#`mOk~7O0CrJ#G@?(UX4ISqA$EHtR+`15B>j{owfIpq{rSf z|NQ4a|G9ntS%4ae)fs4(c%9g$d`li@HS8MM6Y!^bpDU3~Onn?#pc0^&P&18KWtss(C%7 zXY;BSgLhv#uj%ASM=<>Vz{tg%Bu><@#j+dm9(YNsyXYylzt1u zZ8AeBBN3P=LpiBgVtdG|Fj>hfaV8~5K8ZIehvb(8lX6M{oGQqbu+&ZZ(3V`TW)S6ulZ(7V=tF&(k-)bA!5cc^6F0NW-+5 z(lH=zlM8{I$;K*4;^hTZSU}i=i{vu-n2F);$eCGr0rtZ1$%?8iQbpF%SCsh_wDFpf zn=_nc0S!kJMdQeDYGGk`HkH1rs2P*a%nr|%XDidnbcYt+0PS*C&FZpj^wzIu$ZkX+ zX{}JbNeW&4?|KWa=51H+rmOc0*MSnSNqYZMoWL*_J;>mqVWIG>E%riH?$H~;5|vN# zaiO+90mxnmCb<)w4Leo1gd>|JDa2hiz4v^kJP+0J5RGioae}e+#ldK5OJ&whFc0{% zWkp=2w^o{v^G# z6*8#fK&yraZf_lJK~;XMw`_|Vp#~VQk?}!C?H#B9>h0ZkURitP19{m~@V0Dw4{Ul5 zeD3YPe*zn3)@F(<*V0yWkzmLAs~drEsmamdTRywvBfWj=bK8AGn|(uDJ;Tcrg z!L`9c;9Q~QiT8aafot{cI*7k><;pv+7M;W!*li^~vDiZbZL2p{ZmdgN{(%kGz-}*a zzwq||M{9=pd=zOK6}V3Y&uFVNT?R6+330&BG`DYnc$~R`IWbA5k>`;Z8>)?rtpa?E z3st~B&$g16IgK4A8c;8Rm&hNBR}2xRR4z%*)_;zjYA2)#fUmJC_ejGSwhTi9Q$g7| z5@usd@76*5@%XLlQjO=j=IQvEXj~kt3~=zi3}A$v>KPKop(;Hohq_?OIwL^ zwE1?MZIpS5w`G?DJbfP^!^gM$$N!wUuif#kdDqil20|ZaHvPvpT*r4?EX4izLx2QM zXaN{wO^oz#fA5JL#%ozzVWxO-dwqPzh%=O+Hi$-1=~kp?y=T( ziu;=<;(3UJh=JRBBr95sSg4A+8!kUx~NJ2OW(igguE`)LkYIkJy(<8Cj0l2yUT4 z3}z?+8JTk-MHN-o43}~Z?K*!o)eS4Ja~r4Q(FmY15gDC~ieqQR*mOdSUKo=S(!L;j zxUL(z4G6lB3q*eb{^?wNY$_7JC{9E#ijibudMpNdr=qdMV8fmmS;a*3Lc$y_mYkdv zld-X1CZmnT%@ABI-&k-jNLNeOO`<{Wv0Nuq-kP!4ndpU@t;uGtRjf;s_lA`-ik7ZZ zG#%U9%foxZrXR1LN!Up-YEi_I;joU;;2>c!g!!wPEZuVp;37=Fj_%QP1{#E40w-fS zbcz{7LoZ&Pz7K<#b{(oW$ydRS_w_qB)^2PCpImkpnu4n%t0Nz#??3g&p^t{P`hN0B z_g3F%p}BQ6wi4TJ7B`#4Kec_rZy@!;H`NiKIj}mpGFfDuEp0oT+v_W~f#bJWkgV)* zg4eg(2`*#X+yAAVSv)v$aI}N_w8JxcsM>L$@k7L7N=E>~2Z@Nz16WpRh6uucp{J3*My$$R$jo%;F@eAeohad$()dTYD@P?}hocyFrc zK?senjK4Se?;SS@be9P04r24v%GA5lh31x`0F3`aCTz?7s9}(J>)Fv(=C5s$e)7QA z0_21KCn6`g2Pe762zf9P91U=v1~}kXWwUtE19M_Z&jV2%tRw~-Z7e0>I|aVOlPf=Y zd`iL)e~5$~F@w!!=mFr+KCCcnf-rs>=^8FWyRnh6(lfw1DVs88ag8s=s)uNO%&R@);rf`3OxtbbDKR!zjq4(Pmu_24{d`!Y4}wN z;@({MTR=SC&MsB#B;LS|B<4A$QAoA1-N>FW*xJ`w*gqb>)v)@~lH=&HW6vKuW-z}B zJ1B0j!NFaR;;06IwobUvjUPWZTxOQn^MiMc=vp&cmM@asbP&Nu@C+EJ(w zoSm!JZ(aZJ+P&*{uW$MXxBbs<`k&qM|7_d;;->$_e{_8Yx$E?XYueNgCx+e{zLHSW z7vu6^lIH0mZ5=#PhJz~lB2^9EylS#E2FxW7=GB*w^9mrt%E<*J-*y)*tBNSr_8m)#WSmYRn`I>~jCBCo8 Zlm8@7eNDP{9Z7~c$NT_Z93JMx{s*mtKHmTU literal 0 HcmV?d00001 diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..edb7b801e2d800d3d42ecae0b749d9bc34fbe336 GIT binary patch literal 10910 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 literal 0 HcmV?d00001 diff --git a/webui/backend/app/db/bookmark_repository.py b/webui/backend/app/db/bookmark_repository.py new file mode 100644 index 0000000..4735e24 --- /dev/null +++ b/webui/backend/app/db/bookmark_repository.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + + +class BookmarkRepository: + def __init__(self, db_path: str): + self._db_path = db_path + self._ensure_schema() + + def create_bookmark(self, path: str, label: str) -> dict: + created_at = self._now_iso() + with self._connection() as conn: + cursor = conn.execute( + """ + INSERT INTO bookmarks (path, label, created_at) + VALUES (?, ?, ?) + """, + (path, label, created_at), + ) + bookmark_id = int(cursor.lastrowid) + row = conn.execute( + "SELECT id, path, label, created_at FROM bookmarks WHERE id = ?", + (bookmark_id,), + ).fetchone() + return self._to_dict(row) + + def list_bookmarks(self) -> list[dict]: + with self._connection() as conn: + rows = conn.execute( + """ + SELECT id, path, label, created_at + FROM bookmarks + ORDER BY created_at DESC + """ + ).fetchall() + return [self._to_dict(row) for row in rows] + + def delete_bookmark(self, bookmark_id: int) -> bool: + with self._connection() as conn: + cursor = conn.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,)) + return cursor.rowcount > 0 + + def _ensure_schema(self) -> None: + db_path = Path(self._db_path) + if db_path.parent and str(db_path.parent) not in {"", "."}: + db_path.parent.mkdir(parents=True, exist_ok=True) + + with self._connection() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at_desc + ON bookmarks(created_at DESC) + """ + ) + + @contextmanager + def _connection(self): + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + @staticmethod + def _to_dict(row: sqlite3.Row) -> dict: + return { + "id": int(row["id"]), + "path": row["path"], + "label": row["label"], + "created_at": row["created_at"], + } + + @staticmethod + def _now_iso() -> str: + return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py new file mode 100644 index 0000000..8d3cfae --- /dev/null +++ b/webui/backend/app/db/task_repository.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import sqlite3 +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +VALID_STATUSES = {"queued", "running", "completed", "failed"} +VALID_OPERATIONS = {"copy", "move"} + + +class TaskRepository: + def __init__(self, db_path: str): + self._db_path = db_path + self._ensure_schema() + + def create_task(self, operation: str, source: str, destination: str) -> dict: + if operation not in VALID_OPERATIONS: + raise ValueError("invalid operation") + + task_id = str(uuid.uuid4()) + created_at = self._now_iso() + + with self._connection() as conn: + conn.execute( + """ + INSERT INTO tasks ( + id, operation, status, source, destination, + done_bytes, total_bytes, done_items, total_items, + current_item, failed_item, error_code, error_message, + created_at, started_at, finished_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task_id, + operation, + "queued", + source, + destination, + None, + None, + None, + None, + None, + None, + None, + None, + created_at, + None, + None, + ), + ) + row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + + return self._to_dict(row) + + def insert_task_for_testing(self, task: dict) -> None: + status = task["status"] + operation = task["operation"] + if status not in VALID_STATUSES: + raise ValueError("invalid status") + if operation not in VALID_OPERATIONS: + raise ValueError("invalid operation") + + with self._connection() as conn: + conn.execute( + """ + INSERT INTO tasks ( + id, operation, status, source, destination, + done_bytes, total_bytes, done_items, total_items, + current_item, failed_item, error_code, error_message, + created_at, started_at, finished_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task["id"], + operation, + status, + task["source"], + task["destination"], + task.get("done_bytes"), + task.get("total_bytes"), + task.get("done_items"), + task.get("total_items"), + task.get("current_item"), + task.get("failed_item"), + task.get("error_code"), + task.get("error_message"), + task["created_at"], + task.get("started_at"), + task.get("finished_at"), + ), + ) + + def get_task(self, task_id: str) -> dict | None: + with self._connection() as conn: + row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + return self._to_dict(row) if row else None + + def list_tasks(self) -> list[dict]: + with self._connection() as conn: + rows = conn.execute( + """ + SELECT * FROM tasks + ORDER BY created_at DESC + """ + ).fetchall() + return [self._to_dict(row) for row in rows] + + def mark_running(self, task_id: str, done_bytes: int, total_bytes: int | None, current_item: str | None) -> None: + started_at = self._now_iso() + with self._connection() as conn: + conn.execute( + """ + UPDATE tasks + SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, current_item = ? + WHERE id = ? + """, + ("running", started_at, done_bytes, total_bytes, current_item, task_id), + ) + + def update_progress(self, task_id: str, done_bytes: int, total_bytes: int | None, current_item: str | None) -> None: + with self._connection() as conn: + conn.execute( + """ + UPDATE tasks + SET done_bytes = ?, total_bytes = ?, current_item = ? + WHERE id = ? + """, + (done_bytes, total_bytes, current_item, task_id), + ) + + def mark_completed(self, task_id: str, done_bytes: int | None, total_bytes: int | None) -> None: + finished_at = self._now_iso() + with self._connection() as conn: + conn.execute( + """ + UPDATE tasks + SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ? + WHERE id = ? + """, + ("completed", finished_at, done_bytes, total_bytes, task_id), + ) + + def mark_failed( + self, + task_id: str, + error_code: str, + error_message: str, + failed_item: str | None, + done_bytes: int | None, + total_bytes: int | None, + ) -> None: + finished_at = self._now_iso() + with self._connection() as conn: + conn.execute( + """ + UPDATE tasks + SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ? + WHERE id = ? + """, + ("failed", finished_at, error_code, error_message, failed_item, done_bytes, total_bytes, task_id), + ) + + def _ensure_schema(self) -> None: + db_path = Path(self._db_path) + if db_path.parent and str(db_path.parent) not in {"", "."}: + db_path.parent.mkdir(parents=True, exist_ok=True) + + with self._connection() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + operation TEXT NOT NULL, + status TEXT NOT NULL, + source TEXT NOT NULL, + destination TEXT NOT NULL, + done_bytes INTEGER NULL, + total_bytes INTEGER NULL, + done_items INTEGER NULL, + total_items INTEGER NULL, + current_item TEXT NULL, + failed_item TEXT NULL, + error_code TEXT NULL, + error_message TEXT NULL, + created_at TEXT NOT NULL, + started_at TEXT NULL, + finished_at TEXT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_tasks_created_at_desc + ON tasks(created_at DESC) + """ + ) + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + return conn + + @contextmanager + def _connection(self): + conn = self._connect() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + @staticmethod + def _to_dict(row: sqlite3.Row) -> dict: + return { + "id": row["id"], + "operation": row["operation"], + "status": row["status"], + "source": row["source"], + "destination": row["destination"], + "done_bytes": row["done_bytes"], + "total_bytes": row["total_bytes"], + "done_items": row["done_items"], + "total_items": row["total_items"], + "current_item": row["current_item"], + "failed_item": row["failed_item"], + "error_code": row["error_code"], + "error_message": row["error_message"], + "created_at": row["created_at"], + "started_at": row["started_at"], + "finished_at": row["finished_at"], + } + + @staticmethod + def _now_iso() -> str: + return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py new file mode 100644 index 0000000..d5a92aa --- /dev/null +++ b/webui/backend/app/dependencies.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from functools import lru_cache + +from backend.app.config import Settings, get_settings +from backend.app.db.bookmark_repository import BookmarkRepository +from backend.app.db.task_repository import TaskRepository +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.security.path_guard import PathGuard +from backend.app.services.bookmark_service import BookmarkService +from backend.app.services.browse_service import BrowseService +from backend.app.services.copy_task_service import CopyTaskService +from backend.app.services.file_ops_service import FileOpsService +from backend.app.services.move_task_service import MoveTaskService +from backend.app.services.task_service import TaskService +from backend.app.tasks_runner import TaskRunner + + +@lru_cache(maxsize=1) +def get_path_guard() -> PathGuard: + settings: Settings = get_settings() + return PathGuard(root_aliases=settings.root_aliases) + + +@lru_cache(maxsize=1) +def get_filesystem_adapter() -> FilesystemAdapter: + return FilesystemAdapter() + + +@lru_cache(maxsize=1) +def get_task_repository() -> TaskRepository: + settings: Settings = get_settings() + return TaskRepository(db_path=settings.task_db_path) + +@lru_cache(maxsize=1) +def get_bookmark_repository() -> BookmarkRepository: + settings: Settings = get_settings() + return BookmarkRepository(db_path=settings.task_db_path) + + +@lru_cache(maxsize=1) +def get_task_runner() -> TaskRunner: + return TaskRunner(repository=get_task_repository(), filesystem=get_filesystem_adapter()) + + +async def get_browse_service() -> BrowseService: + return BrowseService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter()) + + +async def get_file_ops_service() -> FileOpsService: + return FileOpsService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter()) + + +async def get_task_service() -> TaskService: + return TaskService(repository=get_task_repository()) + + +async def get_copy_task_service() -> CopyTaskService: + return CopyTaskService( + path_guard=get_path_guard(), + repository=get_task_repository(), + runner=get_task_runner(), + ) + + +async def get_move_task_service() -> MoveTaskService: + return MoveTaskService( + path_guard=get_path_guard(), + repository=get_task_repository(), + runner=get_task_runner(), + ) + + +async def get_bookmark_service() -> BookmarkService: + return BookmarkService(path_guard=get_path_guard(), repository=get_bookmark_repository()) diff --git a/webui/backend/app/fs/__init__.py b/webui/backend/app/fs/__init__.py new file mode 100644 index 0000000..8d0d8c9 --- /dev/null +++ b/webui/backend/app/fs/__init__.py @@ -0,0 +1 @@ +"""Filesystem access layer.""" diff --git a/webui/backend/app/fs/__pycache__/__init__.cpython-313.pyc b/webui/backend/app/fs/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fe42820b5f59c129719a858ed94e629e7238107 GIT binary patch literal 194 zcmey&%ge<81lrCUGUb8vV-N=h7@>^M96-iYhG2#whIB?vrYZ@y%$(HX%HopLT!qBs z_ndpr`<{6kk4FeRmq+gya}pt6P661;{m`5QRHU206;fhzCvwXaGk3 zG(X5weo&x-J1?9T2ZJ;ylUCA66!CSU1p5QtPBe5jkP3Z+FDZ^iHPf^l%`q&~9!>?A zl+_&FG4eW-5YAer4phQVG-oQ~@q3W>2JYz;AlJyx3FQ<5qHvT~VoKn3at=gM;tKC# zqEe>_E*4bkQ5;weDG4R$ViM4WTr3QjI|fHJmhZx;yp+1}sK0WqDkwa01@BryqdI*GL~RdH4%soVyBOgiEidFHiTEM}-4(1r7w7 z$d9;FAwo=XB=A;wE!zp)V!G^<@}xVYf%3&i;rq*H`urM5G}Pnn5q^=H5MKf_IOYZf zQlw+Gbe&`*n69gxA4~Xi=|&%`j*4&~kZ!KN`}+t?o;?G5_Au^)6i<Db>J&wnZHv4S}K*g!1SjwxdzUK5sZvOaR=Wm~|{ZNv%AVCYZ_>m;`#buT;cU zzUX9F*s!fhi{>?lg{WT0X&Idb4IA}kL9ic~4$iVwv>W3M6Ubl}6e}gcQF?BYg)6pU zQG3cduTB}+tZuTXKX2$Z6J5hIQ8yhrL${$usFscOM=n1UQ)OB4wg1me zYh8y|x(-CPS9i>F`_1KlzKjk-WMQ%nO%Uw5)-+J%nd#m!>rS9FO?$nd8*xd4* z4>dGCX&@~l+_#-1(e|Kz`vYk^g_I!~%pLzhN4n*^8xY7NF1%2WymKPb{NIZIOf z@JUFUP(h>M3Y$s#M;PALG=!&^p9YsL2WScynNWax%*MlHx$K#|Hmz4PVKdKcq>zEx z#_9^5kzxyx`N&fL?f$vQ=hEArD(i0+#9dW(0P-?bMgr9ubCyu7_aJx>bb&Ik%zKhU zjua`xaWo5oZCs`HjsY5kUFiUVg8=Le>V)&wuXHa(+iI1mETUm;tRt7m zV<|TGvyWqIQrn8u_PgUZ+E&|p9!b5m0VV*s>y;ybY}9HqZTwru*TBE$c9Cvh6UJ-i z3~h()C{k~Tutu-)c$HSw^95&yb=PWCON9|@w*Uaunpzf}>ldzExO#E5X~)C*9S@`( zwR&M(yLxp4^0H|2wW<06Vy$TA40F0xc8Io|?v&lINldL<-i^{mU1>2dqVv(q@&BWU zBF#e{*Gkw@7Qwaax;%cX*20@p_HC3K^7(Sim3ugYapFJN-MO}1f?~8WX8gF=&|y~M z9=b9j%|Va?nYs_DmjQ+)jnuvcm)m z`T!HB+5rqE&Qo$V?UCIJ6~{Xz%d~br?5(X=SS{?gdVJ9ss0&UE|_AALr(H zL=LXjb--mx2zOieywRchcD%oDz z@*3XH(JRrV6Au%e&@X(krQ?Q(H}xN454ZF@iIb+}bDr$jw=n)JLBjP5^}nfK;_tk% zD(!#X0MmaO|88OY^7wal7f5rv;n+d`FGr)tc8d1|3GjP6BggiK@9hgAelYUGaA=vN zf^;X)dIf$LU`&{>EU+!rogy`7w>rf_P9MXrXe?`FoDX-)Rs2Kf6tRW&xHB&IFn)&y zJr}sQ2Ku2&K(p{#UI&Lb)b$h9kq9^N)?eI~PT-Pz-%8%rE`z(Zi@gu)v-Qv!=d_8O zE?RsCgA%avE^Rd6=79SA?&v{aR1$)P_$fEN%w zir@qSNGU>x5U2oHv#MhJ85vb`9BNEJ6Kw;#h6LC(x#2`5#1P=$#=U<{o&}o4&S$-x zxc6C*6T1-fA$Su(3xek7!KirP2?5~kh1}5of*O-W@DZp%d6=SS5MU+r8f<8?oT;2K z;o06jd!sz?-Qn5c2+ac*2Y(ws1;6Dvj{A~yeof;4Aa8z2TAv5|xdZ tuple[list[dict], list[dict]]: + directories: list[dict] = [] + files: list[dict] = [] + + for entry in sorted(directory.iterdir(), key=lambda item: item.name.lower()): + if not show_hidden and entry.name.startswith("."): + continue + stat = entry.stat() + modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z") + if entry.is_dir(): + directories.append({"name": entry.name, "modified": modified, "absolute": entry}) + elif entry.is_file(): + files.append( + { + "name": entry.name, + "size": int(stat.st_size), + "modified": modified, + "absolute": entry, + } + ) + + return directories, files + + def make_directory(self, path: Path) -> None: + path.mkdir(parents=False, exist_ok=False) + + def rename_path(self, source: Path, destination: Path) -> None: + source.rename(destination) + + def move_file(self, source: str, destination: str) -> None: + Path(source).rename(Path(destination)) + + def is_directory_empty(self, path: Path) -> bool: + return not any(path.iterdir()) + + def delete_file(self, path: Path) -> None: + path.unlink() + + def delete_empty_directory(self, path: Path) -> None: + path.rmdir() + + def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: + src = Path(source) + dst = Path(destination) + with src.open("rb") as in_f, dst.open("xb") as out_f: + while True: + chunk = in_f.read(1024 * 1024) + if not chunk: + break + out_f.write(chunk) + if on_progress: + on_progress(out_f.tell()) + shutil.copystat(src, dst, follow_symlinks=False) diff --git a/webui/backend/app/logging.py b/webui/backend/app/logging.py new file mode 100644 index 0000000..3ab094f --- /dev/null +++ b/webui/backend/app/logging.py @@ -0,0 +1,8 @@ +import logging + + +def configure_logging() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py new file mode 100644 index 0000000..a4b5c9d --- /dev/null +++ b/webui/backend/app/main.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + +from backend.app.api.errors import AppError +from backend.app.api.routes_bookmarks import router as bookmarks_router +from backend.app.api.routes_browse import router as browse_router +from backend.app.api.routes_copy import router as copy_router +from backend.app.api.routes_files import router as files_router +from backend.app.api.routes_move import router as move_router +from backend.app.api.routes_tasks import router as tasks_router +from backend.app.logging import configure_logging + +configure_logging() + +BASE_DIR = Path(__file__).resolve().parents[3] +UI_DIR = Path(__file__).resolve().parents[2] / "html" +if not UI_DIR.exists(): + raise RuntimeError(f"UI directory does not exist: {UI_DIR}") + +app = FastAPI(title="WebManager MVP Backend") +app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui") +app.include_router(browse_router, prefix="/api") +app.include_router(files_router, prefix="/api") +app.include_router(copy_router, prefix="/api") +app.include_router(move_router, prefix="/api") +app.include_router(bookmarks_router, prefix="/api") +app.include_router(tasks_router, prefix="/api") + + +@app.exception_handler(AppError) +async def handle_app_error(_: Request, exc: AppError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": exc.code, + "message": exc.message, + "details": exc.details, + } + }, + ) + + +@app.get("/") +async def read_root() -> dict[str, str]: + return {"status": "ok"} diff --git a/webui/backend/app/security/__init__.py b/webui/backend/app/security/__init__.py new file mode 100644 index 0000000..5ed3b23 --- /dev/null +++ b/webui/backend/app/security/__init__.py @@ -0,0 +1 @@ +"""Security helpers.""" diff --git a/webui/backend/app/security/__pycache__/__init__.cpython-313.pyc b/webui/backend/app/security/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c543fa61309ff12ce0abad6fc57a9fbd4b396e33 GIT binary patch literal 193 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 literal 0 HcmV?d00001 diff --git a/webui/backend/app/security/path_guard.py b/webui/backend/app/security/path_guard.py new file mode 100644 index 0000000..78fd6c8 --- /dev/null +++ b/webui/backend/app/security/path_guard.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from backend.app.api.errors import AppError + + +@dataclass(frozen=True) +class ResolvedPath: + alias: str + relative: str + absolute: Path + + +class PathGuard: + def __init__(self, root_aliases: dict[str, str]): + normalized: dict[str, Path] = {} + for alias, root in root_aliases.items(): + normalized[alias] = Path(root).resolve() + self._roots = normalized + + def resolve_directory_path(self, input_path: str) -> ResolvedPath: + resolved = self.resolve_path(input_path) + if not resolved.absolute.exists(): + raise AppError( + code="path_not_found", + message="Requested path was not found", + status_code=404, + details={"path": input_path}, + ) + if not resolved.absolute.is_dir(): + raise AppError( + code="path_type_conflict", + message="Requested path is not a directory", + status_code=409, + details={"path": input_path}, + ) + return resolved + + def resolve_existing_path(self, input_path: str) -> ResolvedPath: + resolved = self.resolve_path(input_path) + if not resolved.absolute.exists(): + raise AppError( + code="path_not_found", + message="Requested path was not found", + status_code=404, + details={"path": input_path}, + ) + return resolved + + def resolve_path(self, input_path: str) -> ResolvedPath: + alias, rel_segments, candidate = self.resolve_lexical_path(input_path) + root = self._roots[alias] + + # Resolve symlinks for existing prefixes; for not-yet-existing tails strict=False keeps + # path normalization while still enabling containment check. + resolved_candidate = candidate.resolve(strict=False) + if not self._is_under_root(resolved_candidate, root): + raise AppError( + code="path_outside_whitelist", + message="Requested path is outside allowed roots", + status_code=403, + details={"path": input_path}, + ) + + return ResolvedPath( + alias=alias, + relative=self._format_relative(alias, rel_segments), + absolute=resolved_candidate, + ) + + def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path]: + normalized_input = (input_path or "").strip().strip("/") + if not normalized_input: + raise AppError( + code="invalid_request", + message="Query parameter 'path' is required", + status_code=400, + ) + + segments = [seg for seg in normalized_input.split("/") if seg] + alias = segments[0] if segments else "" + if alias not in self._roots: + raise AppError( + code="invalid_root_alias", + message="Unknown root alias", + status_code=403, + details={"path": input_path}, + ) + + rel_segments = segments[1:] + if any(seg == ".." for seg in rel_segments): + raise AppError( + code="path_traversal_detected", + message="Path traversal is not allowed", + status_code=403, + details={"path": input_path}, + ) + + root = self._roots[alias] + candidate = root.joinpath(*rel_segments) + return alias, rel_segments, candidate + + def validate_name(self, name: str, field: str) -> str: + normalized = (name or "").strip() + if not normalized or normalized in {".", ".."} or "/" in normalized or "\\" in normalized: + raise AppError( + code="invalid_request", + message="Invalid name", + status_code=400, + details={field: name}, + ) + return normalized + + def entry_relative_path(self, alias: str, absolute: Path) -> str: + root = self._roots[alias] + resolved_absolute = absolute.resolve(strict=False) + if not self._is_under_root(resolved_absolute, root): + raise AppError( + code="symlink_escape_detected", + message="Entry resolves outside allowed root", + status_code=403, + details={"path": f"{alias}"}, + ) + rel = resolved_absolute.relative_to(root).as_posix() + return self._format_relative(alias, [p for p in rel.split("/") if p]) + + @staticmethod + def _is_under_root(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + @staticmethod + def _format_relative(alias: str, rel_segments: list[str]) -> str: + return alias if not rel_segments else f"{alias}/{'/'.join(rel_segments)}" diff --git a/webui/backend/app/services/__init__.py b/webui/backend/app/services/__init__.py new file mode 100644 index 0000000..02dea84 --- /dev/null +++ b/webui/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Service layer.""" diff --git a/webui/backend/app/services/__pycache__/__init__.cpython-313.pyc b/webui/backend/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c8ec431b24ab8ca9700f9b6cf02dbfd23ff2a43 GIT binary patch literal 190 zcmey&%ge<81lrCUGDU&(V-N=h7@>^M96-iYhG2#whIB?vrYgSR)S|M?`D*$mzGxd`a zle1IvQuGrG3iOL1#ue+w$7kkcmc+;F6;$5hu*uC&Da}c>D`Ep02y#j>$Qd7)85tRG KF^Ckg06741@G`al literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/__pycache__/bookmark_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/bookmark_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bd0192f34376f0489573f956b22df059a5a32ac GIT binary patch literal 2882 zcmb7G&2JmW6`%bgmmeY}(iUk;h*q*b%(|wwc4fy-)Y^fIB%-U>+|n?hz>5`kC~r+I z>6xWd`_w}b_}D`Xw1=VwaJ3WL}^7(MvrMgpAVlsCIvQf6|=035z~ z^Jd=H@4eZ*v9TnAapJ}k=gT-kFZm)Ga!3e!HV_YxfefL7)&wGGoE9r05#e8|NNX~Y z1D;$_)?y^K7ANsFm8bzvsU+5tBpJ}LN@^`l(i$qFIb_6-B12sfLoH-%ODrW`@kL2s zDa&))b<}76fShvB*+p8;mx$xYe*ALw_VDQ`~H@058rnveH~TR*`lg@aUIVzS#iXdaWb;XMdd0qvusCam;zy7D^+&T%V8MwO>+WYpv;cYI{jBqM%Kbd`;Kv|*%m zR8uYqXM}k)i#B+if>9lhsNsJcSx0yr4tS;l(vn8%$F)V?HqfdxgBn6y}6_a-h|o4BbtRO1|nVB7gK_{@|h=RcO2TyNQ!#kVn~))r+|U{=qVZ$SUqK_8g|m-QDajP- z5vNUfj!2eYm?oIEns;!3Z^C24vUO15VKagyQweTDd$3v1W7or5#PRM1 zor1*RU@1l>_`O_NXzjS$BHIv>d)PJupZQgRI-9A{^^~853V}fm@uQ0{v6;xReg&6D zjN+Ekf58OcpPv1DYX7`BpaG_f~{{MuM z&%8#;Smt#G9V+(n$Nc=UUS9X}`jbOD`7`&`*Rq<(>=veag(bhR)GM6z3upg%y|=pV zuda8mHM)hf-NM%WkM?tDeA>@0{pBW1U3KZ3{RuQ)=w%oD?7|+3B{G5bh27%$K>N(Y zVsG}8KYOZMJbl0h2?W2R9L-Nt^B2#fr{_<8uq4$XVS_O!-9G^fN*7fJTSKBcD99l% zD&9Q0CQxx1Q8AA`f+BE*4JDE?L?{t~m&g_Iw-C@2(vT~H#GqVj(0o3%(f03_ReUw-@c4@ zA9?{MNVfjVo_@jCFZA?JeEpMW`qiEB>c91?kMdtk`xEmIrDqfKzJB$-(jBjUl|A+d zG6I0mzx`T56NW%J@c(vrRj&768(1QgfFFcV*!vuapAzH)Jxr{LgC1Fc9ytuM5O&D} zK^=5H=pzd6nj74LVAOaYg@Bjh>nszzDDX6!jaJ9C?+xB!?K6vNz=t9SzVihDhNYr% zG98=nHx05RI(A9v*R&nH--G-F9e_#D4G6cipCiIi!{VW={dS(t@~t0K<;b-^2O(O4 z2@;i??B(YC+*~hr%Fms8^4?DF*Z1QuvXfD1=s{^L{zH0H**X4hw|H(?8T)B8zW8X$ z&#rXU6$0Tzg!k`tz*>$kc(@4f^n}+3^qrvPyvrH9OA=187{OkLxM6D&$$NYwz@r?x zDLJkea)SsY_>=2e+ZdkQq-k!q>`s%@Y16#jv6}sztZ6nJLcONrVz*_QBIlixp$}iri z3FS<9gbS52+_FXo+ycu8Ko$`WIyTw=tSG}L2K<~d=AUmgOmbm#lh$wIZHoqpAC@Vb zJ1A#wmIr_ue#`{nW-|u`DXw?;Gf*CW{)FiX+}SH6uMtc^6ut*q0i?kI literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..086f8d3b2364d7d34807b4b5c598dac875ca612f GIT binary patch literal 2168 zcmcIlO>7fK6rTO@dTnDn;Fu)*)lD#Ei)u?1Q5*V0QxaM^B0^aiMNOpD#-1b#&f1+> zlZI1I7032~#Ie`jdZee)o;YR&m7|e3_0Vz)aU{g8@6FnYgH)-PjZVscHJGb(Ww zm$W`-RuU{xAfsfQ*s)W@HYU|bBTKHuE%OC3mLgKF?>7TC@R~liG?Dq7H8(k3pu7bO zRAgp6Mr%QnZO!@t+Y*WEUV};!&K^gGZ}EU`OxN93Kp8A1Zo0wx7j2i-YmvQZ?ip9Q z7lD63mI+gA!c<#fnr+zXDRLXGV#jSwQZZX+##+oWMLGgxQ+C&@(HAD1u4w~`Q7`aH z_l(rLIMg`UTr4z{KC=dQowrnxa#{e-SwnoltR7qd?yhs%SQW_uf#l&nN27&8L0m6y zHrXBCa%;4_Nmn*p-(92Zy^XsqWZPbO#jV|;e!c9rT4f%==H(UXR;S08T3f<&9MAUx z#}OxwT(Q(&Pk=$5f$3YabGrD<9EJH0bF_C0-WUsmPV4Pe zmC2rid<{m=&H7WQFvoiA(T^U8^Y0=t?D^)%iwC6a5YE1{D=_WSVEL? zR3Y4u?Ei){-4h%5fkpJg8w2!csHdOT!0X7{u_^7QdgBv9QlvoqxNlTrNAO&gc#ikg z{f~GIdCxws_>>N~rhB+d@Ja3fgSIf-rw{*&-`M7qb{@P~fX_Ep2Pi8skJZc9o14zM zSFclF=)Su_h3T&FW}_Vdwb875s~)Xe8XE45oxaGHEGvdl5}Ag_gToy*hKkVhXpY~{z8oTB7rW{BNOJEOP~YqODUv?DlOKC{;fH1T zb@Ih9U+mS=yyXYB05+u@kCAJ@~Fmq|Fjn{!( z7RcCmH)|eG?qz@iy=js@`5*Q6toPL?VxS(-jqhejBC|_C>HaVoI``v;or%lg#O24A z!tBzvx%7JP9kBd*?*s_Qmh_u3tzLUq|2;EpTr2CADkCp*V;+)f2pq_;M=&fhhCReG zqNLJzz?h7szS3+qgu!UgX1MLg zND_tStrUV-Z38|4!LWadkzR8zL34-CEAiSo-GKDP%#}$l6Ir}HD7VT>hwq5vMlYrG zig;^Kj?-G3dBIlc@CAyzC^5f}77p$q%#S1Ye9At64Q%7Q0Mo9fD9Up({uj9sk{i#- S)#qgLm2pN{R`v+aQo}#=$@@$I literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a0d58722ccf4abc106eef680cb3212dc085aa72 GIT binary patch literal 3868 zcmbUkO>Y~=b(YJIC70C7)EB*`#I-HUqUDN~Q`L~+*pWZbRI-JFYX(jgbh{!)%GT79 znOz!|F9~`moI{cJU?91qIog*ZhaQrf^AEUCiX|2@5TMAxH>EO=qP_IZ>@LaB(xn6J z?3;NX^WK}8_ud?2GARV@>vz61S|URKq=T?UeSkZt1Mmc?Naa@13P(7F!jaX;3Qu?j z^Q+O77>O}Bx*A_ekOYHctHMf>BpDoEO|6JTWN>0Ny^?#?U{vwTG)Srju4IeQHF$)$gITu5y-an_&fAZPz9B{s2))6=Ln`N++yG@lRtth){_uI zJ+2nWR8aF)C03?7479~yOm~SkeF&lTqz@yw3Ti~<)o6vgz^SpNDCR1BnO&uj!vB}4 zF9zD6!DKUAOL*SXBZ5#FO)IM6`q=)e>VD~a&>ngYn0 zID|q7HB-qganG^sXPq>7d8 zmSrm&SkV=Nw_0_bD4Rwdzx)jDtrw*c>KVkVYNLj|#5T4peG7Y{1@XRRfh;;(!?teJ ztw&?838aF&A-xWfzqX^{hlXWakL0^L*}}F$MJoYL@o{vW=1!6Kl8q)NETwpHi>c{} zHEh|2=}$(C8br|qi+e_m&`ls`q4Pu=Qc+#o*s-w%tW3>fgwP!!)b+&)LhqzEMzGbW zKfu0HhPlO}wl^A1*Fm8w2%<`QDZ|pNo$b0|-Y3+ho}h04cgqIj0mZCLhn$Qf-qZ;; zZEplRvI9w=4tm3C3>nkwhHiPswQaqr)eM5GH2rB0V7eCA9JoUW89m+rX;EsAm#}Qj zG9F<?U9FRqkWZ~E6Fe%WxrUicHG;b)h zK9~xNmT{calbl);QONI?y- z+Hg7{bIDV#e9n>2x$;|%{MPgFUHR?DQm1g*EtH%>$t~P;3OAp>vs<|B2>Hj{q;d@ zD!I~vBQ1nXrNfzqkTM-Ef5j)G*Y2u;YJmR$LwBVTmoOOAYLS6*`EWk+7#9l!oq>_{W7RB$9P zcFvLJI^+4y$SHSZ&Ka5O^*9QSNoGDte;laS8O?n<`j?fx zk?}u&=uVWJiPG-Kg}vN~cK(8!UvlzGySdAq-0A%!N}l}z#gZvdX!z9A>8JLyb6?$k zKKu1=9qD#kxJ_twsdfu07zpq|0l*#n4S*+T>i;S{6e!J3qV;Zhmq7Y?gv>}KQl?j8 zpsC#Ddr(Ng;asOzowcBhh?J>51I;fiFjU)SWI3|QZK5~%0@~z?@ii|?ues7p=& z|D!WEd$A1SAiX+@VJT2XLlo{t1d<0@G6h{R=0~mZFvxUiSVUn#iO?KLXtFEv?2>z# zFnai5hsO`A7I+hdmyggC>J?aRDe%L-y+LcOs<5QjEvz`2nj&d4K>R(qsTZQ8}k%en^EDNV8 zll|}hF<|h);omm1EHjZ7KLPtcC_pR zJWB;{(g~Um2!*^j!FG$7eguq?l@LMhP;7LqVdA1di)K^b#!xh;G;O<4Yt<>7*0c{= zdOg5M8U$2g*^mmb+0ZmX%P4Y-cFdV$nnq)^QPp(YCdLM&2ustRA$GJ`#3M8evW`AF zSOxw^^eV!~re3AyV|Tb8f5(ZjtWQr#*NPI=XR`$sfIft7pqHQrt8Q!%dZ@ke;9)Qi zj|BLPc}$;OmY=F729Z|veY~w(%o*NPKeo0p&k}0yCBoe675a%RTx}7<-kE1-o4s<_ zc6hmdDi-jA=MLK$-|V}fZqF5-aK(4?Rq`Gn=sUrp+qchi9QOj9`X1%JM{_UGFJ7Rf V7ijSXD*YH==D6&?5bc?q{{e}TfxZ9$ literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b5ee64188a0f02499375a232ff631dbf1054676 GIT binary patch literal 5934 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 literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb9e9d2def3e9432fc0462a9274b57304908266a GIT binary patch literal 3926 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 P7GGp4H2<6cQ7`%zS~~e+ literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/bookmark_service.py b/webui/backend/app/services/bookmark_service.py new file mode 100644 index 0000000..71dbaa4 --- /dev/null +++ b/webui/backend/app/services/bookmark_service.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import sqlite3 + +from backend.app.api.errors import AppError +from backend.app.api.schemas import BookmarkDeleteResponse, BookmarkItem, BookmarkListResponse +from backend.app.db.bookmark_repository import BookmarkRepository +from backend.app.security.path_guard import PathGuard + + +class BookmarkService: + def __init__(self, path_guard: PathGuard, repository: BookmarkRepository): + self._path_guard = path_guard + self._repository = repository + + def create_bookmark(self, path: str, label: str) -> BookmarkItem: + normalized_label = (label or "").strip() + if not normalized_label: + raise AppError( + code="invalid_request", + message="Label is required", + status_code=400, + details={"label": label}, + ) + + resolved = self._path_guard.resolve_path(path) + + try: + bookmark = self._repository.create_bookmark(path=resolved.relative, label=normalized_label) + except sqlite3.IntegrityError: + raise AppError( + code="already_exists", + message="Bookmark already exists for path", + status_code=409, + details={"path": resolved.relative}, + ) + + return BookmarkItem(**bookmark) + + def list_bookmarks(self) -> BookmarkListResponse: + items = [BookmarkItem(**row) for row in self._repository.list_bookmarks()] + return BookmarkListResponse(items=items) + + def delete_bookmark(self, bookmark_id: int) -> BookmarkDeleteResponse: + deleted = self._repository.delete_bookmark(bookmark_id) + if not deleted: + raise AppError( + code="path_not_found", + message="Bookmark was not found", + status_code=404, + details={"bookmark_id": str(bookmark_id)}, + ) + return BookmarkDeleteResponse(id=bookmark_id) diff --git a/webui/backend/app/services/browse_service.py b/webui/backend/app/services/browse_service.py new file mode 100644 index 0000000..c379f52 --- /dev/null +++ b/webui/backend/app/services/browse_service.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.security.path_guard import PathGuard + + +class BrowseService: + def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter): + self._path_guard = path_guard + self._filesystem = filesystem + + def browse(self, path: str, show_hidden: bool) -> BrowseResponse: + resolved = self._path_guard.resolve_directory_path(path) + directories_raw, files_raw = self._filesystem.list_directory(resolved.absolute, show_hidden=show_hidden) + + directories = [ + DirectoryEntry( + name=item["name"], + path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]), + modified=item["modified"], + ) + for item in directories_raw + ] + + files = [ + FileEntry( + name=item["name"], + path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]), + size=item["size"], + modified=item["modified"], + ) + for item in files_raw + ] + + return BrowseResponse(path=resolved.relative, directories=directories, files=files) diff --git a/webui/backend/app/services/copy_task_service.py b/webui/backend/app/services/copy_task_service.py new file mode 100644 index 0000000..e1b00d7 --- /dev/null +++ b/webui/backend/app/services/copy_task_service.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path + +from backend.app.api.errors import AppError +from backend.app.api.schemas import TaskCreateResponse +from backend.app.db.task_repository import TaskRepository +from backend.app.security.path_guard import PathGuard +from backend.app.tasks_runner import TaskRunner + + +class CopyTaskService: + def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner): + self._path_guard = path_guard + self._repository = repository + self._runner = runner + + def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse: + resolved_source = self._path_guard.resolve_existing_path(source) + _, _, lexical_source = self._path_guard.resolve_lexical_path(source) + if lexical_source.is_symlink(): + raise AppError( + code="type_conflict", + message="Source must be a regular file", + status_code=409, + details={"path": source}, + ) + if not resolved_source.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": source}, + ) + + resolved_destination = self._path_guard.resolve_path(destination) + + destination_parent = resolved_destination.absolute.parent + parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent) + self._map_directory_validation(parent_relative) + + if resolved_destination.absolute.exists(): + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": resolved_destination.relative}, + ) + + total_bytes = int(resolved_source.absolute.stat().st_size) + task = self._repository.create_task( + operation="copy", + source=resolved_source.relative, + destination=resolved_destination.relative, + ) + + self._runner.enqueue_copy_file( + task_id=task["id"], + source=str(resolved_source.absolute), + destination=str(resolved_destination.absolute), + total_bytes=total_bytes, + ) + + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def _map_directory_validation(self, relative_path: str) -> None: + try: + self._path_guard.resolve_directory_path(relative_path) + except AppError as exc: + if exc.code == "path_type_conflict": + raise AppError( + code="type_conflict", + message="Destination parent is not a directory", + status_code=409, + details=exc.details, + ) + raise diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py new file mode 100644 index 0000000..fed183c --- /dev/null +++ b/webui/backend/app/services/file_ops_service.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from pathlib import Path + +from backend.app.api.errors import AppError +from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.security.path_guard import PathGuard + + +class FileOpsService: + def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter): + self._path_guard = path_guard + self._filesystem = filesystem + + def mkdir(self, parent_path: str, name: str) -> MkdirResponse: + resolved_parent = self._path_guard.resolve_directory_path(parent_path) + safe_name = self._path_guard.validate_name(name, field="name") + target_relative = self._join_relative(resolved_parent.relative, safe_name) + resolved_target = self._path_guard.resolve_path(target_relative) + + if resolved_target.absolute.exists(): + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": resolved_target.relative}, + ) + + try: + self._filesystem.make_directory(resolved_target.absolute) + except FileExistsError: + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": resolved_target.relative}, + ) + except OSError as exc: + raise AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + + return MkdirResponse(path=resolved_target.relative) + + def rename(self, path: str, new_name: str) -> RenameResponse: + resolved_source = self._path_guard.resolve_existing_path(path) + safe_name = self._path_guard.validate_name(new_name, field="new_name") + + parent_relative = self._path_guard.entry_relative_path(resolved_source.alias, resolved_source.absolute.parent) + target_relative = self._join_relative(parent_relative, safe_name) + resolved_target = self._path_guard.resolve_path(target_relative) + + if resolved_target.absolute.exists(): + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": resolved_target.relative}, + ) + + try: + self._filesystem.rename_path(resolved_source.absolute, resolved_target.absolute) + except FileNotFoundError: + raise AppError( + code="path_not_found", + message="Requested path was not found", + status_code=404, + details={"path": path}, + ) + except FileExistsError: + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": resolved_target.relative}, + ) + except OSError as exc: + raise AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + + return RenameResponse(path=resolved_target.relative) + + def delete(self, path: str) -> DeleteResponse: + resolved_target = self._path_guard.resolve_existing_path(path) + + try: + if resolved_target.absolute.is_file(): + self._filesystem.delete_file(resolved_target.absolute) + elif resolved_target.absolute.is_dir(): + if not self._filesystem.is_directory_empty(resolved_target.absolute): + raise AppError( + code="directory_not_empty", + message="Directory is not empty", + status_code=409, + details={"path": resolved_target.relative}, + ) + self._filesystem.delete_empty_directory(resolved_target.absolute) + else: + raise AppError( + code="type_conflict", + message="Unsupported path type for delete", + status_code=409, + details={"path": resolved_target.relative}, + ) + except AppError: + raise + except FileNotFoundError: + raise AppError( + code="path_not_found", + message="Requested path was not found", + status_code=404, + details={"path": path}, + ) + except OSError as exc: + raise AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + + return DeleteResponse(path=resolved_target.relative) + + @staticmethod + def _join_relative(base: str, name: str) -> str: + return f"{base}/{name}" if base else name diff --git a/webui/backend/app/services/move_task_service.py b/webui/backend/app/services/move_task_service.py new file mode 100644 index 0000000..9d072ec --- /dev/null +++ b/webui/backend/app/services/move_task_service.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from backend.app.api.errors import AppError +from backend.app.api.schemas import TaskCreateResponse +from backend.app.db.task_repository import TaskRepository +from backend.app.security.path_guard import PathGuard +from backend.app.tasks_runner import TaskRunner + + +class MoveTaskService: + def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner): + self._path_guard = path_guard + self._repository = repository + self._runner = runner + + def create_move_task(self, source: str, destination: str) -> TaskCreateResponse: + resolved_source = self._path_guard.resolve_existing_path(source) + _, _, lexical_source = self._path_guard.resolve_lexical_path(source) + + if lexical_source.is_symlink(): + raise AppError( + code="type_conflict", + message="Source must be a regular file", + status_code=409, + details={"path": source}, + ) + if not resolved_source.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": source}, + ) + + resolved_destination = self._path_guard.resolve_path(destination) + destination_parent = resolved_destination.absolute.parent + parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent) + self._map_directory_validation(parent_relative) + + if resolved_destination.absolute.exists(): + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": resolved_destination.relative}, + ) + + total_bytes = int(resolved_source.absolute.stat().st_size) + task = self._repository.create_task( + operation="move", + source=resolved_source.relative, + destination=resolved_destination.relative, + ) + + same_root = resolved_source.alias == resolved_destination.alias + self._runner.enqueue_move_file( + task_id=task["id"], + source=str(resolved_source.absolute), + destination=str(resolved_destination.absolute), + total_bytes=total_bytes, + same_root=same_root, + ) + + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def _map_directory_validation(self, relative_path: str) -> None: + try: + self._path_guard.resolve_directory_path(relative_path) + except AppError as exc: + if exc.code == "path_type_conflict": + raise AppError( + code="type_conflict", + message="Destination parent is not a directory", + status_code=409, + details=exc.details, + ) + raise diff --git a/webui/backend/app/services/task_service.py b/webui/backend/app/services/task_service.py new file mode 100644 index 0000000..0032e5d --- /dev/null +++ b/webui/backend/app/services/task_service.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from backend.app.api.errors import AppError +from backend.app.api.schemas import TaskDetailResponse, TaskListItem, TaskListResponse +from backend.app.db.task_repository import TaskRepository + + +class TaskService: + def __init__(self, repository: TaskRepository): + self._repository = repository + + def create_task(self, operation: str, source: str, destination: str) -> TaskDetailResponse: + task = self._repository.create_task(operation=operation, source=source, destination=destination) + return TaskDetailResponse(**task) + + def get_task(self, task_id: str) -> TaskDetailResponse: + task = self._repository.get_task(task_id) + if not task: + raise AppError( + code="task_not_found", + message="Task was not found", + status_code=404, + details={"task_id": task_id}, + ) + return TaskDetailResponse(**task) + + def list_tasks(self) -> TaskListResponse: + tasks = self._repository.list_tasks() + return TaskListResponse( + items=[ + TaskListItem( + id=task["id"], + operation=task["operation"], + status=task["status"], + source=task["source"], + destination=task["destination"], + created_at=task["created_at"], + finished_at=task["finished_at"], + ) + for task in tasks + ] + ) diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py new file mode 100644 index 0000000..8b3c0e3 --- /dev/null +++ b/webui/backend/app/tasks_runner.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import threading +from pathlib import Path + +from backend.app.db.task_repository import TaskRepository +from backend.app.fs.filesystem_adapter import FilesystemAdapter + + +class TaskRunner: + def __init__(self, repository: TaskRepository, filesystem: FilesystemAdapter): + self._repository = repository + self._filesystem = filesystem + + def enqueue_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: + thread = threading.Thread( + target=self._run_copy_file, + args=(task_id, source, destination, total_bytes), + daemon=True, + ) + thread.start() + + def enqueue_move_file( + self, + task_id: str, + source: str, + destination: str, + total_bytes: int, + same_root: bool, + ) -> None: + thread = threading.Thread( + target=self._run_move_file, + args=(task_id, source, destination, total_bytes, same_root), + daemon=True, + ) + thread.start() + + def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: + self._repository.mark_running( + task_id=task_id, + done_bytes=0, + total_bytes=total_bytes, + current_item=source, + ) + + progress = {"done": 0} + + def on_progress(done_bytes: int) -> None: + progress["done"] = done_bytes + self._repository.update_progress( + task_id=task_id, + done_bytes=done_bytes, + total_bytes=total_bytes, + current_item=source, + ) + + try: + self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress) + self._repository.mark_completed( + task_id=task_id, + done_bytes=total_bytes, + total_bytes=total_bytes, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_bytes=progress["done"], + total_bytes=total_bytes, + ) + + def _run_move_file( + self, + task_id: str, + source: str, + destination: str, + total_bytes: int, + same_root: bool, + ) -> None: + self._repository.mark_running( + task_id=task_id, + done_bytes=0, + total_bytes=total_bytes, + current_item=source, + ) + + progress = {"done": 0} + + try: + if same_root: + self._filesystem.move_file(source=source, destination=destination) + self._repository.mark_completed( + task_id=task_id, + done_bytes=total_bytes, + total_bytes=total_bytes, + ) + return + + def on_progress(done_bytes: int) -> None: + progress["done"] = done_bytes + self._repository.update_progress( + task_id=task_id, + done_bytes=done_bytes, + total_bytes=total_bytes, + current_item=source, + ) + + self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress) + self._filesystem.delete_file(Path(source)) + self._repository.mark_completed( + task_id=task_id, + done_bytes=total_bytes, + total_bytes=total_bytes, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_bytes=progress["done"], + total_bytes=total_bytes, + ) diff --git a/webui/backend/main.py b/webui/backend/main.py new file mode 100644 index 0000000..60b7ff5 --- /dev/null +++ b/webui/backend/main.py @@ -0,0 +1,3 @@ +from backend.app.main import app + +__all__ = ["app"] diff --git a/webui/backend/requirements.txt b/webui/backend/requirements.txt new file mode 100644 index 0000000..f2f2421 --- /dev/null +++ b/webui/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.111.0 +starlette==0.37.2 +pydantic==2.12.5 +httpx==0.27.2 +anyio==4.4.0 +sniffio==1.3.1 diff --git a/webui/backend/tests/__init__.py b/webui/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webui/backend/tests/__pycache__/__init__.cpython-313.pyc b/webui/backend/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aed7bf01fd06f378e2f9df5a3d997e4ebe146134 GIT binary patch literal 154 zcmey&%ge<81P#s`GC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iQetCXTc5y*s za;koLYEo`uUSfJ`k#25T0f<|gsh^aXoSmANqF<6)TvDtbAD@|*SrQ+wS5SG2!zMRB br8Fniu80+A63C`v5aS~=BO_xGGmr%UYS|@( literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__init__.py b/webui/backend/tests/golden/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webui/backend/tests/golden/__pycache__/__init__.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58bce9c99e1ad42d9dfca73c6ae1cdf7cf7d5817 GIT binary patch literal 161 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 literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88c47d7a7ae723a9faceedc01ae5daed6704bbcb GIT binary patch literal 12915 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 V*%Ny2#E(zB`|5pxc-k4@{{utsGQa=; literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1976108f9e36a563c6f1886f00682c2e75e0fbd9 GIT binary patch literal 13562 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 literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c8f2f0545f5b1b7ae5b9413c1dc69940c02b580 GIT binary patch literal 13793 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 T{vYmt|K&Rz!^5wXV2J+%cLEYG literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0524c340674ba779155a5ff1d85842b114a893d GIT binary patch literal 10213 zcmb_iU2GdycD}DGIFp(83RG(EcNZ~~~bKH{))*=U^SLTAG?%&_ZR!`VjK$guld z)7c1(Ff5*HKHEZD;v`DCiR@`5viE{>Jh}BgM|dlbMlZx<-|_Dgl7^>*x`(&c?Ix8G z$H}Z?){*c(#7YUy@TCidqMp`s#ey~lY2mwR{o+IH8WQ1IRaf-1_J*RV^sQV*g}mSd zq{r3yqL$N(bnzx<@ag$^DDYu{Y}L!u4#8(7xJUm9;3IN@P);VWf*h<~Sa!6MOSpbv znP4*F4r6BmjW`Y3=zQ+wVm_-DCR9y-*aQ!rDXSBk4Ac;qWA4#u$bAha zWr}5^7%wYKH3NXXd=n&JEuV5%>P&emXo8Fj6>ML}8P)7GqKbmbTpuh@B?bMvD)pv9 z6$HKdE7-o)v?&vokSTmr_JcOz3bwB`ZK`9E`4lJ zG&Mh?;nH0(&i7v~(l@mEbVlvJtWM9R3+Y*v_RYODkI98x|8zR@hFZw>>#*ip|14X7 zmQd33ImRPRv2v61i$;r5d`qP?msK}V%y@RI0wpz7Pt7M!YyP|um`M5+5l;a>S#o=>O9PbTF5L` z$E#7)u7rc4nou;r8m`gnaT)_*3DxfzJYK@u8LY&}!T8txGFyr&py>DEHrwbbosIw5v)jxPimRM(vU#SRfDLCRucj$Q6)@b03k&lUogUNO^7O)Dr|FBu%LSvY z&J0%yu|Ew719L3;teIF7dsf7ryJBxi^j{0SA6OIHSH$*vVkhl}iW!`c$`6%W zuW{hL9Vrgz&4HandOLP9bt#8!?keVDA4%|I8>o$X$EfWGa#pClQ`b{CIQEmd5w1{c zb?dA`H%YcE(-yNbk5dduU_!c)&U;-nKy8n|?FlJ6ul9GWn&h7`^5 zQo}`EpFiGjStD@EDh2v`beb+`Kvvxlr_-9MEYN(yX*jX&4~@X6{PLNJN)3y~zEN$l zkoi_VrxtXBhkzi#(*w}I;mVZrpbHJAhQ#AnEE)?6T__mO)X^~o+SiydsJ0IpYCizM zjMn{V`&#tio#?^8=U1YKub#Ot?OBt0??}D3MwVZmT$85mNK?y-T58|>o5_zSZ}6We zYto^wcoMz9LE)-2^&d4!$XS-A{zv;B^#1P93j+5K+~_{nKk|V7xq0*ee>=#H_PB0` zc*Ko<$iCgdjrO{3ce)VwaG2lAAwJ-Gfpgy;;6{i2w+H=*Pr4x=44DL-KMu0#OOC5B zvjt6sd3L99)^bIIhi8NtWA<(k>e3+))^t^@o`ADrNZU-;UaXHHDA>HO9K5G*-AlsK zwYPuv_Q!`mIl3C^UKYC<3&#>(!>j3Os;gNgtsB0XTp_1jv=ZKIv7jo`i{P3JpAG?E zzMQZMa^ME41y;hJS)dfwT)`xCmIf!0wGv)%TScm5idoeNSa?p=H1LMiAO{UpHOToZ zNPGljs^@pdQFAu^1|Z%~aSDV4DF;YC#e)=50!U}d3DTAFfOMz4AjOmqq$lME=}iSd z`cgrV{!|ELAQc7~Ou0aYQf`ppl&EkOk|K2y4P%BQmo+@ad6lw2*l@u#>I)iNiwiWP z8on%e^jv`z(&u2b^aP5NC{Cex9>r-CqbObgVR$oCO^&gl{K;T##V%K|2 zH)7Gk9>iYK&~g23(}&nk8rrX4G6RT%R(=R^*vfA}+-T)DA&!uS&QJYjGvXF2UqT$U z>cay8!>5Z}NC8rv?_b`m3t)UqcpC<=WD6HMy!* z;2lUk#Ri{I^+46W!N*j5LL5m_hY-*X{oSTtB_fddeloz>eXxl5v9}+B&VE!Q?cu*e zOT)={eN&*vFy%{YsDLlMxsc8qK1<&%Mhlm;V&SG0q8TDB+cXxZ#mj+247C-lRynAJ z$WdQZ@;Oa64%N|}ZEH`!NVL-+tZ;SY!;$M3R+|o76(5NHH8Hv(Mz2TLVtp&Iz8l(I zaTtP+O73skKW_h|^Tx4TFWl(6CqBD%#RAYP%p~_H3M;k!ilKj(1=wb~WVbAixY-KS zTU(Fpm3^{b4#=?88*y#7QgXOrT9^*hiTz+L>%~_7 zU_fi04s!Y(oAe}7-Z}NbUd0eHMc9^jGR3+1yb6ZvaZrO@09f_G{&Z6Rp35gsiueet@P%54&q2|x^}qdt2mMKQ)W{7xN zza*l^3wF{2QF?7ns9dgT23R#Ws}_m=YS+9g`6JiPzkmLNS099>2OYhoXAYLS;cuw4 z|5&N#L}~xQFGFH;3)tn(1S{*SDLZMEH8-_3ntU@@T4%JLr9~ZQyzSXFZ=b+M@hREp z`DSc{0LCk00OOM}fbq*1zyxFrV1hCRFd>XgIIgyR^e+GBZ5}+>|_|e7q(e@;*Uv59L+H`bBcT-jS zYIrkO4R5|z6W;XN;mvaE0kaz4EO++T1UT8607p0N04HnO0gi6k0gg};;OM3u;ABlZ zz=_xa&T_|L(~fYmrXAtvCW~;Kk$Swtvp&YV$zAhK7Rx?Iko_b6Z9QV(DLELlb-eyw zyAuBt9NTzIha5sjD)Tix)%UFqIm~K`yQtZ~YVK?y9C9P8DcF4iSDVI`{EVw**G6B5 z5z;h@jZA&K*{6Yb9h}S&fsluAP(U8SI+2HX@{;iH;OE#@VLSFNNt=y9Wn|16u&syP zmTh#9(KpL`+CK(t2-C&vV)+c993t+i%kOg6M*nUJUT*NWa2M@gL2O`nH)FW%*U$+{ zZKdd*k`ygPW2IQaj4@9a08iIJEKj%Gess0z*tVPxN!Nb#{*ONR^Q(d#ZAPwN{J7&r zY(oSZxt?E%9lSAdR~)LXIJXizbR)Z|W&r}&<^JdIiKm|srylxxyn|?O#Jer$d{4c~b4l7~CyWs!je4@9{SvHgIL%=4g=x zj22zYX2ud9u%xxhl4n<&MxKHtZFj{Zn+dU;eDSXMQf;oqoqOWYRzd_}&&|8kXBWG}{`PmWO0BYGZ@#XjMPF zv^v{;cxlzY4@1=c1;hqIZ4NK39T?k+b(LCsO^I>J51iUl<0vE;4bJ@NUiM8lT^jA(xcVEYr24W0z*Z~lRw5H{yQ%=L}Wv?`}E z18~HfQaKE(?oMD+UfOf0)Hz&=CvKct?mYgbTfoh>3pd-Ia?qa;XgfXwo0e+!&#|IhL9Z0C!}XtF(Mc2xv14|ISP04*p2kv!n1^KukKV03Hu85NnFt%A zqTp+5MKQdJGFQwlOAdV`UWhT1fMN!v2)3J3P); zxb+r?GrlgmoO{h~(%n;nzpiI&xp#jV@9X{Ynf*>L9IH1rn{GrRiAbgg5ge&UOdldR zP>+}aL_yLLGed~Nq&aFfAZjGdon{lF2x*C%&4^ksPeK&MycnWZ%xgom2lLtybr7k` z>_imDye>rDSZ^<)eI(j#vhnp0Z}9i~A#Z)06PzRK{v^QHxh|03<|OARw>}hc4zC|- zah~SB8VES8vv)Q(xM*|fTtQJ1ZMbYR3-I9rEE`25L~-*6UnMMR6u%HL#DzjmM;01x z{461#n`U1t&_h@#V3y&s-e54b#=h~Z2T%exoNyQOc-T0f#`j&C5iNhmk%aG>lGTqG za;o+5g3+;|7|tK@O$~kkVSNx~eGw6^t%Ofuy-e+@%`SgsqFFtZQ|v1WL!5v|`4+sN zwN&M&>?v3`9{{v9$q-Li+I))s8VW#>i1uR;CeLx)=cM}!68s(U{*Hv;?{gCUg7_a0 z{yp&>@x9PHpP-8P8ai;W@&B`8q$mTUh5F_b}LD+2-29DH*j1HJamESLJC0d)Y* zzW3%gZyw*gkJ(Em;|PY+{}21G5rn>FOgPkl5cXaZ5c(7u$PnhxtUv^v(c+v)ME;eC z1Yc=Ro>hp#*W|g#Y?MSft<0&jF%r|!l2{lBRquZbL;G|{8E7dnhL#lgB`4r)HVS8g z@4FAW&+ z>0cpay@C*6zkeJ%hC*v}v?8vEnVx41lo9+M%W+)K@@&_k^T5l$u)Ld0ZZKi-r*;KX zV3q4`&GDYGBY~5wYPERql=dGG_AZ0;Q?!JLU?8{v0h}K-#1V8$CbFR#5~mcPWKKm4 zg+x~(nV8?VU>mFMZTvI0Qo_y+Ouc6*&{b?Zm>a{%ql`3Pm-02cK0dQn#YGR7bl~(==26{q^<}IRykb)i6I{wf2s6Ns5Vz)G z>c?$rJJhqBA|}jwU#`Lh`EuE^D;d$3DXx@#B>*!o=(_)I{sWiXrd6wm^B>^lRm-tf zFv+gIUuA5~&M#ZV+t?}PLBB`yE8OFpF>AI-*)5oXFjrmoV@$|x^+%80QLeMt3un_S zFnx%2)7oe6{rSC(ncdXz=DE+)tipKs-)Fy={p#G~OFw!tbo!5p|0rlse;z@x zb|GI>rsdazTs6HLKZ^75y6J!VFcqA!~%=wBO|mt)Byn{7W$5WfHg!zni3odboX~z zl^3Kipm|7k!zd@fL{5N-9>Ju$*rT2R6FZ23B#F=02)-ZgqH%s`f!gcTIR0?Ln-Yyt`Gl65~GU~iBb_9J)= zvYGlZf~}J2;Wf`!+o~KWxLoPH>s@m)GGUkPD?xq*|KAJ}eAz9nQ+A3DgRmR*Utvjp2Mtn`~&4O>JsNo!(#hO=I|S zOPg+J(@pKlj(YXqX|0t$_jUT*!_uSSM*7W-_^vkE(k^_hUDz6U^ln4@@kX+fb(Z_*dSgY7H{kRFQk5a9| z=!9vqdugwvOtWkg>Q!t9JFaO4_dE`22)xpepEk|VX`V;yW%ix|6ub%%w}KZ{!a6#i zSWHn?jz2~(L|K`7kw_@7|MzrEnF`Ph$n08$N^6>#L1tOG2LAvoBdjv`5?!ahT61iV zUAiA-g}h=f2fd=dY=JdZI~UkgW)ExLO8h>MkP3!PIrff&R>xh!gWQimS%)43x;?^9 zXZyetQHUM4@;+vV5C|gb_qJbkIhF;?#^4>P-T+g6Vu23+M}NTVl=<>uKltjEz|C)w z8Njet7WH6yE(wD06pek0;{QUar%3w_U3`khzd_O+^&|C8>Z8;jgnO^w^ETi4({Ect z*~U<|Ih1b=O*V!mzY-cl)6M=jchHsZkp3d_sxY;w1|8r90_OSK0+JIO^` None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + self.repo = BookmarkRepository(str(Path(self.temp_dir.name) / "bookmarks.db")) + + path_guard = PathGuard({"storage1": str(self.root)}) + service = BookmarkService(path_guard=path_guard, repository=self.repo) + + async def _override_bookmark_service() -> BookmarkService: + return service + + app.dependency_overrides[get_bookmark_service] = _override_bookmark_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + if method == "POST": + return await client.post(url, json=payload) + if method == "DELETE": + return await client.delete(url) + return await client.get(url) + + return asyncio.run(_run()) + + def test_create_success(self) -> None: + response = self._request( + "POST", + "/api/bookmarks", + {"path": "storage1/my/path", "label": "My Path"}, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["path"], "storage1/my/path") + self.assertEqual(body["label"], "My Path") + self.assertIn("id", body) + self.assertIn("created_at", body) + + def test_list_shape(self) -> None: + self._request( + "POST", + "/api/bookmarks", + {"path": "storage1/a", "label": "A"}, + ) + + response = self._request("GET", "/api/bookmarks") + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()["items"]), 1) + item = response.json()["items"][0] + self.assertEqual(set(item.keys()), {"id", "path", "label", "created_at"}) + + def test_delete_success(self) -> None: + created = self._request( + "POST", + "/api/bookmarks", + {"path": "storage1/a", "label": "A"}, + ).json() + + response = self._request("DELETE", f"/api/bookmarks/{created['id']}") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"id": created["id"]}) + + def test_invalid_path(self) -> None: + response = self._request( + "POST", + "/api/bookmarks", + {"path": "unknown/path", "label": "A"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_invalid_label(self) -> None: + response = self._request( + "POST", + "/api/bookmarks", + {"path": "storage1/a", "label": " "}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_request", + "message": "Label is required", + "details": {"label": " "}, + } + }, + ) + + def test_duplicate_conflict(self) -> None: + self._request( + "POST", + "/api/bookmarks", + {"path": "storage1/a", "label": "A"}, + ) + + response = self._request( + "POST", + "/api/bookmarks", + {"path": "storage1/a", "label": "Again"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "already_exists", + "message": "Bookmark already exists for path", + "details": {"path": "storage1/a"}, + } + }, + ) + + def test_traversal_attempt(self) -> None: + response = self._request( + "POST", + "/api/bookmarks", + {"path": "storage1/../etc", "label": "Bad"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_browse_golden.py b/webui/backend/tests/golden/test_api_browse_golden.py new file mode 100644 index 0000000..b06886f --- /dev/null +++ b/webui/backend/tests/golden/test_api_browse_golden.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_browse_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.browse_service import BrowseService + + +class BrowseApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + + folder = self.root / "folder" + folder.mkdir() + file_path = self.root / "video.mkv" + file_path.write_bytes(b"abc") + + hidden_dir = self.root / ".hidden_dir" + hidden_dir.mkdir() + hidden_file = self.root / ".secret" + hidden_file.write_bytes(b"x") + + mtime = 1710000000 + for path in [folder, file_path, hidden_dir, hidden_file]: + Path(path).touch() + Path(path).chmod(0o755) + import os + os.utime(path, (mtime, mtime)) + + service = BrowseService( + path_guard=PathGuard({"storage1": str(self.root)}), + filesystem=FilesystemAdapter(), + ) + async def _override_browse_service() -> BrowseService: + return service + + app.dependency_overrides[get_browse_service] = _override_browse_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _get(self, path: str, show_hidden: str | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + params = {"path": path} + if show_hidden is not None: + params["show_hidden"] = show_hidden + return await client.get("/api/browse", params=params) + + return asyncio.run(_run()) + + def test_browse_success_default_hides_hidden_entries(self) -> None: + response = self._get("storage1") + + self.assertEqual(response.status_code, 200) + modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z") + expected = { + "path": "storage1", + "directories": [ + { + "name": "folder", + "path": "storage1/folder", + "modified": modified, + } + ], + "files": [ + { + "name": "video.mkv", + "path": "storage1/video.mkv", + "size": 3, + "modified": modified, + } + ], + } + self.assertEqual(response.json(), expected) + + def test_browse_success_show_hidden_true(self) -> None: + response = self._get("storage1", show_hidden="true") + + self.assertEqual(response.status_code, 200) + body = response.json() + directory_names = [item["name"] for item in body["directories"]] + file_names = [item["name"] for item in body["files"]] + self.assertEqual(directory_names, [".hidden_dir", "folder"]) + self.assertEqual(file_names, [".secret", "video.mkv"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_copy_golden.py b/webui/backend/tests/golden/test_api_copy_golden.py new file mode 100644 index 0000000..05116ad --- /dev/null +++ b/webui/backend/tests/golden/test_api_copy_golden.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import time +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_copy_task_service, get_task_service +from backend.app.db.task_repository import TaskRepository +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.copy_task_service import CopyTaskService +from backend.app.services.task_service import TaskService +from backend.app.tasks_runner import TaskRunner +from backend.app.fs.filesystem_adapter import FilesystemAdapter + + +class FailingFilesystemAdapter(FilesystemAdapter): + def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: + raise OSError("forced copy failure") + + +class CopyApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + self.repo = TaskRepository(str(Path(self.temp_dir.name) / "tasks.db")) + + path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) + self._set_services(path_guard=path_guard, filesystem=FilesystemAdapter()) + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _set_services(self, path_guard: PathGuard, filesystem: FilesystemAdapter) -> None: + runner = TaskRunner(repository=self.repo, filesystem=filesystem) + copy_service = CopyTaskService(path_guard=path_guard, repository=self.repo, runner=runner) + task_service = TaskService(repository=self.repo) + + async def _override_copy_service() -> CopyTaskService: + return copy_service + + async def _override_task_service() -> TaskService: + return task_service + + app.dependency_overrides[get_copy_task_service] = _override_copy_service + app.dependency_overrides[get_task_service] = _override_task_service + + def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + if method == "POST": + return await client.post(url, json=payload) + return await client.get(url) + + return asyncio.run(_run()) + + def _wait_task(self, task_id: str, timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request("GET", f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in {"completed", "failed"}: + return body + time.sleep(0.02) + self.fail("task did not reach terminal state in time") + + def test_copy_success_create_task_shape(self) -> None: + src = self.root / "source.txt" + src.write_text("hello", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/source.txt", "destination": "storage1/copy.txt"}, + ) + + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertIn("task_id", body) + self.assertEqual(body["status"], "queued") + + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["total_bytes"], 5) + self.assertEqual(detail["done_bytes"], 5) + self.assertTrue((self.root / "copy.txt").exists()) + self.assertEqual((self.root / "copy.txt").read_text(encoding="utf-8"), "hello") + + def test_copy_source_not_found(self) -> None: + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/missing.txt", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_not_found", + "message": "Requested path was not found", + "details": {"path": "storage1/missing.txt"}, + } + }, + ) + + def test_copy_source_is_directory_type_conflict(self) -> None: + (self.root / "dir").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/dir", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_copy_destination_exists_already_exists(self) -> None: + (self.root / "source.txt").write_text("x", encoding="utf-8") + (self.root / "exists.txt").write_text("y", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/source.txt", "destination": "storage1/exists.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "already_exists", + "message": "Target path already exists", + "details": {"path": "storage1/exists.txt"}, + } + }, + ) + + def test_copy_traversal_source(self) -> None: + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/../etc/passwd", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_copy_traversal_destination(self) -> None: + (self.root / "source.txt").write_text("x", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/source.txt", "destination": "storage1/../etc/out.txt"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_copy_source_symlink_rejected(self) -> None: + target = self.root / "real.txt" + target.write_text("x", encoding="utf-8") + link = self.root / "link.txt" + link.symlink_to(target) + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/link.txt", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_copy_runtime_io_error_failed_task_shape(self) -> None: + src = self.root / "source.txt" + src.write_text("hello", encoding="utf-8") + + path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) + self._set_services(path_guard=path_guard, filesystem=FailingFilesystemAdapter()) + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/source.txt", "destination": "storage1/copy.txt"}, + ) + self.assertEqual(response.status_code, 202) + + task_id = response.json()["task_id"] + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "failed") + self.assertEqual(detail["error_code"], "io_error") + self.assertEqual(detail["failed_item"], str(src)) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_errors_golden.py b/webui/backend/tests/golden/test_api_errors_golden.py new file mode 100644 index 0000000..2927199 --- /dev/null +++ b/webui/backend/tests/golden/test_api_errors_golden.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_browse_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.browse_service import BrowseService + + +class BrowseApiErrorsGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + (self.root / "a.txt").write_text("a", encoding="utf-8") + + service = BrowseService( + path_guard=PathGuard({"storage1": str(self.root)}), + filesystem=FilesystemAdapter(), + ) + async def _override_browse_service() -> BrowseService: + return service + + app.dependency_overrides[get_browse_service] = _override_browse_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _get(self, path: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get("/api/browse", params={"path": path}) + + return asyncio.run(_run()) + + def test_invalid_root_alias_error_shape(self) -> None: + response = self._get("unknown/path") + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_root_alias", + "message": "Unknown root alias", + "details": {"path": "unknown/path"}, + } + }, + ) + + def test_traversal_error_shape(self) -> None: + response = self._get("storage1/../etc") + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_traversal_detected", + "message": "Path traversal is not allowed", + "details": {"path": "storage1/../etc"}, + } + }, + ) + + def test_not_found_error_shape(self) -> None: + response = self._get("storage1/missing") + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_not_found", + "message": "Requested path was not found", + "details": {"path": "storage1/missing"}, + } + }, + ) + + def test_type_conflict_error_shape(self) -> None: + response = self._get("storage1/a.txt") + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_type_conflict", + "message": "Requested path is not a directory", + "details": {"path": "storage1/a.txt"}, + } + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_file_ops_golden.py b/webui/backend/tests/golden/test_api_file_ops_golden.py new file mode 100644 index 0000000..6732a5d --- /dev/null +++ b/webui/backend/tests/golden/test_api_file_ops_golden.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService + + +class FileOpsApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + + self.scope = self.root / "scope" + self.scope.mkdir(parents=True, exist_ok=True) + (self.scope / "old.txt").write_text("x", encoding="utf-8") + (self.scope / "existing.txt").write_text("y", encoding="utf-8") + + service = FileOpsService( + path_guard=PathGuard({"storage1": str(self.root)}), + filesystem=FilesystemAdapter(), + ) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _post(self, url: str, payload: dict[str, str]) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.post(url, json=payload) + + return asyncio.run(_run()) + + def test_mkdir_success(self) -> None: + response = self._post( + "/api/files/mkdir", + {"parent_path": "storage1/scope", "name": "new_folder"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"path": "storage1/scope/new_folder"}) + self.assertTrue((self.scope / "new_folder").is_dir()) + + def test_mkdir_conflict_directory_exists(self) -> None: + (self.scope / "existing_dir").mkdir() + response = self._post( + "/api/files/mkdir", + {"parent_path": "storage1/scope", "name": "existing_dir"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "already_exists", + "message": "Target path already exists", + "details": {"path": "storage1/scope/existing_dir"}, + } + }, + ) + + def test_mkdir_conflict_file_exists(self) -> None: + response = self._post( + "/api/files/mkdir", + {"parent_path": "storage1/scope", "name": "existing.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "already_exists", + "message": "Target path already exists", + "details": {"path": "storage1/scope/existing.txt"}, + } + }, + ) + + def test_rename_success(self) -> None: + response = self._post( + "/api/files/rename", + {"path": "storage1/scope/old.txt", "new_name": "renamed.txt"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"path": "storage1/scope/renamed.txt"}) + self.assertFalse((self.scope / "old.txt").exists()) + self.assertTrue((self.scope / "renamed.txt").exists()) + + def test_rename_conflict(self) -> None: + response = self._post( + "/api/files/rename", + {"path": "storage1/scope/old.txt", "new_name": "existing.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "already_exists", + "message": "Target path already exists", + "details": {"path": "storage1/scope/existing.txt"}, + } + }, + ) + + def test_rename_not_found(self) -> None: + response = self._post( + "/api/files/rename", + {"path": "storage1/scope/missing.txt", "new_name": "renamed.txt"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_not_found", + "message": "Requested path was not found", + "details": {"path": "storage1/scope/missing.txt"}, + } + }, + ) + + def test_rename_invalid_new_name_dotdot(self) -> None: + response = self._post( + "/api/files/rename", + {"path": "storage1/scope/old.txt", "new_name": ".."}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_request", + "message": "Invalid name", + "details": {"new_name": ".."}, + } + }, + ) + + def test_rename_invalid_new_name_with_slash(self) -> None: + response = self._post( + "/api/files/rename", + {"path": "storage1/scope/old.txt", "new_name": "a/b"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_request", + "message": "Invalid name", + "details": {"new_name": "a/b"}, + } + }, + ) + + def test_mkdir_invalid_path(self) -> None: + response = self._post( + "/api/files/mkdir", + {"parent_path": "storage1/scope", "name": "bad/name"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_request", + "message": "Invalid name", + "details": {"name": "bad/name"}, + } + }, + ) + + def test_mkdir_traversal_attempt(self) -> None: + response = self._post( + "/api/files/mkdir", + {"parent_path": "storage1/../etc", "name": "x"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_traversal_detected", + "message": "Path traversal is not allowed", + "details": {"path": "storage1/../etc"}, + } + }, + ) + + def test_delete_file_success(self) -> None: + target = self.scope / "delete_me.txt" + target.write_text("z", encoding="utf-8") + + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/delete_me.txt"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"path": "storage1/scope/delete_me.txt"}) + self.assertFalse(target.exists()) + + def test_delete_empty_directory_success(self) -> None: + target = self.scope / "empty_dir" + target.mkdir() + + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/empty_dir"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"path": "storage1/scope/empty_dir"}) + self.assertFalse(target.exists()) + + def test_delete_not_found(self) -> None: + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/missing.txt"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_not_found", + "message": "Requested path was not found", + "details": {"path": "storage1/scope/missing.txt"}, + } + }, + ) + + def test_delete_traversal_attempt(self) -> None: + response = self._post( + "/api/files/delete", + {"path": "storage1/../etc/passwd"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_traversal_detected", + "message": "Path traversal is not allowed", + "details": {"path": "storage1/../etc/passwd"}, + } + }, + ) + + def test_delete_non_empty_directory_conflict(self) -> None: + target = self.scope / "non_empty" + target.mkdir() + (target / "a.txt").write_text("a", encoding="utf-8") + + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/non_empty"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "directory_not_empty", + "message": "Directory is not empty", + "details": {"path": "storage1/scope/non_empty"}, + } + }, + ) + + def test_delete_invalid_path(self) -> None: + response = self._post( + "/api/files/delete", + {"path": ""}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_request", + "message": "Query parameter 'path' is required", + "details": None, + } + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_move_golden.py b/webui/backend/tests/golden/test_api_move_golden.py new file mode 100644 index 0000000..4d211fd --- /dev/null +++ b/webui/backend/tests/golden/test_api_move_golden.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import time +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_move_task_service, get_task_service +from backend.app.db.task_repository import TaskRepository +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.move_task_service import MoveTaskService +from backend.app.services.task_service import TaskService +from backend.app.tasks_runner import TaskRunner + + +class FailingDeleteFilesystemAdapter(FilesystemAdapter): + def delete_file(self, path: Path) -> None: + raise OSError("forced delete failure") + + +class MoveApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root1 = Path(self.temp_dir.name) / "root1" + self.root2 = Path(self.temp_dir.name) / "root2" + self.root1.mkdir(parents=True, exist_ok=True) + self.root2.mkdir(parents=True, exist_ok=True) + + self.repo = TaskRepository(str(Path(self.temp_dir.name) / "tasks.db")) + path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)}) + self._set_services(path_guard=path_guard, filesystem=FilesystemAdapter()) + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _set_services(self, path_guard: PathGuard, filesystem: FilesystemAdapter) -> None: + runner = TaskRunner(repository=self.repo, filesystem=filesystem) + move_service = MoveTaskService(path_guard=path_guard, repository=self.repo, runner=runner) + task_service = TaskService(repository=self.repo) + + async def _override_move_service() -> MoveTaskService: + return move_service + + async def _override_task_service() -> TaskService: + return task_service + + app.dependency_overrides[get_move_task_service] = _override_move_service + app.dependency_overrides[get_task_service] = _override_task_service + + def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + if method == "POST": + return await client.post(url, json=payload) + return await client.get(url) + + return asyncio.run(_run()) + + def _wait_task(self, task_id: str, timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request("GET", f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in {"completed", "failed"}: + return body + time.sleep(0.02) + self.fail("task did not reach terminal state in time") + + def test_move_success_same_root_create_task_shape_and_completed(self) -> None: + src = self.root1 / "source.txt" + src.write_text("hello", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source.txt", "destination": "storage1/moved.txt"}, + ) + + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertIn("task_id", body) + self.assertEqual(body["status"], "queued") + + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertTrue((self.root1 / "moved.txt").exists()) + self.assertFalse(src.exists()) + + def test_move_success_cross_root_create_task_shape_and_completed(self) -> None: + src = self.root1 / "source.txt" + src.write_text("hello", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source.txt", "destination": "storage2/moved.txt"}, + ) + + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertIn("task_id", body) + self.assertEqual(body["status"], "queued") + + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertTrue((self.root2 / "moved.txt").exists()) + self.assertFalse(src.exists()) + + def test_move_source_not_found(self) -> None: + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/missing.txt", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_move_source_is_directory_type_conflict(self) -> None: + (self.root1 / "dir").mkdir() + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/dir", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_move_destination_exists_already_exists(self) -> None: + (self.root1 / "source.txt").write_text("x", encoding="utf-8") + (self.root1 / "exists.txt").write_text("y", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source.txt", "destination": "storage1/exists.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + + def test_move_traversal_source(self) -> None: + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/../etc/passwd", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_move_traversal_destination(self) -> None: + (self.root1 / "source.txt").write_text("x", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source.txt", "destination": "storage1/../etc/out.txt"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_move_source_symlink_rejected(self) -> None: + target = self.root1 / "real.txt" + target.write_text("x", encoding="utf-8") + link = self.root1 / "link.txt" + link.symlink_to(target) + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/link.txt", "destination": "storage1/out.txt"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_move_runtime_io_error_failed_task_shape(self) -> None: + src = self.root1 / "source.txt" + src.write_text("hello", encoding="utf-8") + + path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)}) + self._set_services(path_guard=path_guard, filesystem=FailingDeleteFilesystemAdapter()) + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source.txt", "destination": "storage2/moved.txt"}, + ) + self.assertEqual(response.status_code, 202) + + task_id = response.json()["task_id"] + detail = self._wait_task(task_id) + + self.assertEqual(detail["status"], "failed") + self.assertEqual(detail["error_code"], "io_error") + self.assertTrue((self.root2 / "moved.txt").exists()) + self.assertTrue(src.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py new file mode 100644 index 0000000..a2cbd1b --- /dev/null +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_task_service +from backend.app.db.task_repository import TaskRepository +from backend.app.main import app +from backend.app.services.task_service import TaskService + + +class TasksApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.db_path = str(Path(self.temp_dir.name) / "tasks.db") + self.repo = TaskRepository(self.db_path) + self.service = TaskService(self.repo) + + async def _override_task_service() -> TaskService: + return self.service + + app.dependency_overrides[get_task_service] = _override_task_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _get(self, url: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get(url) + + return asyncio.run(_run()) + + def _insert_task( + self, + *, + task_id: str, + operation: str, + status: str, + source: str, + destination: str, + created_at: str, + started_at: str | None = None, + finished_at: str | None = None, + done_bytes: int | None = None, + total_bytes: int | None = None, + done_items: int | None = None, + total_items: int | None = None, + current_item: str | None = None, + failed_item: str | None = None, + error_code: str | None = None, + error_message: str | None = None, + ) -> None: + self.repo.insert_task_for_testing( + { + "id": task_id, + "operation": operation, + "status": status, + "source": source, + "destination": destination, + "done_bytes": done_bytes, + "total_bytes": total_bytes, + "done_items": done_items, + "total_items": total_items, + "current_item": current_item, + "failed_item": failed_item, + "error_code": error_code, + "error_message": error_message, + "created_at": created_at, + "started_at": started_at, + "finished_at": finished_at, + } + ) + + def test_get_tasks_empty_list(self) -> None: + response = self._get("/api/tasks") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"items": []}) + + def test_get_tasks_list_shape(self) -> None: + self._insert_task( + task_id="task-old", + operation="copy", + status="completed", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + finished_at="2026-03-10T10:00:05Z", + ) + self._insert_task( + task_id="task-new", + operation="move", + status="running", + source="storage1/b.txt", + destination="storage2/b.txt", + created_at="2026-03-10T10:01:00Z", + ) + + response = self._get("/api/tasks") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "items": [ + { + "id": "task-new", + "operation": "move", + "status": "running", + "source": "storage1/b.txt", + "destination": "storage2/b.txt", + "created_at": "2026-03-10T10:01:00Z", + "finished_at": None, + }, + { + "id": "task-old", + "operation": "copy", + "status": "completed", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "created_at": "2026-03-10T10:00:00Z", + "finished_at": "2026-03-10T10:00:05Z", + }, + ] + }, + ) + + def test_get_task_detail_queued(self) -> None: + self._insert_task( + task_id="task-queued", + operation="copy", + status="queued", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + ) + + response = self._get("/api/tasks/task-queued") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "id": "task-queued", + "operation": "copy", + "status": "queued", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "done_bytes": None, + "total_bytes": None, + "done_items": None, + "total_items": None, + "current_item": None, + "failed_item": None, + "error_code": None, + "error_message": None, + "created_at": "2026-03-10T10:00:00Z", + "started_at": None, + "finished_at": None, + }, + ) + + def test_get_task_detail_running(self) -> None: + self._insert_task( + task_id="task-running", + operation="move", + status="running", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + done_bytes=1024, + total_bytes=2048, + done_items=1, + total_items=2, + current_item="storage1/a.txt", + ) + + response = self._get("/api/tasks/task-running") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["status"], "running") + self.assertEqual(body["done_bytes"], 1024) + self.assertEqual(body["total_bytes"], 2048) + self.assertEqual(body["current_item"], "storage1/a.txt") + + def test_get_task_detail_completed(self) -> None: + self._insert_task( + task_id="task-completed", + operation="copy", + status="completed", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + finished_at="2026-03-10T10:00:03Z", + done_bytes=2048, + total_bytes=2048, + ) + + response = self._get("/api/tasks/task-completed") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["status"], "completed") + self.assertEqual(body["finished_at"], "2026-03-10T10:00:03Z") + self.assertEqual(body["error_code"], None) + + def test_get_task_detail_failed(self) -> None: + self._insert_task( + task_id="task-failed", + operation="move", + status="failed", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + finished_at="2026-03-10T10:00:02Z", + failed_item="storage1/a.txt", + error_code="io_error", + error_message="write failed", + ) + + response = self._get("/api/tasks/task-failed") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["status"], "failed") + self.assertEqual(body["failed_item"], "storage1/a.txt") + self.assertEqual(body["error_code"], "io_error") + self.assertEqual(body["error_message"], "write failed") + + def test_get_task_not_found(self) -> None: + response = self._get("/api/tasks/task-missing") + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json(), + { + "error": { + "code": "task_not_found", + "message": "Task was not found", + "details": {"task_id": "task-missing"}, + } + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py new file mode 100644 index 0000000..b91f29d --- /dev/null +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +from fastapi.staticfiles import StaticFiles +from starlette.routing import Mount + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.main import app + + +class UiSmokeGoldenTest(unittest.TestCase): + def _ui_mount(self) -> Mount: + for route in app.routes: + if isinstance(route, Mount) and route.path == "/ui": + return route + self.fail("Expected /ui mount to be registered") + + def test_ui_mount_and_index_contains_expected_panels(self) -> None: + mount = self._ui_mount() + self.assertIsInstance(mount.app, StaticFiles) + index_path = Path(mount.app.directory) / "index.html" + self.assertTrue(index_path.exists()) + + body = index_path.read_text(encoding="utf-8") + self.assertIn('id="workspace"', body) + self.assertIn('id="footer-bar"', body) + self.assertIn('id="left-pane"', body) + self.assertIn('id="right-pane"', body) + self.assertNotIn('id="bookmarks-panel"', body) + self.assertNotIn('id="tasks-panel"', body) + + def test_ui_static_assets_are_present_and_mapped(self) -> None: + mount = self._ui_mount() + static_root = Path(mount.app.directory) + self.assertTrue((static_root / "app.js").exists()) + self.assertTrue((static_root / "style.css").exists()) + + app_js_url = app.url_path_for("ui", path="/app.js") + style_css_url = app.url_path_for("ui", path="/style.css") + self.assertEqual(app_js_url, "/ui/app.js") + self.assertEqual(style_css_url, "/ui/style.css") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/unit/__init__.py b/webui/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webui/backend/tests/unit/__pycache__/__init__.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..510d76c54aaec25321f4c199fdf54330bc21c44f GIT binary patch literal 159 zcmey&%ge<81P#s`GC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iIetCXTc5y*s za;koLYEo`uUSfJ`k#25T0f<|gsh^aXoSmANqF<6)TvDuGnwMFkA0M9yq~hcC3My}L e*yQG?l;)(`6|n-116fxLVtiy~WMnL22C@KA9w&GJ literal 0 HcmV?d00001 diff --git a/webui/backend/tests/unit/__pycache__/test_bookmark_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_bookmark_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4ed49c19c8a68dbf2d475e9efa6549a1dc26878 GIT binary patch literal 2956 zcmbVO-A@!(6u=UWz_7aucUZ%3! zD^!7A>hqoPQ-72UOYv|~3Ahv?0^9xHvNXUb?0)w0_)t4A~#6l z0J5zu9^5*!kw8so0qOO07Q6+E>vqOSOzgiD94W+;wI)YmDpwtIA~R~GOkObnT$&nm z>bOPC1V-9h)sq&bJ}}KC=1_``aoIK|OfFMM$~UVX1?SDktmm(e0@>ijUl?Q%cKfSpi{~G!wlt0{?JKVc;@Yr2DcW_|o zjlreL52xj?1FInVVumqKZie3i<_3X$;Fc{dju_q2Hh~=A+aD8Y2zGFPB4rwOHbcu{ zX@7rC5)8p4&7F=D2;(lcgQ zc1)pIUU^x9nucSpitHHb)$*Gaqs@V&-BDeS5s-N4n zAkXi)r*=Xidugp|&Y9nRU+r*<8q`m`v~CfFV#Fzr-WxEex=hB!u{yr1QZ`#k5!_Jqgikt zT%>TT2;GM}BsRq;UP7+oV~9!Rek>*_Ys7tQX#+0$EE}s7MdPRy-)uOlpPDS4x@e*Z zHEoCS#=-;JYGg9-#$vNf3lbr11$UeLzYckTUpxL?VxgbWOdgUMss=_N_3}KI6=u`tC};x+PcLvQ*uc zukOlKcij3MYp$+!jlke(E0KfKmzJcOWjT25?B{2{ N_~5BTh}xmqgh!>R}%1 z$ur)13QT|}Kk6OvF<+8QdD8WcsoFDrq$WF(M5Y2AWXcD>U_Z=-CEa2__t*WEMmoM< zYs-@01;H8J@3FPkO1-1uk$P5d$-dFZNCRs~k~rxglE0Nm;*^l?c-vk-Z|hj&R8$IF z{E`r*kr3#6>g~QA#93LA%z9=$so*Q@l;X@fC7&>M0;zbY(qN1qijxPmo;o z{Um_NE)ygzICAZou7Inisa>ALb=MIB*6>EH0yZp|I&J%z4j*h|OY*=Uf5DR^KWPO* zWd-|p;RfdSub>Kb$vYW!*6O|&2e_v>M}qxV_dH8dzA;l^g@Qhw@|gaj!f0OCOp)GJ zHC-;uo8E$clQL7(^a4|6>6uiWDe82gIHTsM**Fdo=-JXWmC+1zS+d{CS(P!o9aG3F z3zUUW^e)V!Is!7o{hPd|Gt)bZ;Cu=Y)@W`n-I~-4f*@pk1~s_7s;)uSWHWq8U8^%N|RKtI+O2^&9d$ zo-D`D{xg1dE8f2>+zUT%imx?2ZhO>LPIMZH&aI}d^+lrzUj=r=(EY$l;C^H!@ng!m0($n8)E!P(}u8#QvdVVu_wXr z2RDSj2L2NG)7WPFrLEY&vhe%hF0AJuV-WWiW6p!{0I-|JE{94QdK@nM90$0Bv6P1) zP)vU&N0of3$k5Z-hs|2?1zf7at`%=9EYT&8hz;IjPm2g>XGsL!MVmoUC z^#P(I!K`HuSVVVUwoS)8H^IGs12Ec+=v4~AT$Lhu9FXTdFe>2aYtIRf8{s9s=GWgj z4l#VFzEhWV3B8 z{`lCVW9zNkVjsZO(UlV)8;OrMyDo2wSKd;u5C3`W>DX4+z%%jk+mNotuG0>r(N!HP zlhruo0MSDL;2#d)s+YEs3P^`)U~mW64bBbF>HiO`?zUV2yq_?$KU`ea@IOi7Aez|oSdaP;FV@yeG4{Hq8YLQn^shf zY7Ecc26)H;8;)luL2m}=?F=nqHe?E!LYA6!8YD+0O~wZJJ`2cvM!&7$1v$_?T7m8y zOsmzeIx+>6T`wM>os6jUVUODKG_(2CZ2y2fU(cQr~acs9z(wS z=rce6cYpA+nE!zDts?b%ILCLzjGyLwB@gB?;AA~%7dJe?NxURruH=r{$!h+wk0m_x zbwO;9QvJbH56bsv)N)&p`>eSme#y4I}K|}h3t&W3J`d}W>8yN zz+p1vYiB@FqZUIbTa037QR>4A!%|AHDOBgo0kOsOSM0D)p@$|w1wo+_6%JC%nXt0A z_fhL$*G{zjN2q{W&8-jTR_C6aH<~+_>vqIwSxgvWVp}}A)7bnlwVGO+F&a-S`**}h zS!^}L*0oDNT-!wH>dSYrvfBuCuNSsMSKd#`p>1*aLr96I`?sc#2EF`0yurb!A5IM; zvVH#e(#pXg7c+f~>LmuwYOIi_@ZluNGUmy$YzAd{p^z=*P!7p52J6b3h%C>jOw-}& zXucrJ>?m4HV%3G!IjpWiWyWL~-JhC~6exmY)(Us98~)VX9(P=-f55^flS@ z_`H|*8aQuq&nJ4X>@|747v9tdy%+3pI0P5Eps0CSPQ`GkGbPvtER|tO0Qvlq#*i_l zh#5J0SNx>VZ|2nL%GUt4OrM&^OoSn2&?0_IfQ=M18GiVf&6V`NM=2KJr}j|%nzdtq z*}ng!Ml&U*>PtNq1FXc~{u2Og7X zI#jQDj^kdCjvqZgMK5iWfq#?E7o>mB_jB&TH=mcAP8v-o_Xspzk8?x_E#KMV6FWla?iauP P;&;Dz%@d*3TGf96kt=R> literal 0 HcmV?d00001 diff --git a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd9cc973ba054227b8f570e4073c06d70397e532 GIT binary patch literal 3071 zcmb7GO>7&-6`uX!a!GM1$&yS;hNU%axFr*cvSd|}s|0l#1Bo50Dc1^$O2A@CF4eWE zU3z9|*E$4AfW|h^!ZiZ7KKPJan)sIFlw)$wAq?x1m^y%gpa`ra-n%Cb|q zBWmWGzc+8*eDA&8(zG~%^8CstPBlu%ci8C_H3Y(zE)a5y7{n0HkkbMcbj0Erk%~bp zQ7LF;Dnl!siJVrbqLVeTnCyw_x5eQ;?jeJ$#WG|~f&chCoQ0*B3ftZFe793f?;Z`Y zAx8l8cF&99XrO82jCMLnlR8P03^9}mVno;E;^dPu=B03q4y_Fv>WSYFV(lXY)*g9s zZHDx;(aE~FE@op7aZy&_1D5N0zU4cf%a)-pzhU{mdx)Z;lRRg!3q`x`F~|4lmGW-E zU7dxkB^bR$)(9000yY-FzN#Tkkn?C^)sO-d0VD@V0Tc;P)KI9p9?i!1zQ@lwXEXny z24c%y^9JXl#Xv7%>#N|v)nE{-Vpo-&0Ra6AuaMf#{3?(pEUU2|S|LTbhx=_YAX>kV zsP|lkkRRyxK)%vjw>sEEAT`m${kA6o_H^k=$U4c2%bThXmS)qH5?Dj^?TvcXso7u` zKpwT|l~){Um+=k4S+n9$E-{}{)Sb(&wPEvUrDWFO^(gAYWeV?h7ao+^wJJkH>il%! zvPUnlx>dFdm+jJq=%T?N$mtyZ|dST*nWfiQ1VcFeJis{R)pT z+h3{k^pj6F9n7FNv6rEGo7_+9?eyF?>A6;V{+j$p?S5+F&hZcAkF*c9wm#d`XIls6 zJ~`h!@QYUJB+Mu7k7hnN{?XEhORdqFYx19BTOj3eF5rs>TzCP%EdtiYC3{K{S-hw0 zaswdva#p0sGafD1Y|CxbDR>YGjYUUs0^Y)+uXvYTe&8t=?M;s0F5LPy>8Obt+I6k1 z9&D-y@2OK1Bg11nM&<>&r-MNC?7AC*AsUh)8xcb>qSH!`P5S?Fe{jAS$cP&QM&g-d zpx5mwJsY|L=)U7o{Y5Ln2zgUvVu3JmguDtXyo{ltpf|*`aQgwG!;C9}E z?0T%NW}9mE!G67S@Nj1$)0sHhnR>1>dHmm6R7-A4u~_`kD2Zt|o^9!VGCcOz*v(jb z=ty(uNNecmwa5cC(N@zqo2E%y&C_XC5xO8#>Aq$qtU#!n7K!3~D+KIlY2poW0W6>9qp>T-G6 zb8SqQritm#G=%kx8^ElTYM)T#Ux&HO5NO!J+6eWKrySLMEpE}TSlqP zyxJwm{dJ4luFv>nH-%4I^*Ve`Oe3>)W$y6XKs^WN{L+GO6I^H!h03TTeFen6zF>O1Py!FLA#EZm&C>EC|!{kPhw zd^44Ar3&rTLNm4SxzJ3VXpQ{*?_}v;WN}-W7LvEq?eSc5Jhx4t^Qa^cIdN^XC5?CF T#MQI!p8dnG9!W%=2vqtnEsL-~ literal 0 HcmV?d00001 diff --git a/webui/backend/tests/unit/test_bookmark_repository.py b/webui/backend/tests/unit/test_bookmark_repository.py new file mode 100644 index 0000000..7c28889 --- /dev/null +++ b/webui/backend/tests/unit/test_bookmark_repository.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.db.bookmark_repository import BookmarkRepository + + +class BookmarkRepositoryTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.repo = BookmarkRepository(str(Path(self.temp_dir.name) / "bookmarks.db")) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_duplicate_path_raises_integrity_error(self) -> None: + self.repo.create_bookmark(path="storage1/a", label="A") + with self.assertRaises(sqlite3.IntegrityError): + self.repo.create_bookmark(path="storage1/a", label="Again") + + def test_list_order_created_at_desc(self) -> None: + first = self.repo.create_bookmark(path="storage1/a", label="A") + second = self.repo.create_bookmark(path="storage1/b", label="B") + + items = self.repo.list_bookmarks() + + self.assertEqual(items[0]["id"], second["id"]) + self.assertEqual(items[1]["id"], first["id"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/unit/test_path_guard.py b/webui/backend/tests/unit/test_path_guard.py new file mode 100644 index 0000000..6dcdc8c --- /dev/null +++ b/webui/backend/tests/unit/test_path_guard.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.api.errors import AppError +from backend.app.security.path_guard import PathGuard + + +class PathGuardTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + self.other = Path(self.temp_dir.name) / "other" + self.other.mkdir(parents=True, exist_ok=True) + self.guard = PathGuard({"storage1": str(self.root)}) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_resolve_under_whitelisted_root(self) -> None: + target = self.root / "series" + target.mkdir() + + resolved = self.guard.resolve_directory_path("storage1/series") + + self.assertEqual(resolved.alias, "storage1") + self.assertEqual(resolved.relative, "storage1/series") + self.assertEqual(resolved.absolute, target.resolve()) + + def test_rejects_path_traversal(self) -> None: + with self.assertRaises(AppError) as ctx: + self.guard.resolve_path("storage1/../etc") + + self.assertEqual(ctx.exception.code, "path_traversal_detected") + self.assertEqual(ctx.exception.status_code, 403) + + def test_rejects_symlink_escape(self) -> None: + outside_dir = self.other / "escape" + outside_dir.mkdir() + symlink = self.root / "link" + symlink.symlink_to(outside_dir, target_is_directory=True) + + with self.assertRaises(AppError) as ctx: + self.guard.resolve_directory_path("storage1/link") + + self.assertEqual(ctx.exception.code, "path_outside_whitelist") + self.assertEqual(ctx.exception.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/unit/test_task_repository.py b/webui/backend/tests/unit/test_task_repository.py new file mode 100644 index 0000000..0eaf727 --- /dev/null +++ b/webui/backend/tests/unit/test_task_repository.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.db.task_repository import TaskRepository + + +class TaskRepositoryTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.db_path = str(Path(self.temp_dir.name) / "tasks.db") + self.repo = TaskRepository(self.db_path) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_list_tasks_sorted_created_at_desc(self) -> None: + self.repo.insert_task_for_testing( + { + "id": "task-old", + "operation": "copy", + "status": "queued", + "source": "storage1/a", + "destination": "storage2/a", + "created_at": "2026-03-10T09:00:00Z", + } + ) + self.repo.insert_task_for_testing( + { + "id": "task-new", + "operation": "move", + "status": "queued", + "source": "storage1/b", + "destination": "storage2/b", + "created_at": "2026-03-10T10:00:00Z", + } + ) + + tasks = self.repo.list_tasks() + + self.assertEqual([task["id"] for task in tasks], ["task-new", "task-old"]) + + def test_insert_rejects_invalid_status(self) -> None: + with self.assertRaises(ValueError): + self.repo.insert_task_for_testing( + { + "id": "task-x", + "operation": "copy", + "status": "unknown", + "source": "storage1/a", + "destination": "storage2/a", + "created_at": "2026-03-10T09:00:00Z", + } + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js new file mode 100644 index 0000000..273df3b --- /dev/null +++ b/webui/html/app.js @@ -0,0 +1,468 @@ +let state = { + panes: { + left: { + currentPath: "storage1", + showHidden: false, + selectedItem: null, + selectedItems: [], + }, + right: { + currentPath: "storage1", + showHidden: false, + selectedItem: null, + selectedItems: [], + }, + }, + activePane: "left", + selectedTaskId: null, +}; + +function paneState(pane) { + return state.panes[pane]; +} + +function otherPane(pane) { + return pane === "left" ? "right" : "left"; +} + +function activePaneState() { + return paneState(state.activePane); +} + +function setStatus(msg) { + document.getElementById("status").textContent = msg; +} + +function setError(id, msg) { + document.getElementById(id).textContent = msg || ""; +} + +function setActionError(action, err) { + setError("actions-error", `${action}: ${err.message}`); +} + +async function apiRequest(method, url, body) { + const options = { method, headers: {} }; + if (body !== undefined) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + const response = await fetch(url, options); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const error = data.error || {}; + throw new Error(error.message || `HTTP ${response.status}`); + } + return data; +} + +function createButton(text, onClick) { + const button = document.createElement("button"); + button.textContent = text; + button.onclick = onClick; + return button; +} + +function setActivePane(pane) { + state.activePane = pane; + document.getElementById("active-pane-label").textContent = pane; + document.getElementById("left-pane").classList.toggle("active-pane", pane === "left"); + document.getElementById("right-pane").classList.toggle("active-pane", pane === "right"); + updateActionButtons(); +} + +function setSelectedItem(pane, item) { + const model = paneState(pane); + model.selectedItem = item; + model.selectedItems = item ? [item] : []; + updateActionButtons(); +} + +function selectedPaths(pane) { + return paneState(pane).selectedItems.map((item) => item.path); +} + +function setSingleSelection(pane, item) { + setSelectedItem(pane, item); +} + +function toggleSelection(pane, item) { + const model = paneState(pane); + const index = model.selectedItems.findIndex((selected) => selected.path === item.path); + if (index >= 0) { + const removed = model.selectedItems[index]; + model.selectedItems.splice(index, 1); + if (model.selectedItem && model.selectedItem.path === removed.path) { + model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; + } + } else { + model.selectedItems.push(item); + model.selectedItem = item; + } + updateActionButtons(); +} + +function updateActionButtons() { + const selected = activePaneState().selectedItem; + const hasSelection = Boolean(selected); + const isFile = hasSelection && selected.kind === "file"; + document.getElementById("rename-btn").disabled = !hasSelection; + document.getElementById("delete-btn").disabled = !hasSelection; + document.getElementById("copy-btn").disabled = !isFile; + document.getElementById("move-btn").disabled = !isFile; +} + +function currentParentPath(path) { + if (!path.includes("/")) { + return null; + } + const segments = path.split("/"); + if (segments.length === 2) { + return segments[0]; + } + return segments.slice(0, -1).join("/"); +} + +function renderBreadcrumbs(pane, path) { + const nav = document.getElementById(`${pane}-breadcrumbs`); + nav.innerHTML = ""; + const parts = path.split("/"); + let aggregate = ""; + for (let i = 0; i < parts.length; i += 1) { + aggregate = i === 0 ? parts[i] : `${aggregate}/${parts[i]}`; + const crumb = createButton(parts[i], () => { + setActivePane(pane); + navigateTo(pane, aggregate); + }); + crumb.type = "button"; + crumb.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + setActivePane(pane); + navigateTo(pane, aggregate); + }; + nav.append(crumb); + if (i < parts.length - 1) { + const sep = document.createElement("span"); + sep.textContent = "/"; + nav.append(sep); + } + } +} + +function formatModified(isoString) { + if (!isoString) { + return "-"; + } + const d = new Date(isoString); + if (Number.isNaN(d.getTime())) { + return isoString; + } + const date = d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }); + const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return `${date} ${time}`; +} + +function createBrowseItem(pane, entry, kind) { + const li = document.createElement("li"); + li.className = "selectable"; + const paths = selectedPaths(pane); + if (paths.includes(entry.path)) { + li.classList.add("is-selected"); + } + + li.onclick = () => { + setActivePane(pane); + setSingleSelection(pane, { path: entry.path, name: entry.name, kind }); + loadBrowsePane(pane); + }; + + const marker = document.createElement("input"); + marker.type = "checkbox"; + marker.className = "select-marker"; + marker.checked = paths.includes(entry.path); + marker.onclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + toggleSelection(pane, { path: entry.path, name: entry.name, kind }); + loadBrowsePane(pane); + }; + li.append(marker); + + const name = document.createElement("span"); + name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`; + + if (kind === "directory") { + const open = document.createElement("button"); + open.className = "dir-link"; + open.textContent = `${entry.name}/`; + open.type = "button"; + open.onclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + navigateTo(pane, entry.path); + }; + name.append(open); + } else { + const fileName = document.createElement("span"); + fileName.textContent = entry.name; + fileName.onclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + setSingleSelection(pane, { path: entry.path, name: entry.name, kind }); + loadBrowsePane(pane); + }; + name.append(fileName); + } + li.append(name); + + const size = document.createElement("span"); + size.className = "entry-size"; + size.textContent = kind === "directory" ? "-" : String(entry.size); + li.append(size); + + const modified = document.createElement("span"); + modified.className = "entry-modified"; + modified.textContent = formatModified(entry.modified); + li.append(modified); + return li; +} + +async function loadBrowsePane(pane) { + setError(`${pane}-browse-error`, ""); + try { + const model = paneState(pane); + const query = new URLSearchParams({ + path: model.currentPath, + show_hidden: String(model.showHidden), + }); + const data = await apiRequest("GET", `/api/browse?${query.toString()}`); + model.currentPath = data.path; + document.getElementById(`${pane}-current-path`).textContent = data.path; + renderBreadcrumbs(pane, data.path); + + const items = document.getElementById(`${pane}-items`); + items.innerHTML = ""; + + const parent = currentParentPath(data.path); + if (parent) { + const up = document.createElement("li"); + up.className = "selectable"; + up.append(document.createElement("span")); + const upName = document.createElement("button"); + upName.type = "button"; + upName.className = "dir-link"; + upName.textContent = "../"; + upName.onclick = () => navigateTo(pane, parent); + const upNameCell = document.createElement("span"); + upNameCell.className = "entry-name entry-dir"; + upNameCell.append(upName); + up.append(upNameCell); + const upSize = document.createElement("span"); + upSize.className = "entry-size"; + upSize.textContent = "-"; + up.append(upSize); + const upModified = document.createElement("span"); + upModified.className = "entry-modified"; + upModified.textContent = "-"; + up.append(upModified); + items.append(up); + } + + const visiblePaths = new Set(); + for (const entry of data.directories) { + visiblePaths.add(entry.path); + items.append(createBrowseItem(pane, entry, "directory")); + } + for (const entry of data.files) { + visiblePaths.add(entry.path); + items.append(createBrowseItem(pane, entry, "file")); + } + + model.selectedItems = model.selectedItems.filter((item) => visiblePaths.has(item.path)); + if (model.selectedItem && !visiblePaths.has(model.selectedItem.path)) { + model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; + } + updateActionButtons(); + setStatus(`Loaded ${pane}: ${data.path}`); + } catch (err) { + setError(`${pane}-browse-error`, `Browse: ${err.message}`); + } +} + +function navigateTo(pane, path) { + paneState(pane).currentPath = path; + setSelectedItem(pane, null); + loadBrowsePane(pane); +} + +async function createFolderForPane(pane) { + setActivePane(pane); + const name = window.prompt("Folder name"); + if (!name) { + return; + } + setError(`${pane}-browse-error`, ""); + try { + await apiRequest("POST", "/api/files/mkdir", { + parent_path: paneState(pane).currentPath, + name, + }); + await loadBrowsePane(pane); + } catch (err) { + setError(`${pane}-browse-error`, `Create folder: ${err.message}`); + } +} + +async function createFolderForActivePane() { + await createFolderForPane(state.activePane); +} + +async function renameSelected() { + const pane = state.activePane; + const selected = paneState(pane).selectedItem; + if (!selected) { + return; + } + const newName = window.prompt("New name", selected.name); + if (!newName) { + return; + } + setError("actions-error", ""); + try { + await apiRequest("POST", "/api/files/rename", { + path: selected.path, + new_name: newName, + }); + setSelectedItem(pane, null); + await loadBrowsePane(pane); + } catch (err) { + setActionError("Rename", err); + } +} + +async function deleteSelected() { + const pane = state.activePane; + const selected = paneState(pane).selectedItem; + if (!selected) { + return; + } + if (!window.confirm(`Delete ${selected.path}?`)) { + return; + } + setError("actions-error", ""); + try { + await apiRequest("POST", "/api/files/delete", { path: selected.path }); + setSelectedItem(pane, null); + await loadBrowsePane(pane); + } catch (err) { + setActionError("Delete", err); + } +} + +function defaultDestination(sourcePath, targetBasePath) { + const sourceName = sourcePath.slice(sourcePath.lastIndexOf("/") + 1); + return `${targetBasePath}/${sourceName}`; +} + +async function startCopySelected() { + const sourcePane = state.activePane; + const destinationPane = otherPane(sourcePane); + const selected = paneState(sourcePane).selectedItem; + if (!selected || selected.kind !== "file") { + return; + } + const destination = window.prompt( + "Copy destination (full path)", + defaultDestination(selected.path, paneState(destinationPane).currentPath), + ); + if (!destination) { + return; + } + setError("actions-error", ""); + try { + const result = await apiRequest("POST", "/api/files/copy", { + source: selected.path, + destination, + }); + state.selectedTaskId = result.task_id; + setStatus(`Copy task queued: ${result.task_id}`); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); + } catch (err) { + setActionError("Copy", err); + } +} + +async function startMoveSelected() { + const sourcePane = state.activePane; + const destinationPane = otherPane(sourcePane); + const selected = paneState(sourcePane).selectedItem; + if (!selected || selected.kind !== "file") { + return; + } + const destination = window.prompt( + "Move destination (full path)", + defaultDestination(selected.path, paneState(destinationPane).currentPath), + ); + if (!destination) { + return; + } + setError("actions-error", ""); + try { + const result = await apiRequest("POST", "/api/files/move", { + source: selected.path, + destination, + }); + state.selectedTaskId = result.task_id; + setSelectedItem(sourcePane, null); + setStatus(`Move task queued: ${result.task_id}`); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); + } catch (err) { + setActionError("Move", err); + } +} + +async function addBookmark() { + const pane = state.activePane; + const path = paneState(pane).currentPath; + const label = window.prompt("Bookmark label", path); + if (!label) { + return; + } + setError("actions-error", ""); + try { + await apiRequest("POST", "/api/bookmarks", { path, label }); + setStatus(`Bookmark added for ${path}`); + } catch (err) { + setActionError("Add bookmark", err); + } +} + +function setupPaneEvents(pane) { + document.getElementById(`${pane}-pane`).onclick = () => setActivePane(pane); + document.getElementById(`${pane}-hidden-toggle`).onchange = (ev) => { + setActivePane(pane); + paneState(pane).showHidden = ev.target.checked; + loadBrowsePane(pane); + }; +} + +function setupEvents() { + setupPaneEvents("left"); + setupPaneEvents("right"); + document.getElementById("rename-btn").onclick = renameSelected; + document.getElementById("delete-btn").onclick = deleteSelected; + document.getElementById("copy-btn").onclick = startCopySelected; + document.getElementById("move-btn").onclick = startMoveSelected; + document.getElementById("mkdir-btn").onclick = createFolderForActivePane; + document.getElementById("add-bookmark-btn").onclick = addBookmark; +} + +async function init() { + setError("actions-error", ""); + setActivePane("left"); + setupEvents(); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); +} + +init(); diff --git a/webui/html/index.html b/webui/html/index.html new file mode 100644 index 0000000..01a2844 --- /dev/null +++ b/webui/html/index.html @@ -0,0 +1,78 @@ + + + + + + WebManager v2 + + + +
+
+

WebManager v2

+
+
+ +
+
+
+
+

Left

+ +
+
C:
+ +
+
+ +
+
+ + Name + Size + Modified +
+
    +
    +
    + +
    +
    +
    +

    Right

    + +
    +
    C:
    + +
    +
    + +
    +
    + + Name + Size + Modified +
    +
      +
      +
      +
      + + +
      + + + + diff --git a/webui/html/style.css b/webui/html/style.css new file mode 100644 index 0000000..9a289b2 --- /dev/null +++ b/webui/html/style.css @@ -0,0 +1,300 @@ +:root { + --bg: #f4f7fb; + --panel: #ffffff; + --border: #d7deea; + --text: #192232; + --muted: #5c687c; + --error: #b11d1d; + --accent: #1b5ec9; + --active-border: #1b5ec9; + --active-bg: #ffffff; + --bottom-reserve: 0px; +} + +* { box-sizing: border-box; } + +html, body { + height: 100%; +} + +body { + margin: 0; + font-family: "Segoe UI", Tahoma, sans-serif; + background: var(--bg); + color: var(--text); + overflow: hidden; +} + +#app-shell { + height: 100vh; + display: grid; + grid-template-rows: auto 1fr auto; +} + +#title-zone { + padding: 6px 10px; + border-bottom: 1px solid var(--border); + background: var(--panel); + display: flex; + align-items: center; + justify-content: space-between; +} + +h1, h2, h3 { + margin: 0; +} + +h1 { + font-size: 16px; + line-height: 1.2; +} + +.workspace { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + padding: 6px 10px; + min-height: 0; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; +} + +.pane { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.pane.active-pane { + border-color: var(--active-border); + box-shadow: 0 0 0 1px var(--active-border) inset; + background: var(--panel); +} + +.pane-header { + flex: 0 0 auto; + margin-bottom: 4px; +} + +.pane-content { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + border-top: 1px solid var(--border); + padding-top: 4px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin-bottom: 4px; +} + +.compact-toolbar { + margin-bottom: 2px; +} + +.pane-topbar { + justify-content: space-between; +} + +.pane-title { + min-width: 42px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.checkbox { + color: var(--muted); + font-size: 13px; +} + +input, button { + font: inherit; + padding: 4px 6px; + font-size: 13px; +} + +button { + border: 1px solid var(--border); + background: #f8fafc; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pathline { + color: var(--muted); + margin-bottom: 3px; + font-size: 12px; +} + +.compact-line { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 3px; + color: var(--muted); + font-size: 12px; +} + +.breadcrumbs button { + padding: 1px 4px; + font-size: 12px; +} + +.list-label { + font-size: 11px; + margin: 0; + padding: 2px 0; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.list { + list-style: none; + margin: 0; + padding: 0; +} + +.list-grid-header { + display: grid; + grid-template-columns: 14px minmax(0, 1fr) 88px 138px; + gap: 6px; + padding: 2px 0 4px 0; + border-bottom: 1px solid var(--border); + margin-bottom: 2px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.col-size, +.col-modified { + text-align: right; +} + +.list li { + border-top: 1px solid var(--border); + padding: 5px 0 4px 0; + display: grid; + grid-template-columns: 14px minmax(0, 1fr) 88px 138px; + gap: 6px; + align-items: center; +} + +.list li.selectable { + cursor: pointer; +} + +.list li.is-selected { + background: #e9f0fd; +} + +.select-marker { + appearance: none; + width: 10px; + min-width: 10px; + height: 10px; + border: 1px solid var(--border); + border-radius: 2px; + display: inline-block; + margin: 0; + cursor: pointer; +} + +.list li.is-selected .select-marker { + background: var(--accent); + border-color: var(--accent); +} + +.select-marker:checked { + background: var(--accent); + border-color: var(--accent); +} + +.entry-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.entry-name.entry-dir { + font-weight: 600; +} + +.dir-link { + border: 0; + background: transparent; + padding: 0; + color: var(--accent); + text-decoration: underline; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry-size, +.entry-modified { + font-size: 12px; + color: var(--muted); + text-align: right; + white-space: nowrap; +} + +.error { + color: var(--error); + min-height: 12px; + margin-bottom: 2px; + font-size: 12px; +} + +#status { + color: var(--muted); + font-size: 12px; +} + +#footer-bar { + border-top: 1px solid var(--border); + background: var(--panel); + padding: 4px 10px 2px 10px; +} + +@media (max-width: 1200px) { + .workspace { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } + + .list-grid-header, + .list li { + grid-template-columns: 14px minmax(0, 1fr) 70px 112px; + gap: 4px; + } +}