diff --git a/LICENSE b/LICENSE index 1e5f98b..7b77787 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,18 @@ -MIT License +LiteFetch Licensing +Copyright (c) 2025 JTechMinds LLC -Copyright (c) 2025 jordandevai +This repository uses a dual-license model: -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +1) Personal / Non-Commercial Use License + - See: LICENSE-PERSONAL.md + - Free for personal and non-commercial use. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +2) Commercial Use License + - See: LICENSE-COMMERCIAL.md + - Commercial use requires purchasing a commercial license from the Licensor. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Important: +- The license terms that apply to a version are the terms published with that version. +- Licensor may change licensing terms for future versions/releases. +- Prior versions released under MIT (or any other prior license) remain governed by + those prior terms for recipients of those versions. diff --git a/LICENSE-COMMERCIAL.md b/LICENSE-COMMERCIAL.md new file mode 100644 index 0000000..0d37eb0 --- /dev/null +++ b/LICENSE-COMMERCIAL.md @@ -0,0 +1,20 @@ +LiteFetch Commercial License Notice +Copyright (c) 2025 JTechMinds LLC + +Commercial use of this software is not permitted under the personal/non-commercial license. + +To use LiteFetch for commercial purposes, you must purchase and accept a separate commercial +license agreement from Licensor. + +Commercial use includes, for example: +- business/internal company use; +- paid client or agency work; +- SaaS, hosted, API, or managed offerings built on or including LiteFetch; +- bundling LiteFetch into paid products or services. + +Commercial licensing: +- Website: https://litefetch.com +- Contact: jordan@jtechminds.com + +Unless and until a commercial license is purchased and accepted, your rights are limited to +those in LICENSE-PERSONAL.md. diff --git a/LICENSE-PERSONAL.md b/LICENSE-PERSONAL.md new file mode 100644 index 0000000..c0e40ad --- /dev/null +++ b/LICENSE-PERSONAL.md @@ -0,0 +1,56 @@ +LiteFetch Personal and Non-Commercial License v1.0 +Copyright (c) 2025 JTechMinds LLC + +1. Grant +Subject to this license, Licensor grants you a non-exclusive, worldwide, royalty-free +license to use, copy, modify, and redistribute this software for Personal Use and other +Non-Commercial Use only. + +2. Personal Use / Non-Commercial Use +"Personal Use" means use by an individual for private, educational, hobby, or evaluation +purposes, and not for direct or indirect commercial advantage. + +"Non-Commercial Use" means use where no fee is charged for access to the software and no +revenue, commercial benefit, or business advantage is derived from the software. + +3. Commercial Use Not Granted +Any Commercial Use is prohibited under this license. + +"Commercial Use" includes, without limitation: +- using the software in a business, for paid client work, or in internal business operations; +- offering services (including SaaS/hosted/API offerings) that use this software; +- bundling, reselling, sublicensing, or distributing this software as part of a paid product; +- any use primarily intended for or directed toward commercial advantage or monetary compensation. + +4. Commercial License +To use this software commercially, you must obtain a separate commercial license from Licensor. +See LICENSE-COMMERCIAL.md. + +5. Redistribution Requirements +If you redistribute the software under this license, you must: +- include this license and all copyright notices; +- clearly indicate any material modifications you made; +- not remove or alter Licensor attribution and notices. + +6. Trademark +No rights are granted to use Licensor trademarks, trade names, or logos except as required to +identify the origin of the software. + +7. Termination +This license terminates automatically if you violate its terms. Upon termination, you must stop +using and redistributing the software under this license. + +8. No Warranty +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND +NONINFRINGEMENT. + +9. Limitation of Liability +IN NO EVENT SHALL LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +10. Future Versions / License Changes +Licensor may publish future versions of this software under different license terms (including +subscription or other commercial models). This license applies only to versions released with +this license. diff --git a/backend/app/api.py b/backend/app/api.py index 504e4eb..904e9d4 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -147,6 +147,15 @@ async def save_last_results(collection_id: str, payload: Dict[str, Any] = Body(. return {"status": "ok"} +@router.post("/collections/{collection_id}/last-results/{request_id}") +async def upsert_last_result(collection_id: str, request_id: str, result: RequestResult): + try: + storage.upsert_last_result(collection_id, request_id, result.model_dump()) + except VaultLockedError: + raise HTTPException(status_code=423, detail="workspace locked") + return {"status": "ok"} + + # --- Workspace --- @router.get("/workspace") async def get_workspace(): diff --git a/backend/app/core/engine.py b/backend/app/core/engine.py index b91faf9..68901d7 100644 --- a/backend/app/core/engine.py +++ b/backend/app/core/engine.py @@ -4,6 +4,7 @@ import time import uuid import random +import json from typing import Any, Dict, Tuple from app.models import HttpRequest, RequestResult, EnvironmentFile from app.core.storage import storage @@ -356,24 +357,36 @@ async def execute(self, collection_id: str, req: HttpRequest) -> RequestResult: duration = (time.perf_counter() - start_time) * 1000 - # 4. Parse Body + # 4. Parse Body (lazy) body_content = "" - is_json = False parsed_json = None - + content_type = resp_obj.headers.get("content-type") + content_type_l = (content_type or "").lower() + body_is_json = "application/json" in content_type_l or content_type_l.endswith("+json") or "+json;" in content_type_l + body_bytes = len(resp_obj.content or b"") + try: body_content = resp_obj.text - # Basic JSON detection - if "application/json" in resp_obj.headers.get("content-type", ""): - parsed_json = resp_obj.json() - is_json = True - except: - pass # Keep as text + except Exception: + try: + body_content = (resp_obj.content or b"").decode("utf-8", errors="replace") + except Exception: + body_content = "" # 5. Extraction Logic (Auto-Magic) vars_updated = False rule_errors = [] - if is_json and req.extract_rules: + if req.extract_rules: + if body_is_json: + try: + parsed_json = json.loads(body_content) if body_content else None + except Exception as ex: + parsed_json = None + rule_errors.append({"rule_id": "*", "error": f"Invalid JSON response body: {ex}"}) + else: + rule_errors.append({"rule_id": "*", "error": "Response body is not JSON"}) + + if parsed_json is not None and req.extract_rules: for rule in req.extract_rules: source_path = self._normalize_path(rule.source_path.strip()) try: @@ -395,7 +408,10 @@ async def execute(self, collection_id: str, req: HttpRequest) -> RequestResult: status_code=resp_obj.status_code, duration_ms=duration, headers=dict(resp_obj.headers), - body=parsed_json if is_json else body_content, + body=body_content, + body_is_json=body_is_json, + content_type=content_type, + body_bytes=body_bytes, ) if rule_errors: result.error = f"Extraction issues: {rule_errors}" diff --git a/backend/app/core/secret_codec.py b/backend/app/core/secret_codec.py new file mode 100644 index 0000000..f561b44 --- /dev/null +++ b/backend/app/core/secret_codec.py @@ -0,0 +1,52 @@ +import json +from typing import Any, Tuple + +from app.core.crypto import encrypt_value, decrypt_value, is_encrypted_string, CryptoError + + +def is_ciphertext(value: Any) -> bool: + return isinstance(value, str) and is_encrypted_string(value) + + +def encrypt_if_secret(value: Any, is_secret: bool, master_key: bytes | None) -> Any: + if not master_key or not is_secret: + return value + if not isinstance(value, str): + return value + if is_encrypted_string(value): + return value + return encrypt_value(value, master_key) + + +def encrypt_json_if_secret(value: Any, is_secret: bool, master_key: bytes | None) -> Any: + if not master_key or not is_secret: + return value + if isinstance(value, str): + if is_encrypted_string(value): + return value + return encrypt_value(value, master_key) + if isinstance(value, dict): + try: + return encrypt_value(json.dumps(value), master_key) + except Exception: + return value + return value + + +def decrypt_if_ciphertext(value: Any, master_key: bytes | None) -> Tuple[Any, str | None]: + if not master_key or not isinstance(value, str) or not is_encrypted_string(value): + return value, None + try: + return decrypt_value(value, master_key), None + except CryptoError as ex: + return value, str(ex) + + +def decrypt_body_if_ciphertext(value: Any, master_key: bytes | None) -> Tuple[Any, str | None]: + plain, err = decrypt_if_ciphertext(value, master_key) + if err or not isinstance(plain, str): + return plain, err + try: + return json.loads(plain), None + except Exception: + return plain, None diff --git a/backend/app/core/secret_transformers.py b/backend/app/core/secret_transformers.py new file mode 100644 index 0000000..5e9cf9e --- /dev/null +++ b/backend/app/core/secret_transformers.py @@ -0,0 +1,147 @@ +from typing import Any + +from app.core.secret_codec import ( + decrypt_body_if_ciphertext, + decrypt_if_ciphertext, + encrypt_if_secret, + encrypt_json_if_secret, + is_ciphertext, +) + + +def transform_request_for_encryption(req: Any, master_key: bytes | None) -> Any: + if not master_key: + return req + r = req.model_copy(deep=True) + + secret_headers = getattr(r, "secret_headers", {}) or {} + r.headers = { + k: encrypt_if_secret(v, bool(secret_headers.get(k)), master_key) + for k, v in (r.headers or {}).items() + } + + secret_q = getattr(r, "secret_query_params", {}) or {} + next_q = [] + for row in r.query_params or []: + row_copy = dict(row) + key = row_copy.get("key") or "" + row_copy["value"] = encrypt_if_secret(row_copy.get("value"), bool(secret_q.get(key)), master_key) + next_q.append(row_copy) + r.query_params = next_q + + secret_form = getattr(r, "secret_form_fields", {}) or {} + next_form = [] + for row in r.form_body or []: + row_copy = dict(row) + key = row_copy.get("key") or "" + row_type = (row_copy.get("type") or "text").lower() + if row_type == "text": + row_copy["value"] = encrypt_if_secret(row_copy.get("value"), bool(secret_form.get(key)), master_key) + next_form.append(row_copy) + r.form_body = next_form + + secret_auth = getattr(r, "secret_auth_params", {}) or {} + r.auth_params = { + k: encrypt_if_secret(v, bool(secret_auth.get(k)), master_key) + for k, v in (r.auth_params or {}).items() + } + + r.body = encrypt_json_if_secret(getattr(r, "body", None), bool(getattr(r, "secret_body", False)), master_key) + return r + + +def transform_request_for_decryption(req: Any, master_key: bytes | None) -> Any: + if not master_key: + return req + r = req + + # Decrypt ciphertext regardless of current secret flags so toggles/key renames + # do not strand encrypted values. + next_headers = {} + for k, v in (r.headers or {}).items(): + dec, _ = decrypt_if_ciphertext(v, master_key) + next_headers[k] = dec + r.headers = next_headers + + next_q = [] + for row in r.query_params or []: + row_copy = dict(row) + dec, _ = decrypt_if_ciphertext(row_copy.get("value"), master_key) + row_copy["value"] = dec + next_q.append(row_copy) + r.query_params = next_q + + next_form = [] + for row in r.form_body or []: + row_copy = dict(row) + row_type = (row_copy.get("type") or "text").lower() + if row_type == "text": + dec, _ = decrypt_if_ciphertext(row_copy.get("value"), master_key) + row_copy["value"] = dec + next_form.append(row_copy) + r.form_body = next_form + + next_auth = {} + for k, v in (r.auth_params or {}).items(): + dec, _ = decrypt_if_ciphertext(v, master_key) + next_auth[k] = dec + r.auth_params = next_auth + + body_dec, _ = decrypt_body_if_ciphertext(getattr(r, "body", None), master_key) + r.body = body_dec + return r + + +def request_contains_encrypted_values(req: Any) -> bool: + for _, v in (getattr(req, "headers", {}) or {}).items(): + if is_ciphertext(v): + return True + + for row in getattr(req, "query_params", []) or []: + if is_ciphertext((row or {}).get("value")): + return True + + for row in getattr(req, "form_body", []) or []: + row_type = ((row or {}).get("type") or "text").lower() + if row_type == "text" and is_ciphertext((row or {}).get("value")): + return True + + for _, v in (getattr(req, "auth_params", {}) or {}).items(): + if is_ciphertext(v): + return True + + if is_ciphertext(getattr(req, "body", None)): + return True + return False + + +def transform_environment_for_encryption(env_file: Any, master_key: bytes | None) -> Any: + if not master_key: + return env_file + env_copy = env_file.model_copy(deep=True) + for env_obj in env_copy.envs.values(): + secrets_map = getattr(env_obj, "secrets", {}) or {} + for key, is_secret in secrets_map.items(): + if not is_secret: + continue + env_obj.variables[key] = encrypt_if_secret(env_obj.variables.get(key), True, master_key) + return env_copy + + +def transform_environment_for_decryption(env_file: Any, master_key: bytes | None) -> Any: + if not master_key: + return env_file + # Decrypt ciphertext regardless of secret map to prevent orphaned ciphertext. + for env_obj in env_file.envs.values(): + for key, val in (env_obj.variables or {}).items(): + dec, _ = decrypt_if_ciphertext(val, master_key) + env_obj.variables[key] = dec + return env_file + + +def environment_contains_encrypted_values(env_file: Any) -> bool: + for env_obj in env_file.envs.values(): + for _, val in (env_obj.variables or {}).items(): + if is_ciphertext(val): + return True + return False diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py index 3d8db8e..b67d5e1 100644 --- a/backend/app/core/storage.py +++ b/backend/app/core/storage.py @@ -4,7 +4,6 @@ import secrets import shutil import time -import json as jsonlib from pathlib import Path from typing import Any, List, Dict, Optional, Tuple from uuid import uuid4 @@ -28,6 +27,14 @@ KDF_NAME, KEY_LEN, ) +from app.core.secret_transformers import ( + transform_request_for_encryption, + transform_request_for_decryption, + request_contains_encrypted_values, + transform_environment_for_encryption, + transform_environment_for_decryption, + environment_contains_encrypted_values, +) class VaultLockedError(Exception): @@ -72,29 +79,6 @@ def refresh_workspace_gitignore(self) -> bool: self._ensure_workspace_gitignore() return True - def _ensure_workspace_gitignore(self): - """ - Ensure dynamic runtime artifacts are ignored when users version their workspace. - """ - patterns = [ - "collections/*/history.json", - "collections/*/last_results.json", - "collections/*/cookies.json", - ] - gitignore_path = self.base_dir / ".gitignore" - try: - existing = gitignore_path.read_text().splitlines() if gitignore_path.exists() else [] - updated = existing[:] - for p in patterns: - if p not in existing: - updated.append(p) - if updated != existing: - gitignore_path.parent.mkdir(parents=True, exist_ok=True) - gitignore_path.write_text("\n".join(updated) + ("\n" if updated else "")) - except Exception: - # Gitignore best-effort; ignore failures to avoid blocking runtime - pass - # --- Vault helpers --- def _vault_path(self) -> Path: return self.base_dir / ".litefetch" / "vault.key" @@ -171,15 +155,19 @@ def _history_path(self, collection_id: str) -> Path: def _cookies_path(self, collection_id: str) -> Path: return self._collection_dir(collection_id) / "cookies.json" - def _atomic_write(self, target_path: Path, data: Any): + def _is_runtime_artifact(self, target_path: Path) -> bool: + return target_path.name in {"history.json", "last_results.json", "cookies.json"} + + def _atomic_write(self, target_path: Path, data: Any, compact: bool = False): target_path.parent.mkdir(parents=True, exist_ok=True) tmp = target_path.with_suffix(".tmp") + dump_kwargs = {"separators": (",", ":")} if compact else {"indent": 2} if hasattr(data, "model_dump"): - payload = json.dumps(data.model_dump(), indent=2) + payload = json.dumps(data.model_dump(), **dump_kwargs) elif hasattr(data, "dict"): - payload = json.dumps(data.dict(), indent=2) + payload = json.dumps(data.dict(), **dump_kwargs) else: - payload = json.dumps(data, indent=2) + payload = json.dumps(data, **dump_kwargs) with open(tmp, "w", encoding="utf-8") as f: f.write(payload) f.flush() @@ -196,12 +184,12 @@ def _prepare_payload(self, data: Any) -> str: obj = data return json.dumps(obj, separators=(",", ":")) - def _secure_write_json(self, target_path: Path, data: Any): + def _secure_write_json(self, target_path: Path, data: Any, compact: bool = False): if not self.master_key: raise VaultLockedError("workspace locked") ciphertext = encrypt_value(self._prepare_payload(data), self.master_key) wrapper = {"v": 1, "ciphertext": ciphertext} - self._atomic_write(target_path, wrapper) + self._atomic_write(target_path, wrapper, compact=compact) def _secure_read_json(self, target_path: Path) -> Any: if not self.master_key: @@ -246,152 +234,23 @@ def _read_sensitive(self, path: Path, default: Any = None) -> Any: return json.load(f) def _write_sensitive(self, path: Path, data: Any): + compact = self._is_runtime_artifact(path) if self.master_key: - self._secure_write_json(path, data) + self._secure_write_json(path, data, compact=compact) return if self._vault_ready(): raise VaultLockedError("workspace locked") - self._atomic_write(path, data) + self._atomic_write(path, data, compact=compact) def _require_unlocked(self): if self._vault_ready() and not self.master_key: raise VaultLockedError("workspace locked") def _encrypt_request_secrets(self, req: Any) -> Any: - if not self.master_key: - return req - r = req.model_copy(deep=True) - # Headers - secret_headers = getattr(r, "secret_headers", {}) or {} - next_headers = {} - for k, v in (r.headers or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and not is_encrypted_string(v): - next_headers[k] = encrypt_value(v, self.master_key) - else: - next_headers[k] = v - r.headers = next_headers - - # Query params - secret_q = getattr(r, "secret_query_params", {}) or {} - next_q = [] - for row in r.query_params or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - if secret_q.get(key) and isinstance(val, str) and not is_encrypted_string(str(val)): - row_copy["value"] = encrypt_value(str(val), self.master_key) - next_q.append(row_copy) - r.query_params = next_q - - # Form body - secret_form = getattr(r, "secret_form_fields", {}) or {} - next_form = [] - for row in r.form_body or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - row_type = (row_copy.get("type") or "text").lower() - # Only encrypt textual form values; do not encrypt file paths or blobs - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and not is_encrypted_string(str(val)): - row_copy["value"] = encrypt_value(str(val), self.master_key) - next_form.append(row_copy) - r.form_body = next_form - - # Auth - secret_auth = getattr(r, "secret_auth_params", {}) or {} - next_auth = {} - for k, v in (r.auth_params or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and not is_encrypted_string(v): - next_auth[k] = encrypt_value(v, self.master_key) - else: - next_auth[k] = v - r.auth_params = next_auth - - # Body - if getattr(r, "secret_body", False): - body_val = r.body - if isinstance(body_val, str) and not is_encrypted_string(body_val): - r.body = encrypt_value(body_val, self.master_key) - elif isinstance(body_val, dict): - try: - raw = jsonlib.dumps(body_val) - r.body = encrypt_value(raw, self.master_key) - except Exception: - pass - return r + return transform_request_for_encryption(req, self.master_key) def _decrypt_request_secrets(self, req: Any) -> Any: - if not self.master_key: - return req - r = req - # Headers - secret_headers = getattr(r, "secret_headers", {}) or {} - next_headers = {} - for k, v in (r.headers or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and is_encrypted_string(v): - try: - next_headers[k] = decrypt_value(v, self.master_key) - except CryptoError: - next_headers[k] = v - else: - next_headers[k] = v - r.headers = next_headers - - # Query params - secret_q = getattr(r, "secret_query_params", {}) or {} - next_q = [] - for row in r.query_params or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - if secret_q.get(key) and isinstance(val, str) and is_encrypted_string(val): - try: - row_copy["value"] = decrypt_value(val, self.master_key) - except CryptoError: - pass - next_q.append(row_copy) - r.query_params = next_q - - # Form body - secret_form = getattr(r, "secret_form_fields", {}) or {} - next_form = [] - for row in r.form_body or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - row_type = (row_copy.get("type") or "text").lower() - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and is_encrypted_string(val): - try: - row_copy["value"] = decrypt_value(val, self.master_key) - except CryptoError: - pass - next_form.append(row_copy) - r.form_body = next_form - - # Auth - secret_auth = getattr(r, "secret_auth_params", {}) or {} - next_auth = {} - for k, v in (r.auth_params or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and is_encrypted_string(v): - try: - next_auth[k] = decrypt_value(v, self.master_key) - except CryptoError: - next_auth[k] = v - else: - next_auth[k] = v - r.auth_params = next_auth - - # Body - if getattr(r, "secret_body", False) and isinstance(r.body, str) and is_encrypted_string(r.body): - try: - raw = decrypt_value(r.body, self.master_key) - try: - r.body = jsonlib.loads(raw) - except Exception: - r.body = raw - except CryptoError: - pass - return r + return transform_request_for_decryption(req, self.master_key) def _walk_requests(self, items: list, fn) -> list: result = [] @@ -428,49 +287,13 @@ def _walk_requests_any(self, items: list, predicate) -> bool: return False def _request_contains_encrypted_secret(self, req: Any) -> bool: - secret_headers = getattr(req, "secret_headers", {}) or {} - for k, v in (getattr(req, "headers", {}) or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and is_encrypted_string(v): - return True - - secret_q = getattr(req, "secret_query_params", {}) or {} - for row in getattr(req, "query_params", []) or []: - key = (row or {}).get("key") or "" - val = (row or {}).get("value") - if secret_q.get(key) and isinstance(val, str) and is_encrypted_string(val): - return True - - secret_form = getattr(req, "secret_form_fields", {}) or {} - for row in getattr(req, "form_body", []) or []: - key = (row or {}).get("key") or "" - val = (row or {}).get("value") - row_type = ((row or {}).get("type") or "text").lower() - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and is_encrypted_string(val): - return True - - secret_auth = getattr(req, "secret_auth_params", {}) or {} - for k, v in (getattr(req, "auth_params", {}) or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and is_encrypted_string(v): - return True - - if getattr(req, "secret_body", False) and isinstance(getattr(req, "body", None), str): - if is_encrypted_string(getattr(req, "body")): - return True - return False + return request_contains_encrypted_values(req) def _collection_contains_encrypted_secrets(self, col: Collection) -> bool: return self._walk_requests_any(col.items or [], self._request_contains_encrypted_secret) def _environment_contains_encrypted_secrets(self, env_file: EnvironmentFile) -> bool: - for env_obj in env_file.envs.values(): - secrets_map = getattr(env_obj, "secrets", {}) or {} - for key, is_secret in secrets_map.items(): - if not is_secret: - continue - val = env_obj.variables.get(key) - if isinstance(val, str) and is_encrypted_string(val): - return True - return False + return environment_contains_encrypted_values(env_file) def reencrypt_sensitive(self) -> Dict[str, int]: """ @@ -634,17 +457,7 @@ def load_environment(self, collection_id: str) -> EnvironmentFile: raise VaultLockedError("workspace locked") return env_file - for env_obj in env_file.envs.values(): - secrets_map = getattr(env_obj, "secrets", {}) or {} - for key, is_secret in secrets_map.items(): - if not is_secret: - continue - val = env_obj.variables.get(key) - if isinstance(val, str) and is_encrypted_string(val): - try: - env_obj.variables[key] = decrypt_value(val, self.master_key) - except CryptoError: - continue + env_file = transform_environment_for_decryption(env_file, self.master_key) if was_wrapped: try: self.save_environment(collection_id, env_file) @@ -655,19 +468,7 @@ def load_environment(self, collection_id: str) -> EnvironmentFile: def save_environment(self, collection_id: str, env: EnvironmentFile): if self._vault_ready() and not self.master_key: raise VaultLockedError("workspace locked") - env_copy = env.model_copy(deep=True) - if self.master_key: - for env_obj in env_copy.envs.values(): - secrets_map = getattr(env_obj, "secrets", {}) or {} - for key, is_secret in secrets_map.items(): - if not is_secret: - continue - val = env_obj.variables.get(key) - if isinstance(val, str) and is_encrypted_string(val): - # already encrypted, leave as-is - continue - if isinstance(val, str): - env_obj.variables[key] = encrypt_value(val, self.master_key) + env_copy = transform_environment_for_encryption(env, self.master_key) self._atomic_write(self._env_path(collection_id), env_copy) self._touch_meta(collection_id) @@ -689,7 +490,15 @@ def save_last_results(self, collection_id: str, results: dict): if not isinstance(results, dict): results = {} self._write_sensitive(self._last_results_path(collection_id), results) - self._touch_meta(collection_id) + + def upsert_last_result(self, collection_id: str, request_id: str, result: dict): + if not request_id: + return + results = self.load_last_results(collection_id) + if not isinstance(results, dict): + results = {} + results[request_id] = result if isinstance(result, dict) else {} + self._write_sensitive(self._last_results_path(collection_id), results) def load_history(self, collection_id: str) -> List[dict]: return self._read_sensitive(self._history_path(collection_id), default=[]) @@ -699,7 +508,6 @@ def append_history(self, collection_id: str, result: dict): history.insert(0, result) history = history[:50] self._write_sensitive(self._history_path(collection_id), history) - self._touch_meta(collection_id) # --- Cookies --- def _load_cookies_blob(self, collection_id: str) -> Dict[str, list]: @@ -728,21 +536,18 @@ def load_env_cookies(self, collection_id: str, env_id: str) -> list: if cleaned != entries: data[env_id] = cleaned self._write_sensitive(self._cookies_path(collection_id), data) - self._touch_meta(collection_id) return cleaned def save_env_cookies(self, collection_id: str, env_id: str, cookies: list): data = self._load_cookies_blob(collection_id) data[env_id] = cookies if isinstance(cookies, list) else [] self._write_sensitive(self._cookies_path(collection_id), data) - self._touch_meta(collection_id) def clear_env_cookies(self, collection_id: str, env_id: str): data = self._load_cookies_blob(collection_id) if env_id in data: data[env_id] = [] self._write_sensitive(self._cookies_path(collection_id), data) - self._touch_meta(collection_id) def load_bundle(self, collection_id: str) -> CollectionBundle: meta = self.load_meta(collection_id) diff --git a/backend/app/models.py b/backend/app/models.py index fb0ce69..41766de 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -79,6 +79,9 @@ class RequestResult(BaseModel): duration_ms: float headers: Dict[str, str] body: Any + body_is_json: bool = False + content_type: Optional[str] = None + body_bytes: int = 0 error: Optional[str] = None timestamp: float = Field(default_factory=time.time) diff --git a/backend/tests/test_secret_transformers.py b/backend/tests/test_secret_transformers.py new file mode 100644 index 0000000..3dd7da8 --- /dev/null +++ b/backend/tests/test_secret_transformers.py @@ -0,0 +1,65 @@ +import os +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from app.models import HttpRequest, Environment, EnvironmentFile +from app.core.secret_transformers import ( + transform_request_for_encryption, + transform_request_for_decryption, + transform_environment_for_encryption, + transform_environment_for_decryption, + request_contains_encrypted_values, + environment_contains_encrypted_values, +) + + +def test_request_decrypts_even_if_secret_flags_change(): + master = os.urandom(32) + req = HttpRequest( + headers={"Authorization": "Bearer token"}, + secret_headers={"Authorization": True}, + body="super-secret", + secret_body=True, + ) + + enc = transform_request_for_encryption(req, master) + assert request_contains_encrypted_values(enc) + + # Simulate user toggling secret flags off or key drift before load/decrypt. + enc.secret_headers = {} + enc.secret_body = False + dec = transform_request_for_decryption(enc, master) + + assert dec.headers["Authorization"] == "Bearer token" + assert dec.body == "super-secret" + + +def test_environment_decrypts_even_if_secret_map_changes(): + master = os.urandom(32) + env = EnvironmentFile( + active_env="default", + envs={ + "default": Environment( + name="default", + variables={"API_KEY": "abc123"}, + secrets={"API_KEY": True}, + ) + }, + ) + + enc_env = transform_environment_for_encryption(env, master) + assert environment_contains_encrypted_values(enc_env) + + # Simulate secret map mismatch that previously stranded ciphertext. + enc_env.envs["default"].secrets = {} + dec_env = transform_environment_for_decryption(enc_env, master) + assert dec_env.envs["default"].variables["API_KEY"] == "abc123" + + +def test_non_secret_values_remain_plaintext(): + master = os.urandom(32) + req = HttpRequest(headers={"X-Trace": "abc"}, secret_headers={}) + out = transform_request_for_encryption(req, master) + assert out.headers["X-Trace"] == "abc" diff --git a/frontend/src/components/environment/EnvironmentPanel.tsx b/frontend/src/components/environment/EnvironmentPanel.tsx index 8731f00..37f05ee 100644 --- a/frontend/src/components/environment/EnvironmentPanel.tsx +++ b/frontend/src/components/environment/EnvironmentPanel.tsx @@ -4,6 +4,8 @@ import { useEnvironmentQuery, useSaveEnvironmentMutation } from '../../hooks/use import type { EnvironmentFile } from '../../lib/api'; import { cn } from '../../lib/utils'; import { useWorkspaceLockStore } from '../../stores/useWorkspaceLockStore'; +import { renameKeyInMap, uniqueKey } from '../../lib/forms/stableRows'; +import { useUnsavedChangesGuard } from '../../lib/state/useUnsavedChangesGuard'; type EnvPanelProps = { open: boolean; @@ -21,18 +23,23 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { const { data } = useEnvironmentQuery(); const { mutateAsync: saveEnv, isPending } = useSaveEnvironmentMutation(); const [localEnv, setLocalEnv] = useState(emptyEnvFile); + const [isDirty, setIsDirty] = useState(false); const [status, setStatus] = useState<{ tone: 'info' | 'success' | 'error'; message: string } | null>(null); const { isLocked } = useWorkspaceLockStore(); + const { confirmDiscard } = useUnsavedChangesGuard(); + const markDirty = () => setIsDirty(true); // keep local copy for editing useEffect(() => { - if (data) setLocalEnv(data); - }, [data]); + if (!open || !data || isDirty) return; + setLocalEnv(data); + }, [data, isDirty, open]); const envNames = useMemo(() => Object.keys(localEnv.envs || {}), [localEnv]); const activeEnvKey = localEnv.active_env; const activeEnv = localEnv.envs[activeEnvKey] || { name: activeEnvKey, variables: {}, secrets: {} }; const addEnv = () => { + markDirty(); const base = 'env'; let idx = 1; while (localEnv.envs[`${base}-${idx}`]) idx += 1; @@ -49,7 +56,8 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const addVar = () => { - const key = 'KEY'; + markDirty(); + const key = uniqueKey(Object.keys(activeEnv.variables || {}), 'KEY'); const next = { ...localEnv, envs: { @@ -65,9 +73,8 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const updateVar = (k: string, v: string) => { - const nextVars = { ...activeEnv.variables }; - delete nextVars[k]; - nextVars[k] = v; + markDirty(); + const nextVars = { ...activeEnv.variables, [k]: v }; const next = { ...localEnv, envs: { @@ -82,16 +89,11 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const renameVar = (oldKey: string, newKey: string) => { - const trimmed = newKey.trim(); - if (!trimmed) return; - const nextVars = { ...activeEnv.variables }; - const val = nextVars[oldKey]; - const nextSecrets = { ...(activeEnv.secrets || {}) }; - const secretFlag = nextSecrets[oldKey]; - delete nextVars[oldKey]; - delete nextSecrets[oldKey]; - nextVars[trimmed] = val; - if (secretFlag !== undefined) nextSecrets[trimmed] = secretFlag; + markDirty(); + if (!newKey || oldKey === newKey) return; + if (Object.prototype.hasOwnProperty.call(activeEnv.variables || {}, newKey)) return; + const nextVars = renameKeyInMap(activeEnv.variables || {}, oldKey, newKey); + const nextSecrets = renameKeyInMap(activeEnv.secrets || {}, oldKey, newKey); const next = { ...localEnv, envs: { @@ -103,6 +105,7 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const removeVar = (key: string) => { + markDirty(); const nextVars = { ...activeEnv.variables }; const nextSecrets = { ...(activeEnv.secrets || {}) }; delete nextVars[key]; @@ -118,42 +121,22 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const renameEnv = (newName: string) => { - const trimmed = newName.trim() || activeEnvKey; - if (trimmed === activeEnvKey) { - setLocalEnv((prev) => ({ - ...prev, - envs: { ...prev.envs, [activeEnvKey]: { ...activeEnv, name: trimmed } }, - })); - return; - } - - // Ensure unique key - let candidate = trimmed; - let counter = 1; - while (localEnv.envs[candidate]) { - candidate = `${trimmed}-${counter}`; - counter += 1; - } - - const { [activeEnvKey]: _unused, ...rest } = localEnv.envs; // eslint-disable-line @typescript-eslint/no-unused-vars - const nextEnvs = { - ...rest, - [candidate]: { ...activeEnv, name: trimmed }, - }; - setLocalEnv({ - ...localEnv, - active_env: candidate, - envs: nextEnvs, - }); + markDirty(); + setLocalEnv((prev) => ({ + ...prev, + envs: { ...prev.envs, [activeEnvKey]: { ...activeEnv, name: newName } }, + })); }; const handleSave = async () => { await saveEnv(localEnv); + setIsDirty(false); setStatus({ tone: 'success', message: 'Environment saved' }); onClose(); }; const toggleSecret = (key: string) => { + markDirty(); const nextSecrets = { ...(activeEnv.secrets || {}) }; nextSecrets[key] = !nextSecrets[key]; setLocalEnv((prev) => ({ @@ -165,6 +148,16 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { })); }; + const handleClose = () => { + const shouldClose = confirmDiscard({ + isDirty, + message: 'Discard unsaved environment changes?', + }); + if (!shouldClose) return; + setIsDirty(false); + onClose(); + }; + if (!open) return null; if (isLocked) { @@ -178,7 +171,7 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { +
+ + +
void; showSecrets?: boolean; + tableId?: string; + duplicateKeyIndexes?: Set; + missingKeyIndexes?: Set; }; -export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, showSecrets = false }: Props) => { - const tableRef = useRef(null); +export const FormTable = ({ + rows, + onChange, + showDescription = false, + onEditRow, + showSecrets = false, + tableId = 'form-table', + duplicateKeyIndexes, + missingKeyIndexes, +}: Props) => { + const displayRows = rows && rows.length > 0 ? rows : [{ key: '', value: '', enabled: true, description: '' }]; // Ensure there's always at least one empty row useEffect(() => { @@ -42,6 +56,15 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, const addRow = () => { onChange([...(rows || []), { key: '', value: '', enabled: true, description: '' }]); }; + const { getCellProps } = useKeyValueTableNavigation({ + tableId, + rowCount: displayRows.length, + fields: showDescription ? ['key', 'value', 'description'] : ['key', 'value'], + addRow, + deleteRow, + canDeleteRow: (rowIndex) => + Boolean(displayRows[rowIndex]?.key?.trim() || displayRows[rowIndex]?.value?.trim()) || displayRows.length > 1, + }); // Auto-expand: add new row when user types in the last row const handleChange = (idx: number, patch: Partial) => { @@ -64,10 +87,8 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, onChange(rows.map(r => ({ ...r, enabled: !allEnabled }))); }; - const displayRows = rows && rows.length > 0 ? rows : [{ key: '', value: '', enabled: true, description: '' }]; - return ( -
+
{/* Table Header */}
@@ -115,11 +136,15 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow,
handleChange(idx, { key: e.target.value })} disabled={row.enabled === false} + {...getCellProps(idx, 'key')} />
@@ -133,6 +158,7 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, onChange={(e) => handleChange(idx, { value: e.target.value })} disabled={row.enabled === false} onDoubleClick={() => onEditRow?.(idx)} + {...getCellProps(idx, 'value')} />
@@ -146,6 +172,7 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, value={row.description || ''} onChange={(e) => updateRow(idx, { description: e.target.value })} disabled={row.enabled === false} + {...getCellProps(idx, 'description')} />
)} @@ -189,6 +216,12 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, {/* Add Row Button */}
+
); @@ -182,23 +195,30 @@ const HeadersTab = React.memo(({ control, onHeadersChange }: HeadersTabProps) => HeadersTab.displayName = 'HeadersTab'; export const RequestEditor = () => { - const { activeRequestId, setIsRunning, setResult, setSentRequest } = useActiveRequestStore(); + const { activeRequestId, runningByRequest, setRequestRunning, setResult, setSentRequest, setRequestSelectionGuard } = useActiveRequestStore(); const activeRequest = useActiveRequestFromCollection(activeRequestId); const { mutateAsync: saveRequest, isPending: isSaving } = useSaveRequestMutation(); const { mutateAsync: saveLastResult } = useSaveLastResultMutation(); const [activeTab, setActiveTab] = useState<'body' | 'headers' | 'params' | 'auth' | 'settings'>('body'); const [showUrlEditor, setShowUrlEditor] = useState(false); + const [urlDraft, setUrlDraft] = useState(''); + const [urlDraftDirty, setUrlDraftDirty] = useState(false); + const [saveState, setSaveState] = useState<'saved' | 'unsaved' | 'saving' | 'error'>('saved'); + const [validationError, setValidationError] = useState(null); const { isLocked, openModal } = useWorkspaceLockStore(); - const syncingFromUrl = useRef(false); - const syncingFromParams = useRef(false); + const [isEditingUrl, setIsEditingUrl] = useState(false); + const urlInputRef = useRef(null); + const { confirmDiscard } = useUnsavedChangesGuard(); + const { markFromLeft, markFromRight, consumeIfFromLeft, consumeIfFromRight, resetSyncOrigin } = useBiDirectionalSync(); const [paramEditor, setParamEditor] = useState<{ index: number; key: string; value: string; } | null>(null); useHotkeys([ - { combo: 'mod+s', handler: () => handleSave() }, - { combo: 'mod+enter', handler: () => handleRun() }, + { combo: 'mod+s', handler: () => void handleSave() }, + { combo: 'mod+enter', handler: () => void handleRun() }, + { combo: 'mod+l', handler: () => urlInputRef.current?.focus() }, ]); const { @@ -208,6 +228,7 @@ export const RequestEditor = () => { watch, setValue, getValues, + formState, } = useForm({ defaultValues: { name: '', @@ -231,12 +252,22 @@ export const RequestEditor = () => { }); const urlValue = watch('url'); const queryParams = watch('query_params'); + const headersRows = watch('headers'); + const formBodyRows = watch('form_body'); const authType = watch('auth_type'); const authParams = watch('auth_params'); const secretBody = watch('secret_body'); const secretAuthParams = watch('secret_auth_params'); + const autoSaveSnapshot = useWatch({ control }); + const isDirty = formState.isDirty; const queryClient = useQueryClient(); + const headerDuplicateIndexes = useMemo(() => findDuplicateKeyIndexes(headersRows || []), [headersRows]); + const headerMissingKeyIndexes = useMemo(() => findMissingKeyIndexes(headersRows || []), [headersRows]); + const queryDuplicateIndexes = useMemo(() => findDuplicateKeyIndexes(queryParams || []), [queryParams]); + const queryMissingKeyIndexes = useMemo(() => findMissingKeyIndexes(queryParams || []), [queryParams]); + const formDuplicateIndexes = useMemo(() => findDuplicateKeyIndexes(formBodyRows || []), [formBodyRows]); + const formMissingKeyIndexes = useMemo(() => findMissingKeyIndexes(formBodyRows || []), [formBodyRows]); const activeCollectionId = useActiveCollectionStore((s) => s.activeId); const { fields: ruleFields, append: appendRule, remove: removeRule } = useFieldArray({ @@ -323,6 +354,7 @@ export const RequestEditor = () => { // Reset form when active request changes useEffect(() => { if (!activeRequest) return; + resetSyncOrigin(); const headersRecord = activeRequest.headers || {}; const derived = deriveAuthFromHeaders(headersRecord, (activeRequest.auth_type as any) || 'none', activeRequest.auth_params || {}); const secretHeaderMap = activeRequest.secret_headers || {}; @@ -364,31 +396,32 @@ export const RequestEditor = () => { secret_body: Boolean(activeRequest.secret_body), binary: activeRequest.binary || null, }); - }, [activeRequest, reset, parseQueryParamsFromUrl, deriveAuthFromHeaders]); + setSaveState('saved'); + }, [activeRequest, reset, parseQueryParamsFromUrl, deriveAuthFromHeaders, resetSyncOrigin]); // Sync URL -> Params useEffect(() => { - if (syncingFromParams.current) { - syncingFromParams.current = false; + if (consumeIfFromRight()) { return; } + if (isEditingUrl) return; const parsed = parseQueryParamsFromUrl(urlValue || ''); - syncingFromUrl.current = true; + markFromLeft(); setValue('query_params', parsed, { shouldDirty: false }); - }, [parseQueryParamsFromUrl, setValue, urlValue]); + }, [consumeIfFromRight, isEditingUrl, markFromLeft, parseQueryParamsFromUrl, setValue, urlValue]); // Sync Params -> URL useEffect(() => { - if (syncingFromUrl.current) { - syncingFromUrl.current = false; + if (consumeIfFromLeft()) { return; } + if (isEditingUrl) return; const nextUrl = buildUrlWithParams(urlValue || '', queryParams || []); if (nextUrl !== urlValue) { - syncingFromParams.current = true; + markFromRight(); setValue('url', nextUrl, { shouldDirty: true }); } - }, [buildUrlWithParams, queryParams, setValue, urlValue]); + }, [buildUrlWithParams, consumeIfFromLeft, isEditingUrl, markFromRight, queryParams, setValue, urlValue]); const buildRequest = useMemo(() => { return (values: FormValues): HttpRequest => { @@ -482,10 +515,49 @@ export const RequestEditor = () => { return { ...req, headers, body: body ?? null, form_body: formBody, binary: binaryPayload }; }; + const performSave = useCallback( + async (mode: 'manual' | 'auto') => { + if (!activeRequest || !activeCollectionId || isLocked) return; + if (mode === 'auto' && isSaving) return; + const values = getValues(); + const updated = buildRequest(values); + try { + setSaveState('saving'); + await saveRequest(updated); + reset(values); + setSaveState('saved'); + } catch (err) { + console.error(err); + setSaveState('error'); + } + }, + [activeCollectionId, activeRequest, buildRequest, getValues, isLocked, isSaving, reset, saveRequest], + ); + const handleSave = async () => { if (!activeRequest || !activeCollectionId) return; - const updated = buildRequest(getValues()); - await saveRequest(updated); + const issues = validateRequestForSubmit({ + url: getValues('url'), + body_mode: getValues('body_mode'), + headers: getValues('headers') || [], + query_params: getValues('query_params') || [], + form_body: getValues('form_body') || [], + binary: getValues('binary'), + }); + if (issues.length) { + const first = issues[0]; + setValidationError(first.message); + setActiveTab(first.tab); + if (first.selector) { + window.setTimeout(() => { + const node = document.querySelector(first.selector!); + node?.focus(); + }, 0); + } + return; + } + setValidationError(null); + await performSave('manual'); }; const handleRun = async () => { @@ -493,10 +565,31 @@ export const RequestEditor = () => { return; } if (!activeRequest || !activeCollectionId) return; + const issues = validateRequestForSubmit({ + url: getValues('url'), + body_mode: getValues('body_mode'), + headers: getValues('headers') || [], + query_params: getValues('query_params') || [], + form_body: getValues('form_body') || [], + binary: getValues('binary'), + }); + if (issues.length) { + const first = issues[0]; + setValidationError(first.message); + setActiveTab(first.tab); + if (first.selector) { + window.setTimeout(() => { + const node = document.querySelector(first.selector!); + node?.focus(); + }, 0); + } + return; + } + setValidationError(null); const baseReq = buildRequest(getValues()); const prepared = prepareForSend(baseReq); setSentRequest(prepared.id, prepared); - setIsRunning(true); + setRequestRunning(prepared.id, true); try { await saveRequest(baseReq); const res = await LiteAPI.runRequest(activeCollectionId, prepared); @@ -515,13 +608,68 @@ export const RequestEditor = () => { timestamp: Date.now() / 1000, }); } finally { - setIsRunning(false); + setRequestRunning(prepared.id, false); } }; + const openUrlEditor = useCallback(() => { + setUrlDraft(getValues('url') || ''); + setUrlDraftDirty(false); + setShowUrlEditor(true); + setIsEditingUrl(true); + }, [getValues]); + + const closeUrlEditor = useCallback(() => { + const shouldClose = confirmDiscard({ + isDirty: urlDraftDirty, + message: 'Discard unsaved URL changes?', + }); + if (!shouldClose) return; + setShowUrlEditor(false); + setIsEditingUrl(false); + setUrlDraftDirty(false); + }, [confirmDiscard, urlDraftDirty]); + + const saveAndCloseUrlEditor = useCallback(() => { + const nextUrl = urlDraft.trim(); + const currentUrl = getValues('url') || ''; + setValue('url', nextUrl, { shouldDirty: nextUrl !== currentUrl }); + setShowUrlEditor(false); + setIsEditingUrl(false); + setUrlDraftDirty(false); + }, [getValues, setValue, urlDraft]); + + useEffect(() => { + setSaveState((prev) => (isDirty ? (prev === 'saving' ? prev : 'unsaved') : 'saved')); + }, [isDirty]); + + useEffect(() => { + if (validationError) setValidationError(null); + }, [autoSaveSnapshot]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!activeRequest || !activeCollectionId || isLocked || !isDirty) return; + const timer = window.setTimeout(() => { + void performSave('auto'); + }, 800); + return () => window.clearTimeout(timer); + }, [activeCollectionId, activeRequest, autoSaveSnapshot, isDirty, isLocked, performSave]); + + useEffect(() => { + setRequestSelectionGuard(() => + confirmDiscard({ + isDirty, + message: 'You have unsaved request changes. Discard and switch requests?', + }), + ); + return () => setRequestSelectionGuard(null); + }, [confirmDiscard, isDirty, setRequestSelectionGuard]); + const bodyValue = watch('body'); const bodyMode = watch('body_mode'); const binary = watch('binary'); + const activeRequestRunning = activeRequestId ? Boolean(runningByRequest[activeRequestId]) : false; + const urlField = register('url'); if (isLocked) { return ( @@ -562,13 +710,39 @@ export const RequestEditor = () => { { + urlField.ref(el); + urlInputRef.current = el; + }} + data-req-field="url" placeholder="http://localhost:8000/api..." - onDoubleClick={() => setShowUrlEditor(true)} + onDoubleClick={openUrlEditor} + onFocus={() => setIsEditingUrl(true)} + onBlur={() => setIsEditingUrl(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void handleRun(); + } + }} /> +
+ {saveState === 'saved' ? 'Saved' : saveState === 'unsaved' ? 'Unsaved' : saveState === 'saving' ? 'Saving…' : 'Save failed'} +
+
- {showUrlEditor && ( -
-
-
-
Edit URL
-
- - -
-
+ {validationError && ( +
+ {validationError} +
+ )} + !isOpen && closeUrlEditor()}> + + + Edit URL + +