pypproxy 0.1.0__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.
- pypproxy/__init__.py +0 -0
- pypproxy/api/__init__.py +0 -0
- pypproxy/api/server.py +427 -0
- pypproxy/bulk/__init__.py +0 -0
- pypproxy/bulk/sender.py +97 -0
- pypproxy/cert/__init__.py +0 -0
- pypproxy/cert/ca.py +144 -0
- pypproxy/cert/client_cert.py +65 -0
- pypproxy/codec.py +176 -0
- pypproxy/config/__init__.py +0 -0
- pypproxy/config/config.py +106 -0
- pypproxy/dns/__init__.py +0 -0
- pypproxy/dns/server.py +149 -0
- pypproxy/exporter/__init__.py +0 -0
- pypproxy/exporter/exporter.py +122 -0
- pypproxy/exporter/importer.py +169 -0
- pypproxy/graphql/__init__.py +0 -0
- pypproxy/graphql/detector.py +76 -0
- pypproxy/graphql/introspection.py +217 -0
- pypproxy/graphql/modifier.py +98 -0
- pypproxy/graphql/schema_store.py +33 -0
- pypproxy/intercept/__init__.py +0 -0
- pypproxy/intercept/manager.py +142 -0
- pypproxy/interceptor/__init__.py +0 -0
- pypproxy/interceptor/interceptor.py +172 -0
- pypproxy/proto/__init__.py +0 -0
- pypproxy/proto/grpc.py +48 -0
- pypproxy/proto/mqtt.py +119 -0
- pypproxy/proto/ws.py +120 -0
- pypproxy/proto/ws_intercept.py +117 -0
- pypproxy/proxy/__init__.py +0 -0
- pypproxy/proxy/proxy.py +407 -0
- pypproxy/replay/__init__.py +0 -0
- pypproxy/replay/replay.py +77 -0
- pypproxy/rule/__init__.py +0 -0
- pypproxy/rule/rule.py +198 -0
- pypproxy/scan/__init__.py +0 -0
- pypproxy/scan/scanner.py +296 -0
- pypproxy/script/__init__.py +0 -0
- pypproxy/script/engine.py +49 -0
- pypproxy/security/__init__.py +0 -0
- pypproxy/security/header_checker.py +308 -0
- pypproxy/security/int_overflow.py +193 -0
- pypproxy/security/jwt_checker.py +273 -0
- pypproxy/security/plugin.py +152 -0
- pypproxy/security/randomness.py +165 -0
- pypproxy/store/__init__.py +0 -0
- pypproxy/store/db.py +189 -0
- pypproxy/store/filter_parser.py +181 -0
- pypproxy/store/fts.py +105 -0
- pypproxy/store/models.py +81 -0
- pypproxy/store/scope.py +63 -0
- pypproxy/store/store.py +120 -0
- pypproxy/ui/__init__.py +0 -0
- pypproxy/ui/app.py +386 -0
- pypproxy/ui/bulk_sender_ui.py +125 -0
- pypproxy/ui/cui.py +162 -0
- pypproxy/ui/detail.py +179 -0
- pypproxy/ui/diff_view.py +118 -0
- pypproxy/ui/graphql_tab.py +265 -0
- pypproxy/ui/import_tab.py +136 -0
- pypproxy/ui/intercept_dialog.py +74 -0
- pypproxy/ui/resender.py +140 -0
- pypproxy/ui/scan_tab.py +98 -0
- pypproxy/ui/security_tab.py +356 -0
- pypproxy/ui/settings.py +413 -0
- pypproxy/ui/theme.py +59 -0
- pypproxy-0.1.0.dist-info/METADATA +19 -0
- pypproxy-0.1.0.dist-info/RECORD +72 -0
- pypproxy-0.1.0.dist-info/WHEEL +4 -0
- pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
- pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from nicegui import ui
|
|
6
|
+
|
|
7
|
+
from pypproxy.store.models import Entry
|
|
8
|
+
from pypproxy.store.store import Store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_graphql_tab(store: Store) -> dict:
|
|
12
|
+
"""Build the GraphQL tab. Returns state with open_entry method."""
|
|
13
|
+
state: dict = {"entry": None, "schema": None}
|
|
14
|
+
|
|
15
|
+
with ui.column().classes("w-full h-full overflow-auto q-pa-md"):
|
|
16
|
+
# --- Introspection section ---
|
|
17
|
+
with ui.expansion("Schema Introspection", icon="schema", value=True).classes(
|
|
18
|
+
"w-full q-mb-md"
|
|
19
|
+
):
|
|
20
|
+
ui.label("Fetch the GraphQL schema from an endpoint using introspection.").classes(
|
|
21
|
+
"text-caption text-grey q-mb-sm"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
with ui.row().classes("gap-2 items-center w-full"):
|
|
25
|
+
url_input = (
|
|
26
|
+
ui.input(
|
|
27
|
+
label="GraphQL endpoint URL", placeholder="https://api.example.com/graphql"
|
|
28
|
+
)
|
|
29
|
+
.props("dense outlined dark")
|
|
30
|
+
.classes("flex-1")
|
|
31
|
+
)
|
|
32
|
+
introspect_btn = ui.button("Introspect", icon="search").props(
|
|
33
|
+
"color=primary size=sm"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
schema_status = ui.label("").classes("text-caption text-grey q-mt-xs")
|
|
37
|
+
|
|
38
|
+
schema_tree = (
|
|
39
|
+
ui.tree(
|
|
40
|
+
[],
|
|
41
|
+
label_key="label",
|
|
42
|
+
children_key="children",
|
|
43
|
+
)
|
|
44
|
+
.props("dark dense")
|
|
45
|
+
.classes("w-full q-mt-sm")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def _introspect() -> None:
|
|
49
|
+
url = url_input.value.strip()
|
|
50
|
+
if not url:
|
|
51
|
+
ui.notify("Enter a URL", type="warning")
|
|
52
|
+
return
|
|
53
|
+
introspect_btn.props("loading")
|
|
54
|
+
try:
|
|
55
|
+
from pypproxy.graphql.introspection import fetch_schema
|
|
56
|
+
|
|
57
|
+
req_headers: dict[str, str] = {}
|
|
58
|
+
entry = state.get("entry")
|
|
59
|
+
if entry:
|
|
60
|
+
req_headers = {
|
|
61
|
+
k: ", ".join(v)
|
|
62
|
+
for k, v in entry.req_headers.items()
|
|
63
|
+
if k.lower() not in ("content-length",)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
schema = await fetch_schema(url, req_headers)
|
|
67
|
+
if schema is None:
|
|
68
|
+
ui.notify("Introspection failed or not supported", type="negative")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
state["schema"] = schema
|
|
72
|
+
schema_status.text = (
|
|
73
|
+
f"Schema loaded: {len(schema.types)} types, "
|
|
74
|
+
f"Query: {schema.query_type}, Mutation: {schema.mutation_type}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Build tree
|
|
78
|
+
nodes = []
|
|
79
|
+
for t in schema.types:
|
|
80
|
+
if t.name.startswith("__") or not t.fields:
|
|
81
|
+
continue
|
|
82
|
+
children = [
|
|
83
|
+
{"id": f"{t.name}.{f.name}", "label": f"{f.name}: {f.type_name}"}
|
|
84
|
+
for f in t.fields
|
|
85
|
+
]
|
|
86
|
+
nodes.append(
|
|
87
|
+
{
|
|
88
|
+
"id": t.name,
|
|
89
|
+
"label": f"{t.name} ({t.kind})",
|
|
90
|
+
"children": children,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
schema_tree.nodes = nodes
|
|
94
|
+
schema_tree.update()
|
|
95
|
+
ui.notify(f"Schema loaded: {len(schema.types)} types", type="positive")
|
|
96
|
+
finally:
|
|
97
|
+
introspect_btn.props(remove="loading")
|
|
98
|
+
|
|
99
|
+
introspect_btn.on("click", _introspect)
|
|
100
|
+
|
|
101
|
+
# --- Query editor ---
|
|
102
|
+
with ui.expansion("Query Editor", icon="code", value=True).classes("w-full q-mb-md"):
|
|
103
|
+
entry_label = ui.label("No entry selected").classes("text-caption text-grey q-mb-xs")
|
|
104
|
+
|
|
105
|
+
with ui.row().classes("gap-2 items-center q-mb-xs"):
|
|
106
|
+
op_type_badge = ui.badge("", color="grey").props("rounded")
|
|
107
|
+
op_name_label = ui.label("").classes("text-caption")
|
|
108
|
+
|
|
109
|
+
query_input = (
|
|
110
|
+
ui.textarea(placeholder="query { ... }\n\nOr paste a GraphQL query here")
|
|
111
|
+
.props("outlined dense rows=8")
|
|
112
|
+
.classes("w-full font-mono text-xs")
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
ui.label("Variables (JSON):").classes("text-caption q-mt-xs")
|
|
116
|
+
vars_input = (
|
|
117
|
+
ui.textarea(placeholder='{"id": "123"}')
|
|
118
|
+
.props("outlined dense rows=3")
|
|
119
|
+
.classes("w-full font-mono text-xs")
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
run_btn = ui.button("Run Query", icon="play_arrow").props("color=primary")
|
|
123
|
+
result_label = ui.label("").classes("text-caption text-grey q-mt-xs")
|
|
124
|
+
result_area = (
|
|
125
|
+
ui.textarea()
|
|
126
|
+
.props("outlined dense rows=10 readonly")
|
|
127
|
+
.classes("w-full font-mono text-xs q-mt-xs")
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def _run_query() -> None:
|
|
131
|
+
entry = state.get("entry")
|
|
132
|
+
if not entry:
|
|
133
|
+
ui.notify(
|
|
134
|
+
"No entry selected — right-click a GraphQL request in Traffic",
|
|
135
|
+
type="warning",
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
import httpx
|
|
140
|
+
|
|
141
|
+
url = f"{entry.scheme}://{entry.host}{entry.path}"
|
|
142
|
+
req_headers = {
|
|
143
|
+
k: ", ".join(v)
|
|
144
|
+
for k, v in entry.req_headers.items()
|
|
145
|
+
if k.lower() not in ("content-length",)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
query = query_input.value.strip()
|
|
149
|
+
if not query:
|
|
150
|
+
ui.notify("Enter a query", type="warning")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
variables: dict = {}
|
|
154
|
+
if vars_input.value.strip():
|
|
155
|
+
try:
|
|
156
|
+
variables = json.loads(vars_input.value)
|
|
157
|
+
except Exception:
|
|
158
|
+
ui.notify("Variables must be valid JSON", type="warning")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
run_btn.props("loading")
|
|
162
|
+
try:
|
|
163
|
+
async with httpx.AsyncClient(verify=False, timeout=30, http2=True) as client:
|
|
164
|
+
resp = await client.post(
|
|
165
|
+
url,
|
|
166
|
+
json={"query": query, "variables": variables},
|
|
167
|
+
headers=req_headers,
|
|
168
|
+
)
|
|
169
|
+
try:
|
|
170
|
+
body = json.dumps(resp.json(), indent=2, ensure_ascii=False)
|
|
171
|
+
except Exception:
|
|
172
|
+
body = resp.text
|
|
173
|
+
result_label.text = f"Status: {resp.status_code}"
|
|
174
|
+
result_area.value = body
|
|
175
|
+
ui.notify(
|
|
176
|
+
f"{resp.status_code}",
|
|
177
|
+
type="positive" if resp.status_code < 400 else "warning",
|
|
178
|
+
)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
result_area.value = str(e)
|
|
181
|
+
ui.notify(str(e), type="negative")
|
|
182
|
+
finally:
|
|
183
|
+
run_btn.props(remove="loading")
|
|
184
|
+
|
|
185
|
+
run_btn.on("click", _run_query)
|
|
186
|
+
|
|
187
|
+
# --- Operation analysis ---
|
|
188
|
+
with ui.expansion("Operation Analysis", icon="analytics").classes("w-full"):
|
|
189
|
+
ui.label("Analyse a captured GraphQL request.").classes(
|
|
190
|
+
"text-caption text-grey q-mb-sm"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
analyse_btn = ui.button("Analyse selected entry", icon="analytics").props(
|
|
194
|
+
"color=secondary size=sm"
|
|
195
|
+
)
|
|
196
|
+
analysis_area = (
|
|
197
|
+
ui.textarea()
|
|
198
|
+
.props("outlined dense rows=12 readonly")
|
|
199
|
+
.classes("w-full font-mono text-xs q-mt-xs")
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _analyse() -> None:
|
|
203
|
+
entry = state.get("entry")
|
|
204
|
+
if not entry or "graphql" not in entry.tags:
|
|
205
|
+
ui.notify("Select a GraphQL entry from Traffic", type="warning")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
from pypproxy.graphql.detector import (
|
|
209
|
+
extract_field_names,
|
|
210
|
+
extract_operation_name,
|
|
211
|
+
extract_operation_type,
|
|
212
|
+
parse_operation,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
op = parse_operation(entry.req_body)
|
|
216
|
+
query = op.get("query", "")
|
|
217
|
+
lines = [
|
|
218
|
+
f"Operation type : {extract_operation_type(query)}",
|
|
219
|
+
f"Operation name : {extract_operation_name(query) or '(anonymous)'}",
|
|
220
|
+
f"Fields detected: {', '.join(extract_field_names(query)) or '(none)'}",
|
|
221
|
+
"",
|
|
222
|
+
"--- Variables ---",
|
|
223
|
+
json.dumps(op.get("variables", {}), indent=2),
|
|
224
|
+
"",
|
|
225
|
+
"--- Query ---",
|
|
226
|
+
query,
|
|
227
|
+
]
|
|
228
|
+
analysis_area.value = "\n".join(lines)
|
|
229
|
+
|
|
230
|
+
# Try to find schema for this host
|
|
231
|
+
schema = state.get("schema")
|
|
232
|
+
if schema:
|
|
233
|
+
root = schema.root_fields()
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.append(f"--- Schema root fields ({schema.query_type}) ---")
|
|
236
|
+
lines.extend(f" {f.name}: {f.type_name}" for f in root)
|
|
237
|
+
analysis_area.value = "\n".join(lines)
|
|
238
|
+
|
|
239
|
+
analyse_btn.on("click", _analyse)
|
|
240
|
+
|
|
241
|
+
def open_entry(entry: Entry) -> None:
|
|
242
|
+
state["entry"] = entry
|
|
243
|
+
entry_label.text = f"#{entry.id} {entry.method} {entry.scheme}://{entry.host}{entry.path}"
|
|
244
|
+
op_type_badge.text = entry.graphql_op_type or ""
|
|
245
|
+
op_name_label.text = entry.graphql_operation or ""
|
|
246
|
+
if entry.graphql_op_type == "mutation":
|
|
247
|
+
op_type_badge.props("color=warning")
|
|
248
|
+
elif entry.graphql_op_type == "subscription":
|
|
249
|
+
op_type_badge.props("color=info")
|
|
250
|
+
else:
|
|
251
|
+
op_type_badge.props("color=positive")
|
|
252
|
+
|
|
253
|
+
# Auto-fill query and variables from entry
|
|
254
|
+
from pypproxy.graphql.detector import parse_operation
|
|
255
|
+
|
|
256
|
+
op = parse_operation(entry.req_body)
|
|
257
|
+
query_input.value = op.get("query", "")
|
|
258
|
+
vars_input.value = (
|
|
259
|
+
json.dumps(op.get("variables", {}), indent=2) if op.get("variables") else ""
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Auto-set introspection URL
|
|
263
|
+
url_input.value = f"{entry.scheme}://{entry.host}{entry.path}"
|
|
264
|
+
|
|
265
|
+
return {"open_entry": open_entry}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nicegui import ui
|
|
4
|
+
|
|
5
|
+
from pypproxy.store.store import Store
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_import_tab(store: Store) -> None:
|
|
9
|
+
"""Build the Import tab content."""
|
|
10
|
+
with ui.column().classes("w-full h-full overflow-auto q-pa-md"):
|
|
11
|
+
ui.label("Import Traffic").classes("text-subtitle2 q-mb-sm")
|
|
12
|
+
|
|
13
|
+
# --- File upload ---
|
|
14
|
+
ui.label("Upload HAR or paxy JSON file:").classes("text-caption text-grey q-mb-xs")
|
|
15
|
+
|
|
16
|
+
async def _handle_upload(e) -> None: # noqa: ANN001
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
from pypproxy.exporter.importer import import_har, import_json
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
data = e.content.read()
|
|
23
|
+
# Auto-detect format
|
|
24
|
+
parsed = json.loads(data)
|
|
25
|
+
if "log" in parsed and "entries" in parsed.get("log", {}):
|
|
26
|
+
count = import_har(data, store)
|
|
27
|
+
ui.notify(f"Imported {count} entries from HAR", type="positive")
|
|
28
|
+
else:
|
|
29
|
+
count = import_json(data, store)
|
|
30
|
+
ui.notify(f"Imported {count} entries from JSON", type="positive")
|
|
31
|
+
except Exception as err:
|
|
32
|
+
ui.notify(f"Import failed: {err}", type="negative")
|
|
33
|
+
|
|
34
|
+
ui.upload(
|
|
35
|
+
label="Drop HAR/JSON here",
|
|
36
|
+
on_upload=_handle_upload,
|
|
37
|
+
auto_upload=True,
|
|
38
|
+
).props("accept=.har,.json flat").classes("w-full q-mb-md")
|
|
39
|
+
|
|
40
|
+
ui.separator()
|
|
41
|
+
|
|
42
|
+
# --- Paste JSON ---
|
|
43
|
+
ui.label("Or paste HAR/JSON directly:").classes("text-caption text-grey q-mb-xs")
|
|
44
|
+
paste_area = (
|
|
45
|
+
ui.textarea(placeholder='{"log": {"entries": [...]}} or [{"method": "GET", ...}]')
|
|
46
|
+
.props("outlined dense rows=10")
|
|
47
|
+
.classes("w-full font-mono text-xs")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def _import_paste() -> None:
|
|
51
|
+
import json
|
|
52
|
+
|
|
53
|
+
from pypproxy.exporter.importer import import_har, import_json
|
|
54
|
+
|
|
55
|
+
text = paste_area.value.strip()
|
|
56
|
+
if not text:
|
|
57
|
+
ui.notify("Nothing to import", type="warning")
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
parsed = json.loads(text)
|
|
61
|
+
if "log" in parsed and "entries" in parsed.get("log", {}):
|
|
62
|
+
count = import_har(text, store)
|
|
63
|
+
ui.notify(f"Imported {count} entries from HAR", type="positive")
|
|
64
|
+
else:
|
|
65
|
+
count = import_json(text, store)
|
|
66
|
+
ui.notify(f"Imported {count} entries from JSON", type="positive")
|
|
67
|
+
paste_area.value = ""
|
|
68
|
+
except Exception as err:
|
|
69
|
+
ui.notify(f"Import failed: {err}", type="negative")
|
|
70
|
+
|
|
71
|
+
ui.button("Import", icon="upload", on_click=_import_paste).props("color=primary q-mt-sm")
|
|
72
|
+
|
|
73
|
+
ui.separator().classes("q-my-md")
|
|
74
|
+
|
|
75
|
+
# --- Full-text search ---
|
|
76
|
+
ui.label("Full-text search").classes("text-subtitle2 q-mb-sm")
|
|
77
|
+
ui.label("Search across all captured request/response bodies, headers, and URLs.").classes(
|
|
78
|
+
"text-caption text-grey q-mb-xs"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
with ui.row().classes("gap-2 items-center w-full"):
|
|
82
|
+
search_input = (
|
|
83
|
+
ui.input(placeholder="Search term…").props("dense outlined dark").classes("flex-1")
|
|
84
|
+
)
|
|
85
|
+
search_btn = ui.button("Search", icon="search").props("color=primary size=sm")
|
|
86
|
+
|
|
87
|
+
search_label = ui.label("").classes("text-caption text-grey q-mt-xs")
|
|
88
|
+
search_table = (
|
|
89
|
+
ui.table(
|
|
90
|
+
columns=[
|
|
91
|
+
{
|
|
92
|
+
"name": "id",
|
|
93
|
+
"label": "ID",
|
|
94
|
+
"field": "entry_id",
|
|
95
|
+
"align": "right",
|
|
96
|
+
"style": "width:60px",
|
|
97
|
+
},
|
|
98
|
+
{"name": "snippet", "label": "Match", "field": "snippet", "align": "left"},
|
|
99
|
+
{
|
|
100
|
+
"name": "rank",
|
|
101
|
+
"label": "Score",
|
|
102
|
+
"field": "rank",
|
|
103
|
+
"align": "right",
|
|
104
|
+
"style": "width:80px",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
rows=[],
|
|
108
|
+
row_key="entry_id",
|
|
109
|
+
)
|
|
110
|
+
.classes("w-full")
|
|
111
|
+
.props("dense flat dark")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def _search() -> None:
|
|
115
|
+
q = search_input.value.strip()
|
|
116
|
+
if not q:
|
|
117
|
+
return
|
|
118
|
+
db = getattr(store, "_db", None)
|
|
119
|
+
if db is None:
|
|
120
|
+
# fallback to in-memory
|
|
121
|
+
from pypproxy.store.models import Filter
|
|
122
|
+
|
|
123
|
+
entries, total = store.list(Filter(search=q), 0, 50)
|
|
124
|
+
rows = [
|
|
125
|
+
{"entry_id": e.id, "rank": 0.0, "snippet": f"{e.host}{e.path}"} for e in entries
|
|
126
|
+
]
|
|
127
|
+
search_label.text = f"{total} results (in-memory search)"
|
|
128
|
+
else:
|
|
129
|
+
results = await db.search(q, limit=50)
|
|
130
|
+
rows = [r.to_dict() for r in results]
|
|
131
|
+
search_label.text = f"{len(rows)} results"
|
|
132
|
+
search_table.rows = rows
|
|
133
|
+
search_table.update()
|
|
134
|
+
|
|
135
|
+
search_btn.on("click", _search)
|
|
136
|
+
search_input.on("keydown.enter", _search)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from nicegui import ui
|
|
6
|
+
|
|
7
|
+
from pypproxy.intercept.manager import InterceptManager, PendingRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_intercept_panel(mgr: InterceptManager, container: ui.element) -> None:
|
|
11
|
+
"""Render the intercept panel inside container. Polls for pending requests."""
|
|
12
|
+
|
|
13
|
+
async def _poller() -> None:
|
|
14
|
+
q = mgr.subscribe()
|
|
15
|
+
try:
|
|
16
|
+
while True:
|
|
17
|
+
try:
|
|
18
|
+
req: PendingRequest = q.get_nowait()
|
|
19
|
+
_show_dialog(req, mgr)
|
|
20
|
+
except asyncio.QueueEmpty:
|
|
21
|
+
pass
|
|
22
|
+
await asyncio.sleep(0.1)
|
|
23
|
+
except asyncio.CancelledError:
|
|
24
|
+
mgr.unsubscribe(q)
|
|
25
|
+
|
|
26
|
+
asyncio.ensure_future(_poller())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _show_dialog(req: PendingRequest, mgr: InterceptManager) -> None:
|
|
30
|
+
with ui.dialog() as dlg, ui.card().classes("w-full").style("min-width:700px"):
|
|
31
|
+
ui.label(f"Intercepted: {req.method} {req.scheme}://{req.host}{req.path}").classes(
|
|
32
|
+
"text-subtitle1 text-weight-bold"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Headers editor
|
|
36
|
+
ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm")
|
|
37
|
+
headers_text = "\n".join(f"{k}: {', '.join(v)}" for k, v in req.headers.items())
|
|
38
|
+
headers_input = (
|
|
39
|
+
ui.textarea(value=headers_text)
|
|
40
|
+
.props("outlined dense rows=8")
|
|
41
|
+
.classes("w-full font-mono text-xs")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Body editor
|
|
45
|
+
ui.label("Body").classes("text-caption text-weight-bold q-mt-sm")
|
|
46
|
+
body_text = req.body.decode(errors="replace")
|
|
47
|
+
body_input = (
|
|
48
|
+
ui.textarea(value=body_text)
|
|
49
|
+
.props("outlined dense rows=6")
|
|
50
|
+
.classes("w-full font-mono text-xs")
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _forward() -> None:
|
|
54
|
+
# Parse edited headers back
|
|
55
|
+
edited_headers: dict[str, list[str]] = {}
|
|
56
|
+
for line in headers_input.value.splitlines():
|
|
57
|
+
if ":" in line:
|
|
58
|
+
k, _, v = line.partition(":")
|
|
59
|
+
edited_headers.setdefault(k.strip().lower(), []).append(v.strip())
|
|
60
|
+
edited_body = body_input.value.encode()
|
|
61
|
+
mgr.forward(req.id, edited_headers, edited_body)
|
|
62
|
+
dlg.close()
|
|
63
|
+
ui.notify("Forwarded", type="positive")
|
|
64
|
+
|
|
65
|
+
def _drop() -> None:
|
|
66
|
+
mgr.drop(req.id)
|
|
67
|
+
dlg.close()
|
|
68
|
+
ui.notify("Dropped", type="warning")
|
|
69
|
+
|
|
70
|
+
with ui.row().classes("q-mt-sm gap-2"):
|
|
71
|
+
ui.button("Forward", icon="send", on_click=_forward).props("color=positive size=sm")
|
|
72
|
+
ui.button("Drop", icon="block", on_click=_drop).props("color=negative size=sm")
|
|
73
|
+
|
|
74
|
+
dlg.open()
|
pypproxy/ui/resender.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from nicegui import ui
|
|
7
|
+
|
|
8
|
+
from pypproxy.store.models import Entry
|
|
9
|
+
from pypproxy.store.store import Store
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_resender_tab(store: Store) -> None:
|
|
13
|
+
"""Render the Resender tab content."""
|
|
14
|
+
state: dict = {"tabs": [], "active": None}
|
|
15
|
+
|
|
16
|
+
with ui.column().classes("w-full h-full"):
|
|
17
|
+
# Tab bar
|
|
18
|
+
with ui.row().classes("items-center gap-2 q-pa-sm").style("border-bottom:1px solid #333"):
|
|
19
|
+
ui.label("Resender").classes("text-subtitle2")
|
|
20
|
+
ui.button("+ New", icon="add", on_click=lambda: _new_tab(state, tab_panels)).props(
|
|
21
|
+
"size=sm flat color=primary"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Tab panels
|
|
25
|
+
with ui.element("div").classes("w-full flex-1 overflow-auto") as tab_panels:
|
|
26
|
+
ui.label("Click '+ New' or drag an entry here to create a resender tab.").classes(
|
|
27
|
+
"text-grey q-pa-md"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# expose method to open entry
|
|
31
|
+
tab_panels._open_entry = lambda e: _open_entry(e, state, tab_panels) # type: ignore[attr-defined]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def open_entry_in_resender(entry: Entry, panel: ui.element) -> None:
|
|
35
|
+
fn = getattr(panel, "_open_entry", None)
|
|
36
|
+
if fn:
|
|
37
|
+
fn(entry)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _new_tab(state: dict, container: ui.element, entry: Entry | None = None) -> None:
|
|
41
|
+
tab_id = len(state["tabs"])
|
|
42
|
+
tab_state: dict = {
|
|
43
|
+
"id": tab_id,
|
|
44
|
+
"method": entry.method if entry else "GET",
|
|
45
|
+
"url": f"{entry.scheme}://{entry.host}{entry.path}" if entry else "https://",
|
|
46
|
+
"headers": "\n".join(f"{k}: {', '.join(v)}" for k, v in (entry.req_headers or {}).items())
|
|
47
|
+
if entry
|
|
48
|
+
else "",
|
|
49
|
+
"body": entry.req_body.decode(errors="replace") if (entry and entry.req_body) else "",
|
|
50
|
+
"result": "",
|
|
51
|
+
}
|
|
52
|
+
state["tabs"].append(tab_state)
|
|
53
|
+
state["active"] = tab_id
|
|
54
|
+
container.clear()
|
|
55
|
+
_render_tabs(state, container)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _open_entry(entry: Entry, state: dict, container: ui.element) -> None:
|
|
59
|
+
_new_tab(state, container, entry)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _render_tabs(state: dict, container: ui.element) -> None:
|
|
63
|
+
container.clear()
|
|
64
|
+
with container:
|
|
65
|
+
for tab in state["tabs"]:
|
|
66
|
+
_render_tab(tab, state, container)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render_tab(tab: dict, state: dict, container: ui.element) -> None:
|
|
70
|
+
with ui.card().classes("w-full q-mb-sm"):
|
|
71
|
+
# Method + URL row
|
|
72
|
+
with ui.row().classes("items-center gap-2 w-full"):
|
|
73
|
+
method_input = (
|
|
74
|
+
ui.select(
|
|
75
|
+
["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
76
|
+
value=tab["method"],
|
|
77
|
+
)
|
|
78
|
+
.props("dense outlined dark")
|
|
79
|
+
.classes("w-28")
|
|
80
|
+
)
|
|
81
|
+
url_input = ui.input(value=tab["url"]).props("dense outlined dark").classes("flex-1")
|
|
82
|
+
send_btn = ui.button("Send", icon="send").props("color=primary size=sm")
|
|
83
|
+
|
|
84
|
+
# Headers
|
|
85
|
+
ui.label("Headers").classes("text-caption text-weight-bold q-mt-xs")
|
|
86
|
+
headers_input = (
|
|
87
|
+
ui.textarea(value=tab["headers"])
|
|
88
|
+
.props("outlined dense rows=4")
|
|
89
|
+
.classes("w-full font-mono text-xs")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Body
|
|
93
|
+
ui.label("Body").classes("text-caption text-weight-bold")
|
|
94
|
+
body_input = (
|
|
95
|
+
ui.textarea(value=tab["body"])
|
|
96
|
+
.props("outlined dense rows=5")
|
|
97
|
+
.classes("w-full font-mono text-xs")
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Result
|
|
101
|
+
ui.label("Response").classes("text-caption text-weight-bold")
|
|
102
|
+
result_area = (
|
|
103
|
+
ui.textarea(value=tab["result"])
|
|
104
|
+
.props("outlined dense rows=8 readonly")
|
|
105
|
+
.classes("w-full font-mono text-xs")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def _send() -> None:
|
|
109
|
+
method = method_input.value
|
|
110
|
+
url = url_input.value
|
|
111
|
+
headers: dict[str, str] = {}
|
|
112
|
+
for line in headers_input.value.splitlines():
|
|
113
|
+
if ":" in line:
|
|
114
|
+
k, _, v = line.partition(":")
|
|
115
|
+
headers[k.strip()] = v.strip()
|
|
116
|
+
body = body_input.value.encode()
|
|
117
|
+
try:
|
|
118
|
+
async with httpx.AsyncClient(verify=False, timeout=30, http2=True) as client:
|
|
119
|
+
resp = await client.request(
|
|
120
|
+
method=method, url=url, headers=headers, content=body
|
|
121
|
+
)
|
|
122
|
+
ct = resp.headers.get("content-type", "")
|
|
123
|
+
try:
|
|
124
|
+
if "json" in ct:
|
|
125
|
+
body_text = json.dumps(resp.json(), indent=2, ensure_ascii=False)
|
|
126
|
+
else:
|
|
127
|
+
body_text = resp.text
|
|
128
|
+
except Exception:
|
|
129
|
+
body_text = resp.text
|
|
130
|
+
result_area.value = (
|
|
131
|
+
f"HTTP {resp.status_code} ({resp.elapsed.total_seconds() * 1000:.0f}ms)\n"
|
|
132
|
+
+ "\n".join(f"{k}: {v}" for k, v in resp.headers.items())
|
|
133
|
+
+ f"\n\n{body_text}"
|
|
134
|
+
)
|
|
135
|
+
ui.notify(f"{resp.status_code}", type="positive")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
result_area.value = f"Error: {e}"
|
|
138
|
+
ui.notify(str(e), type="negative")
|
|
139
|
+
|
|
140
|
+
send_btn.on("click", _send)
|