django-http-inspector 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_http_inspector/__init__.py +6 -0
- django_http_inspector/capture/__init__.py +0 -0
- django_http_inspector/capture/exchange.py +84 -0
- django_http_inspector/capture/headers.py +33 -0
- django_http_inspector/capture/url.py +51 -0
- django_http_inspector/config.py +102 -0
- django_http_inspector/inspector/__init__.py +0 -0
- django_http_inspector/inspector/app.py +292 -0
- django_http_inspector/inspector/presentation.py +195 -0
- django_http_inspector/inspector/security.py +45 -0
- django_http_inspector/inspector/templates.py +21 -0
- django_http_inspector/replay/__init__.py +0 -0
- django_http_inspector/replay/edit.py +119 -0
- django_http_inspector/replay/service.py +69 -0
- django_http_inspector/replay/target.py +68 -0
- django_http_inspector/replay/transport.py +85 -0
- django_http_inspector/static/django_http_inspector/inspect.css +139 -0
- django_http_inspector/static/django_http_inspector/inspect.js +210 -0
- django_http_inspector/storage/__init__.py +3 -0
- django_http_inspector/storage/records.py +84 -0
- django_http_inspector/storage/repository.py +276 -0
- django_http_inspector/storage/schema.py +40 -0
- django_http_inspector/templates/django_http_inspector/base.html +13 -0
- django_http_inspector/templates/django_http_inspector/index.html +101 -0
- django_http_inspector/wrapper/__init__.py +0 -0
- django_http_inspector/wrapper/input.py +68 -0
- django_http_inspector/wrapper/response.py +38 -0
- django_http_inspector/wrapper/wsgi.py +78 -0
- django_http_inspector-0.1.4.dist-info/METADATA +129 -0
- django_http_inspector-0.1.4.dist-info/RECORD +33 -0
- django_http_inspector-0.1.4.dist-info/WHEEL +5 -0
- django_http_inspector-0.1.4.dist-info/licenses/LICENSE +21 -0
- django_http_inspector-0.1.4.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
|
|
6
|
+
from django_http_inspector.storage.records import ExchangeRecord
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("django_http_inspector")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExchangeCapture:
|
|
12
|
+
def __init__(self, environ, input_stream, config, url_data, headers, repository):
|
|
13
|
+
self.environ = environ
|
|
14
|
+
self.input_stream = input_stream
|
|
15
|
+
self.config = config
|
|
16
|
+
self.started = time.monotonic()
|
|
17
|
+
self.response_status = None
|
|
18
|
+
self.response_headers = []
|
|
19
|
+
self.response_body = bytearray()
|
|
20
|
+
self.response_size = 0
|
|
21
|
+
self.response_incomplete = False
|
|
22
|
+
self.error_summary = ""
|
|
23
|
+
self.finalized = False
|
|
24
|
+
self.exchange = None
|
|
25
|
+
self.repository = repository
|
|
26
|
+
url, scheme, host, provenance = url_data
|
|
27
|
+
if not repository.available:
|
|
28
|
+
return
|
|
29
|
+
nonce = next((value for name, value in headers if name.lower() == "x-django-http-inspector-replay"), None)
|
|
30
|
+
try:
|
|
31
|
+
self.exchange = repository.create_exchange(
|
|
32
|
+
correlation_nonce=nonce,
|
|
33
|
+
method=str(environ.get("REQUEST_METHOD", "GET")),
|
|
34
|
+
url=url,
|
|
35
|
+
url_provenance=provenance,
|
|
36
|
+
scheme=scheme,
|
|
37
|
+
host=host,
|
|
38
|
+
path=str(environ.get("PATH_INFO", "/")),
|
|
39
|
+
query_string=str(environ.get("QUERY_STRING", "")),
|
|
40
|
+
request_headers=headers,
|
|
41
|
+
request_content_type=str(environ.get("CONTENT_TYPE", "")),
|
|
42
|
+
request_declared_size=input_stream.declared_size,
|
|
43
|
+
client_addr=str(environ.get("REMOTE_ADDR", "")),
|
|
44
|
+
)
|
|
45
|
+
except Exception:
|
|
46
|
+
logger.exception("Unable to create django-http-inspector exchange")
|
|
47
|
+
|
|
48
|
+
def start(self, status, headers):
|
|
49
|
+
self.response_status = int(str(status).split(" ", 1)[0])
|
|
50
|
+
self.response_headers = [[str(name), str(value)] for name, value in headers]
|
|
51
|
+
|
|
52
|
+
def observe_response(self, data):
|
|
53
|
+
self.response_size += len(data)
|
|
54
|
+
remaining = self.config.capture_max_bytes - len(self.response_body)
|
|
55
|
+
if remaining > 0:
|
|
56
|
+
self.response_body.extend(data[:remaining])
|
|
57
|
+
|
|
58
|
+
def finalize(self, error=None, incomplete=False):
|
|
59
|
+
if not self.exchange or self.finalized:
|
|
60
|
+
return
|
|
61
|
+
self.finalized = True
|
|
62
|
+
now = timezone.now()
|
|
63
|
+
if error:
|
|
64
|
+
self.error_summary = f"{type(error).__name__}: {error}"[:2000]
|
|
65
|
+
try:
|
|
66
|
+
self.exchange.completed_at = now
|
|
67
|
+
self.exchange.duration_ms = (time.monotonic() - self.started) * 1000
|
|
68
|
+
self.exchange.request_body = bytes(self.input_stream.captured)
|
|
69
|
+
self.exchange.request_observed_size = self.input_stream.observed_size
|
|
70
|
+
self.exchange.request_captured_size = len(self.input_stream.captured)
|
|
71
|
+
self.exchange.request_body_truncated = self.input_stream.truncated
|
|
72
|
+
self.exchange.request_body_incomplete = self.input_stream.incomplete
|
|
73
|
+
self.exchange.response_status = self.response_status
|
|
74
|
+
self.exchange.response_headers = self.response_headers
|
|
75
|
+
self.exchange.response_body = bytes(self.response_body)
|
|
76
|
+
self.exchange.response_size = self.response_size
|
|
77
|
+
self.exchange.response_body_truncated = self.response_size > self.config.capture_max_bytes
|
|
78
|
+
self.exchange.response_body_incomplete = incomplete
|
|
79
|
+
self.exchange.state = ExchangeRecord.State.APPLICATION_ERROR if error else ExchangeRecord.State.COMPLETE
|
|
80
|
+
self.exchange.error_summary = self.error_summary
|
|
81
|
+
self.repository.update_exchange(self.exchange)
|
|
82
|
+
self.repository.prune(self.config.max_records)
|
|
83
|
+
except Exception:
|
|
84
|
+
logger.exception("Unable to finalize django-http-inspector exchange")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
HOP_BY_HOP_HEADERS = {
|
|
2
|
+
"connection",
|
|
3
|
+
"keep-alive",
|
|
4
|
+
"proxy-authenticate",
|
|
5
|
+
"proxy-authorization",
|
|
6
|
+
"te",
|
|
7
|
+
"trailer",
|
|
8
|
+
"transfer-encoding",
|
|
9
|
+
"upgrade",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def request_headers_from_environ(environ):
|
|
14
|
+
headers = []
|
|
15
|
+
if environ.get("CONTENT_TYPE"):
|
|
16
|
+
headers.append(["Content-Type", str(environ["CONTENT_TYPE"])])
|
|
17
|
+
if environ.get("CONTENT_LENGTH"):
|
|
18
|
+
headers.append(["Content-Length", str(environ["CONTENT_LENGTH"])])
|
|
19
|
+
for key, value in environ.items():
|
|
20
|
+
if key.startswith("HTTP_"):
|
|
21
|
+
name = "-".join(part.title() for part in key[5:].split("_"))
|
|
22
|
+
headers.append([name, str(value)])
|
|
23
|
+
return headers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def replay_headers(headers):
|
|
27
|
+
result = []
|
|
28
|
+
for name, value in headers:
|
|
29
|
+
lower = name.lower()
|
|
30
|
+
if lower in HOP_BY_HOP_HEADERS or lower in {"content-length", "host", "x-django-http-inspector-replay"}:
|
|
31
|
+
continue
|
|
32
|
+
result.append((name, value))
|
|
33
|
+
return result
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from ipaddress import ip_address, ip_network
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _trusted(remote_addr, cidrs):
|
|
6
|
+
try:
|
|
7
|
+
address = ip_address(remote_addr)
|
|
8
|
+
return any(address in ip_network(cidr, strict=False) for cidr in cidrs)
|
|
9
|
+
except ValueError:
|
|
10
|
+
return False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _forwarded(environ):
|
|
14
|
+
value = environ.get("HTTP_FORWARDED", "")
|
|
15
|
+
if not value:
|
|
16
|
+
return None, None
|
|
17
|
+
nearest = value.split(",")[-1]
|
|
18
|
+
parts = {}
|
|
19
|
+
for item in nearest.split(";"):
|
|
20
|
+
key, separator, raw = item.strip().partition("=")
|
|
21
|
+
if separator:
|
|
22
|
+
parts[key.lower()] = raw.strip().strip('"')
|
|
23
|
+
return parts.get("proto"), parts.get("host")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_url(environ, trusted_proxy_cidrs=()):
|
|
27
|
+
scheme = str(environ.get("wsgi.url_scheme", "http")).lower()
|
|
28
|
+
host = str(environ.get("HTTP_HOST") or "")
|
|
29
|
+
provenance = "reconstructed"
|
|
30
|
+
if _trusted(str(environ.get("REMOTE_ADDR", "")), trusted_proxy_cidrs):
|
|
31
|
+
forwarded_scheme, forwarded_host = _forwarded(environ)
|
|
32
|
+
scheme = forwarded_scheme or str(environ.get("HTTP_X_FORWARDED_PROTO", "")).split(",")[-1].strip() or scheme
|
|
33
|
+
host = forwarded_host or str(environ.get("HTTP_X_FORWARDED_HOST", "")).split(",")[-1].strip() or host
|
|
34
|
+
provenance = "trusted_proxy"
|
|
35
|
+
if scheme not in {"http", "https"} or not host or any(ch in host for ch in "\r\n/@"):
|
|
36
|
+
return "", scheme, host, provenance
|
|
37
|
+
|
|
38
|
+
raw_uri = environ.get("RAW_URI") or environ.get("REQUEST_URI")
|
|
39
|
+
if raw_uri:
|
|
40
|
+
target = str(raw_uri)
|
|
41
|
+
provenance = "server_specific"
|
|
42
|
+
if not target.startswith("/"):
|
|
43
|
+
target = "/" + target
|
|
44
|
+
else:
|
|
45
|
+
script = quote(str(environ.get("SCRIPT_NAME", "")), safe="/%:@")
|
|
46
|
+
path = quote(str(environ.get("PATH_INFO", "/")), safe="/%:@")
|
|
47
|
+
target = script + path
|
|
48
|
+
query = str(environ.get("QUERY_STRING", ""))
|
|
49
|
+
if query:
|
|
50
|
+
target += "?" + query
|
|
51
|
+
return f"{scheme}://{host}{target}", scheme, host, provenance
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from ipaddress import ip_network
|
|
3
|
+
from os import PathLike
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class InspectConfig:
|
|
13
|
+
enabled: bool
|
|
14
|
+
allow_remote: bool
|
|
15
|
+
path: str
|
|
16
|
+
capture_max_bytes: int
|
|
17
|
+
max_records: int
|
|
18
|
+
exclude_paths: Tuple[str, ...]
|
|
19
|
+
trusted_proxy_cidrs: Tuple[str, ...]
|
|
20
|
+
inspector_allowed_hosts: Tuple[str, ...]
|
|
21
|
+
inspector_allowed_client_cidrs: Tuple[str, ...]
|
|
22
|
+
replay_timeout: float
|
|
23
|
+
sqlite_path: Path
|
|
24
|
+
|
|
25
|
+
def is_inspector_path(self, path: str) -> bool:
|
|
26
|
+
base = self.path.rstrip("/")
|
|
27
|
+
return path == base or path.startswith(base + "/")
|
|
28
|
+
|
|
29
|
+
def is_excluded_path(self, path: str) -> bool:
|
|
30
|
+
return self.is_inspector_path(path) or any(
|
|
31
|
+
path == prefix.rstrip("/") or path.startswith(prefix)
|
|
32
|
+
for prefix in self.exclude_paths
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _path(value: object, name: str) -> str:
|
|
37
|
+
if not isinstance(value, str) or not value.startswith("/"):
|
|
38
|
+
raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must start with '/'.")
|
|
39
|
+
return value.rstrip("/") + "/"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _positive_number(value: object, name: str, number_type):
|
|
43
|
+
if isinstance(value, bool) or not isinstance(value, number_type) or value <= 0:
|
|
44
|
+
raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must be positive.")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _boolean(value: object, name: str) -> bool:
|
|
49
|
+
if type(value) is not bool:
|
|
50
|
+
raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must be a boolean.")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config() -> InspectConfig:
|
|
55
|
+
raw = getattr(settings, "DJANGO_HTTP_INSPECTOR", {})
|
|
56
|
+
if not isinstance(raw, dict):
|
|
57
|
+
raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR must be a dictionary.")
|
|
58
|
+
|
|
59
|
+
allow_remote = _boolean(raw.get("ALLOW_REMOTE", False), "ALLOW_REMOTE")
|
|
60
|
+
trusted = tuple(raw.get("TRUSTED_PROXY_CIDRS", ()))
|
|
61
|
+
clients = tuple(raw.get("INSPECTOR_ALLOWED_CLIENT_CIDRS", ("127.0.0.0/8", "::1/128")))
|
|
62
|
+
try:
|
|
63
|
+
for cidr in trusted + clients:
|
|
64
|
+
ip_network(cidr, strict=False)
|
|
65
|
+
except (TypeError, ValueError) as exc:
|
|
66
|
+
raise ImproperlyConfigured(f"Invalid django-http-inspector CIDR: {exc}") from exc
|
|
67
|
+
if not allow_remote and any(not ip_network(cidr, strict=False).is_loopback for cidr in clients):
|
|
68
|
+
raise ImproperlyConfigured(
|
|
69
|
+
"MVP Inspector access is loopback-only; non-loopback authentication is not implemented."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
hosts = tuple(raw.get("INSPECTOR_ALLOWED_HOSTS", ("localhost", "127.0.0.1", "[::1]")))
|
|
73
|
+
if not hosts or not all(isinstance(host, str) and host for host in hosts):
|
|
74
|
+
raise ImproperlyConfigured("INSPECTOR_ALLOWED_HOSTS must contain host names.")
|
|
75
|
+
|
|
76
|
+
base_dir = Path(getattr(settings, "BASE_DIR", Path.cwd()))
|
|
77
|
+
sqlite_value = raw.get("SQLITE_PATH", base_dir / ".django-http-inspector.sqlite3")
|
|
78
|
+
if not isinstance(sqlite_value, (str, PathLike)) or not str(sqlite_value):
|
|
79
|
+
raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR['SQLITE_PATH'] must be a non-empty path.")
|
|
80
|
+
sqlite_path = Path(sqlite_value)
|
|
81
|
+
if not sqlite_path.is_absolute():
|
|
82
|
+
sqlite_path = base_dir / sqlite_path
|
|
83
|
+
sqlite_path = sqlite_path.resolve()
|
|
84
|
+
if sqlite_path.exists() and sqlite_path.is_dir():
|
|
85
|
+
raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR['SQLITE_PATH'] must be a file, not a directory.")
|
|
86
|
+
|
|
87
|
+
return InspectConfig(
|
|
88
|
+
enabled=bool(raw.get("ENABLED", settings.DEBUG)),
|
|
89
|
+
allow_remote=allow_remote,
|
|
90
|
+
path=_path(raw.get("PATH", "/__inspect/"), "PATH"),
|
|
91
|
+
capture_max_bytes=_positive_number(raw.get("CAPTURE_MAX_BYTES", 1024 * 1024), "CAPTURE_MAX_BYTES", int),
|
|
92
|
+
max_records=_positive_number(raw.get("MAX_RECORDS", 1000), "MAX_RECORDS", int),
|
|
93
|
+
exclude_paths=tuple(_path(p, "EXCLUDE_PATHS") for p in raw.get(
|
|
94
|
+
"EXCLUDE_PATHS",
|
|
95
|
+
("/static/", "/favicon.ico", "/.well-known/appspecific/com.chrome.devtools.json"),
|
|
96
|
+
)),
|
|
97
|
+
trusted_proxy_cidrs=trusted,
|
|
98
|
+
inspector_allowed_hosts=hosts,
|
|
99
|
+
inspector_allowed_client_cidrs=clients,
|
|
100
|
+
replay_timeout=float(_positive_number(raw.get("REPLAY_TIMEOUT", 10), "REPLAY_TIMEOUT", (int, float))),
|
|
101
|
+
sqlite_path=sqlite_path,
|
|
102
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from html import escape
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from urllib.parse import parse_qs
|
|
6
|
+
|
|
7
|
+
from django_http_inspector.inspector.presentation import present_body
|
|
8
|
+
from django_http_inspector.inspector.security import mutation_allowed, request_allowed
|
|
9
|
+
from django_http_inspector.inspector.templates import render
|
|
10
|
+
from django_http_inspector.replay.edit import EditValidationError, editable_body, encode_edited_body, headers_to_text, parse_headers
|
|
11
|
+
from django_http_inspector.replay.service import can_replay, replay_exchange
|
|
12
|
+
from django_http_inspector.storage.records import ReplayAttemptRecord
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
STATUS_TEXT = {
|
|
16
|
+
200: "OK", 302: "Found", 303: "See Other", 400: "Bad Request", 403: "Forbidden",
|
|
17
|
+
404: "Not Found", 409: "Conflict", 413: "Content Too Large", 500: "Internal Server Error",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PayloadError(ValueError):
|
|
22
|
+
def __init__(self, status, code, message):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status = status
|
|
25
|
+
self.code = code
|
|
26
|
+
self.message = message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InspectorApp:
|
|
30
|
+
def __init__(self, config, token, repository):
|
|
31
|
+
self.config = config
|
|
32
|
+
self.token = token
|
|
33
|
+
self.repository = repository
|
|
34
|
+
self.base = config.path.rstrip("/")
|
|
35
|
+
|
|
36
|
+
def response(self, start_response, content, status=200, content_type="text/html; charset=utf-8", headers=()):
|
|
37
|
+
body = content.encode("utf-8") if isinstance(content, str) else content
|
|
38
|
+
response_headers = [("Content-Type", content_type), ("Content-Length", str(len(body))), *headers]
|
|
39
|
+
start_response(f"{status} {STATUS_TEXT.get(status, '')}".strip(), response_headers)
|
|
40
|
+
return [body]
|
|
41
|
+
|
|
42
|
+
def json_response(self, start_response, payload, status=200):
|
|
43
|
+
return self.response(
|
|
44
|
+
start_response,
|
|
45
|
+
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
|
46
|
+
status,
|
|
47
|
+
"application/json; charset=utf-8",
|
|
48
|
+
(("Cache-Control", "no-store"),),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def redirect(self, start_response, location):
|
|
52
|
+
return self.response(start_response, b"", 303, headers=(("Location", location),))
|
|
53
|
+
|
|
54
|
+
def form(self, environ):
|
|
55
|
+
try:
|
|
56
|
+
length = min(int(environ.get("CONTENT_LENGTH", "0") or 0), 64 * 1024)
|
|
57
|
+
except ValueError:
|
|
58
|
+
length = 0
|
|
59
|
+
return parse_qs(environ["wsgi.input"].read(length).decode("utf-8", "replace"))
|
|
60
|
+
|
|
61
|
+
def read_json(self, environ):
|
|
62
|
+
limit = self.config.capture_max_bytes + 256 * 1024
|
|
63
|
+
try:
|
|
64
|
+
declared = int(environ.get("CONTENT_LENGTH", "0") or 0)
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
raise PayloadError(400, "invalid_payload", "Content-Length is invalid.")
|
|
67
|
+
if declared > limit:
|
|
68
|
+
raise PayloadError(413, "payload_too_large", "Edit & Replay payload is too large.")
|
|
69
|
+
content_type = str(environ.get("CONTENT_TYPE", "")).split(";", 1)[0].strip().lower()
|
|
70
|
+
if content_type != "application/json":
|
|
71
|
+
raise PayloadError(400, "invalid_payload", "Content-Type must be application/json.")
|
|
72
|
+
data = environ["wsgi.input"].read(limit + 1)
|
|
73
|
+
if len(data) > limit:
|
|
74
|
+
raise PayloadError(413, "payload_too_large", "Edit & Replay payload is too large.")
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(data.decode("utf-8", "strict"))
|
|
77
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
78
|
+
raise PayloadError(400, "invalid_payload", "Request body must be valid UTF-8 JSON.")
|
|
79
|
+
if not isinstance(payload, dict):
|
|
80
|
+
raise PayloadError(400, "invalid_payload", "JSON payload must be an object.")
|
|
81
|
+
allowed = {"token", "headers_text", "body_text"}
|
|
82
|
+
unknown = set(payload) - allowed
|
|
83
|
+
if unknown:
|
|
84
|
+
raise PayloadError(400, "unexpected_field", f"Unexpected field: {sorted(unknown)[0]}.")
|
|
85
|
+
if set(payload) != allowed or not all(isinstance(payload[name], str) for name in allowed):
|
|
86
|
+
raise PayloadError(400, "invalid_payload", "token, headers_text, and body_text are required strings.")
|
|
87
|
+
return payload
|
|
88
|
+
|
|
89
|
+
def __call__(self, environ, start_response):
|
|
90
|
+
if not request_allowed(environ, self.config):
|
|
91
|
+
return self.response(start_response, "Inspector access denied.", 403, "text/plain; charset=utf-8")
|
|
92
|
+
path = str(environ.get("PATH_INFO", "/"))
|
|
93
|
+
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
|
|
94
|
+
relative = path[len(self.base):] or "/"
|
|
95
|
+
if relative.startswith("/assets/") and method == "GET":
|
|
96
|
+
return self.asset(relative, start_response)
|
|
97
|
+
if relative == "/" and method == "GET":
|
|
98
|
+
return self.index(start_response)
|
|
99
|
+
if relative == "/api/exchanges" and method == "GET":
|
|
100
|
+
return self.exchange_snapshot(start_response)
|
|
101
|
+
if relative == "/clear" and method == "POST":
|
|
102
|
+
return self.clear(environ, start_response)
|
|
103
|
+
parts = [part for part in relative.split("/") if part]
|
|
104
|
+
if len(parts) == 2 and parts[0] == "requests" and method == "GET":
|
|
105
|
+
return self.detail(parts[1], start_response)
|
|
106
|
+
if len(parts) == 3 and parts[0] == "requests" and parts[2] == "replay" and method == "POST":
|
|
107
|
+
return self.replay(parts[1], environ, start_response)
|
|
108
|
+
if len(parts) == 3 and parts[0] == "requests" and parts[2] == "edit-replay" and method == "POST":
|
|
109
|
+
return self.edit_replay(parts[1], environ, start_response)
|
|
110
|
+
return self.response(start_response, "Not found", 404, "text/plain; charset=utf-8")
|
|
111
|
+
|
|
112
|
+
def lightweight_snapshot(self):
|
|
113
|
+
exchanges = self.repository.list_exchanges(limit=200)
|
|
114
|
+
rows = [{
|
|
115
|
+
"id": exchange.id, "method": exchange.method, "path": exchange.path, "state": exchange.state,
|
|
116
|
+
"response_status": exchange.response_status, "duration_ms": exchange.duration_ms,
|
|
117
|
+
"created_at": exchange.created_at.isoformat().replace("+00:00", "Z"),
|
|
118
|
+
} for exchange in exchanges]
|
|
119
|
+
total = self.repository.count_exchanges()
|
|
120
|
+
canonical = json.dumps({"exchanges": rows, "total": total}, sort_keys=True, separators=(",", ":"))
|
|
121
|
+
return rows, total, hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
122
|
+
|
|
123
|
+
def exchange_snapshot(self, start_response):
|
|
124
|
+
if not self.repository.available:
|
|
125
|
+
return self.json_error(start_response, 500, "storage_unavailable", self.repository.error)
|
|
126
|
+
try:
|
|
127
|
+
rows, total, cursor = self.lightweight_snapshot()
|
|
128
|
+
return self.json_response(start_response, {"cursor": cursor, "exchanges": rows, "total": total})
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
return self.json_error(start_response, 500, "storage_unavailable", str(exc))
|
|
131
|
+
|
|
132
|
+
def context(self, selected=None, message=""):
|
|
133
|
+
exchanges = self.repository.list_exchanges(limit=200)
|
|
134
|
+
body = body_kind = response_body = response_kind = ""
|
|
135
|
+
request_parts = response_parts = None
|
|
136
|
+
attempts = []
|
|
137
|
+
edit_allowed = False
|
|
138
|
+
edit_body = edit_reason = ""
|
|
139
|
+
if selected:
|
|
140
|
+
body, body_kind, request_parts = present_body(
|
|
141
|
+
selected.request_body,
|
|
142
|
+
selected.request_content_type,
|
|
143
|
+
complete=not (selected.request_body_truncated or selected.request_body_incomplete),
|
|
144
|
+
)
|
|
145
|
+
response_content_type = next((v for n, v in selected.response_headers if n.lower() == "content-type"), "")
|
|
146
|
+
response_body, response_kind, response_parts = present_body(
|
|
147
|
+
selected.response_body,
|
|
148
|
+
response_content_type,
|
|
149
|
+
complete=not (selected.response_body_truncated or selected.response_body_incomplete),
|
|
150
|
+
)
|
|
151
|
+
attempts = self.repository.list_attempts(selected.id, limit=20)
|
|
152
|
+
edit_allowed, edit_body, edit_reason = editable_body(selected)
|
|
153
|
+
_, total, cursor = self.lightweight_snapshot()
|
|
154
|
+
return {
|
|
155
|
+
"base": self.base, "token": self.token, "exchanges": exchanges, "exchange_total": total,
|
|
156
|
+
"list_cursor": cursor, "selected": selected, "request_body": body, "request_body_kind": body_kind,
|
|
157
|
+
"request_multipart_parts": request_parts, "response_body": response_body,
|
|
158
|
+
"response_body_kind": response_kind, "response_multipart_parts": response_parts, "attempts": attempts,
|
|
159
|
+
"can_replay": bool(selected and can_replay(selected)),
|
|
160
|
+
"can_edit": bool(selected and can_replay(selected) and edit_allowed), "edit_reason": edit_reason,
|
|
161
|
+
"edit_headers": headers_to_text(selected.request_headers) if selected else "", "edit_body": edit_body,
|
|
162
|
+
"message": message,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def index(self, start_response):
|
|
166
|
+
if not self.repository.available:
|
|
167
|
+
return self.storage_error(start_response)
|
|
168
|
+
try:
|
|
169
|
+
exchanges = self.repository.list_exchanges(limit=1)
|
|
170
|
+
return self.response(start_response, render("index.html", self.context(exchanges[0] if exchanges else None)))
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
return self.storage_error(start_response, exc)
|
|
173
|
+
|
|
174
|
+
def detail(self, exchange_id, start_response, message=""):
|
|
175
|
+
try:
|
|
176
|
+
exchange = self.repository.get_exchange(int(exchange_id))
|
|
177
|
+
if exchange is None:
|
|
178
|
+
return self.response(start_response, "Request not found", 404, "text/plain; charset=utf-8")
|
|
179
|
+
return self.response(start_response, render("index.html", self.context(exchange, message)))
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
return self.response(start_response, "Request not found", 404, "text/plain; charset=utf-8")
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
return self.storage_error(start_response, exc)
|
|
184
|
+
|
|
185
|
+
def clear(self, environ, start_response):
|
|
186
|
+
data = self.form(environ)
|
|
187
|
+
if not mutation_allowed(environ, self.token, data.get("token", [""])[0]):
|
|
188
|
+
return self.response(start_response, "Forbidden", 403, "text/plain; charset=utf-8")
|
|
189
|
+
if not self.repository.available:
|
|
190
|
+
return self.storage_error(start_response)
|
|
191
|
+
try:
|
|
192
|
+
self.repository.clear()
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
return self.storage_error(start_response, exc)
|
|
195
|
+
return self.redirect(start_response, self.base + "/")
|
|
196
|
+
|
|
197
|
+
def replay(self, exchange_id, environ, start_response):
|
|
198
|
+
data = self.form(environ)
|
|
199
|
+
if not mutation_allowed(environ, self.token, data.get("token", [""])[0]):
|
|
200
|
+
return self.response(start_response, "Forbidden", 403, "text/plain; charset=utf-8")
|
|
201
|
+
try:
|
|
202
|
+
exchange = self.repository.get_exchange(int(exchange_id))
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
exchange = None
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
return self.storage_error(start_response, exc)
|
|
207
|
+
if exchange is None:
|
|
208
|
+
return self.response(start_response, "Request not found", 404, "text/plain; charset=utf-8")
|
|
209
|
+
try:
|
|
210
|
+
attempt = replay_exchange(exchange, self.config, self.repository, allow_risky=True)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
return self.storage_error(start_response, exc)
|
|
213
|
+
message = "Replay completed." if attempt.state == ReplayAttemptRecord.State.COMPLETE else f"Replay failed: {attempt.error_summary}"
|
|
214
|
+
if attempt.persistence_error:
|
|
215
|
+
qualifier = "may have been sent" if attempt.network_attempted else "was not sent"
|
|
216
|
+
message = f"Replay {qualifier}, but its final result could not be saved: {attempt.persistence_error}"
|
|
217
|
+
return self.detail(exchange_id, start_response, message)
|
|
218
|
+
|
|
219
|
+
def edit_replay(self, exchange_id, environ, start_response):
|
|
220
|
+
try:
|
|
221
|
+
payload = self.read_json(environ)
|
|
222
|
+
except PayloadError as exc:
|
|
223
|
+
return self.json_error(start_response, exc.status, exc.code, exc.message)
|
|
224
|
+
if not mutation_allowed(environ, self.token, payload["token"]):
|
|
225
|
+
return self.json_error(start_response, 403, "forbidden", "Forbidden.")
|
|
226
|
+
try:
|
|
227
|
+
exchange = self.repository.get_exchange(int(exchange_id))
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
exchange = None
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
return self.json_error(start_response, 500, "storage_unavailable", str(exc))
|
|
232
|
+
if exchange is None:
|
|
233
|
+
return self.json_error(start_response, 404, "not_found", "Request not found.")
|
|
234
|
+
allowed, _, reason = editable_body(exchange)
|
|
235
|
+
if not allowed:
|
|
236
|
+
return self.json_error(start_response, 409, "body_not_editable", reason, field="body_text")
|
|
237
|
+
try:
|
|
238
|
+
headers = parse_headers(payload["headers_text"])
|
|
239
|
+
body = encode_edited_body(headers, payload["body_text"])
|
|
240
|
+
except EditValidationError as exc:
|
|
241
|
+
return self.json_error(start_response, exc.status, exc.code, exc.message, field=exc.field, line=exc.line)
|
|
242
|
+
try:
|
|
243
|
+
attempt = replay_exchange(
|
|
244
|
+
exchange, self.config, self.repository, allow_risky=True, headers=headers, body=body, mode="edited"
|
|
245
|
+
)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
return self.json_error(start_response, 500, "attempt_persistence_failed", str(exc))
|
|
248
|
+
attempt_data = self.attempt_json(attempt)
|
|
249
|
+
if attempt.persistence_error:
|
|
250
|
+
return self.json_error(
|
|
251
|
+
start_response, 500, "outcome_persistence_failed",
|
|
252
|
+
"The replay may have been sent, but its outcome could not be saved.",
|
|
253
|
+
attempt=attempt_data, request_may_have_been_sent=attempt.network_attempted,
|
|
254
|
+
)
|
|
255
|
+
if attempt.state == ReplayAttemptRecord.State.COMPLETE:
|
|
256
|
+
return self.json_response(start_response, {
|
|
257
|
+
"ok": True, "code": "replay_complete", "message": "Replay completed.", "attempt": attempt_data,
|
|
258
|
+
})
|
|
259
|
+
return self.json_response(start_response, {
|
|
260
|
+
"ok": False, "code": "replay_failed", "message": f"Replay failed: {attempt.error_summary}",
|
|
261
|
+
"attempt": attempt_data,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def attempt_json(attempt):
|
|
266
|
+
return {
|
|
267
|
+
"id": attempt.id, "mode": attempt.mode, "state": attempt.state,
|
|
268
|
+
"response_status": attempt.response_status, "error_stage": attempt.error_stage,
|
|
269
|
+
"error_summary": attempt.error_summary,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def json_error(self, start_response, status, code, message, **extra):
|
|
273
|
+
payload = {"ok": False, "code": code, "message": message, "request_may_have_been_sent": False}
|
|
274
|
+
payload.update({key: value for key, value in extra.items() if value is not None})
|
|
275
|
+
return self.json_response(start_response, payload, status)
|
|
276
|
+
|
|
277
|
+
def storage_error(self, start_response, error=None):
|
|
278
|
+
detail = str(error or self.repository.error or "Unknown storage error")
|
|
279
|
+
body = (
|
|
280
|
+
"<!doctype html><html><head><title>Inspector storage unavailable</title></head>"
|
|
281
|
+
"<body><h1>Inspector storage unavailable</h1><p>Business requests continue normally.</p>"
|
|
282
|
+
f"<pre>{escape(detail)}</pre></body></html>"
|
|
283
|
+
)
|
|
284
|
+
return self.response(start_response, body, 500)
|
|
285
|
+
|
|
286
|
+
def asset(self, relative, start_response):
|
|
287
|
+
name = relative.rsplit("/", 1)[-1]
|
|
288
|
+
if name not in {"inspect.css", "inspect.js"}:
|
|
289
|
+
return self.response(start_response, "Not found", 404, "text/plain; charset=utf-8")
|
|
290
|
+
data = files("django_http_inspector").joinpath("static", "django_http_inspector", name).read_bytes()
|
|
291
|
+
content_type = "text/css; charset=utf-8" if name.endswith(".css") else "text/javascript; charset=utf-8"
|
|
292
|
+
return self.response(start_response, data, content_type=content_type)
|