Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 14 additions & 17 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions LICENSE-COMMERCIAL.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions LICENSE-PERSONAL.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions backend/app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
38 changes: 27 additions & 11 deletions backend/app/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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}"
Expand Down
52 changes: 52 additions & 0 deletions backend/app/core/secret_codec.py
Original file line number Diff line number Diff line change
@@ -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
147 changes: 147 additions & 0 deletions backend/app/core/secret_transformers.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading