major mitmweb upgrades

This commit is contained in:
Maximilian Hils 2021-08-18 17:38:22 +02:00
parent 46cf75d01e
commit 65aa0a6ef5
139 changed files with 2588 additions and 2457 deletions

View File

@ -5,7 +5,8 @@ import logging
import os.path import os.path
import re import re
from io import BytesIO from io import BytesIO
from typing import ClassVar, Optional from itertools import islice
from typing import ClassVar, Optional, Sequence, Union
import tornado.escape import tornado.escape
import tornado.web import tornado.web
@ -13,7 +14,7 @@ import tornado.websocket
import mitmproxy.flow import mitmproxy.flow
import mitmproxy.tools.web.master # noqa import mitmproxy.tools.web.master # noqa
from mitmproxy import contentviews from mitmproxy import certs, command, contentviews
from mitmproxy import flowfilter from mitmproxy import flowfilter
from mitmproxy import http from mitmproxy import http
from mitmproxy import io from mitmproxy import io
@ -21,7 +22,27 @@ from mitmproxy import log
from mitmproxy import optmanager from mitmproxy import optmanager
from mitmproxy import version from mitmproxy import version
from mitmproxy.addons import export from mitmproxy.addons import export
from mitmproxy.http import HTTPFlow
from mitmproxy.tcp import TCPFlow, TCPMessage
from mitmproxy.tools.console.common import SYMBOL_MARK, render_marker
from mitmproxy.utils.strutils import always_str from mitmproxy.utils.strutils import always_str
from mitmproxy.websocket import WebSocketMessage
def cert_to_json(certs: Sequence[certs.Cert]) -> Optional[dict]:
if not certs:
return None
cert = certs[0]
return {
"keyinfo": cert.keyinfo,
"sha256": cert.fingerprint().hex(),
"notbefore": int(cert.notbefore.timestamp()),
"notafter": int(cert.notafter.timestamp()),
"serial": str(cert.serial),
"subject": cert.subject,
"issuer": cert.issuer,
"altnames": cert.altnames,
}
def flow_to_json(flow: mitmproxy.flow.Flow) -> dict: def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
@ -37,7 +58,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"is_replay": flow.is_replay, "is_replay": flow.is_replay,
"type": flow.type, "type": flow.type,
"modified": flow.modified(), "modified": flow.modified(),
"marked": flow.marked, "marked": render_marker(flow.marked).replace(SYMBOL_MARK, "🔴") if flow.marked else "",
} }
if flow.client_conn: if flow.client_conn:
@ -46,6 +67,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"peername": flow.client_conn.peername, "peername": flow.client_conn.peername,
"sockname": flow.client_conn.sockname, "sockname": flow.client_conn.sockname,
"tls_established": flow.client_conn.tls_established, "tls_established": flow.client_conn.tls_established,
"cert": cert_to_json(flow.client_conn.certificate_list),
"sni": flow.client_conn.sni, "sni": flow.client_conn.sni,
"cipher": flow.client_conn.cipher, "cipher": flow.client_conn.cipher,
"alpn": always_str(flow.client_conn.alpn, "ascii", "backslashreplace"), "alpn": always_str(flow.client_conn.alpn, "ascii", "backslashreplace"),
@ -53,11 +75,6 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"timestamp_start": flow.client_conn.timestamp_start, "timestamp_start": flow.client_conn.timestamp_start,
"timestamp_tls_setup": flow.client_conn.timestamp_tls_setup, "timestamp_tls_setup": flow.client_conn.timestamp_tls_setup,
"timestamp_end": flow.client_conn.timestamp_end, "timestamp_end": flow.client_conn.timestamp_end,
# Legacy properties
"address": flow.client_conn.peername,
"cipher_name": flow.client_conn.cipher,
"alpn_proto_negotiated": always_str(flow.client_conn.alpn, "ascii", "backslashreplace"),
} }
if flow.server_conn: if flow.server_conn:
@ -67,6 +84,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"sockname": flow.server_conn.sockname, "sockname": flow.server_conn.sockname,
"address": flow.server_conn.address, "address": flow.server_conn.address,
"tls_established": flow.server_conn.tls_established, "tls_established": flow.server_conn.tls_established,
"cert": cert_to_json(flow.server_conn.certificate_list),
"sni": flow.server_conn.sni, "sni": flow.server_conn.sni,
"cipher": flow.server_conn.cipher, "cipher": flow.server_conn.cipher,
"alpn": always_str(flow.server_conn.alpn, "ascii", "backslashreplace"), "alpn": always_str(flow.server_conn.alpn, "ascii", "backslashreplace"),
@ -75,10 +93,6 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"timestamp_tcp_setup": flow.server_conn.timestamp_tcp_setup, "timestamp_tcp_setup": flow.server_conn.timestamp_tcp_setup,
"timestamp_tls_setup": flow.server_conn.timestamp_tls_setup, "timestamp_tls_setup": flow.server_conn.timestamp_tls_setup,
"timestamp_end": flow.server_conn.timestamp_end, "timestamp_end": flow.server_conn.timestamp_end,
# Legacy properties
"ip_address": flow.server_conn.peername,
"source_address": flow.server_conn.sockname,
"alpn_proto_negotiated": always_str(flow.server_conn.alpn, "ascii", "backslashreplace"),
} }
if flow.error: if flow.error:
f["error"] = flow.error.get_state() f["error"] = flow.error.get_state()
@ -87,7 +101,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
content_length: Optional[int] content_length: Optional[int]
content_hash: Optional[str] content_hash: Optional[str]
if flow.request: if flow.request:
if flow.request.raw_content: if flow.request.raw_content is not None:
content_length = len(flow.request.raw_content) content_length = len(flow.request.raw_content)
content_hash = hashlib.sha256(flow.request.raw_content).hexdigest() content_hash = hashlib.sha256(flow.request.raw_content).hexdigest()
else: else:
@ -109,7 +123,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"pretty_host": flow.request.pretty_host, "pretty_host": flow.request.pretty_host,
} }
if flow.response: if flow.response:
if flow.response.raw_content: if flow.response.raw_content is not None:
content_length = len(flow.response.raw_content) content_length = len(flow.response.raw_content)
content_hash = hashlib.sha256(flow.response.raw_content).hexdigest() content_hash = hashlib.sha256(flow.response.raw_content).hexdigest()
else: else:
@ -129,6 +143,18 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
if flow.response.data.trailers: if flow.response.data.trailers:
f["response"]["trailers"] = tuple(flow.response.data.trailers.items(True)) f["response"]["trailers"] = tuple(flow.response.data.trailers.items(True))
if flow.websocket:
f["websocket"] = {
"messages_meta": {
"count": len(flow.websocket.messages),
"timestamp_last": flow.websocket.messages[-1].timestamp if flow.websocket.messages else None,
},
"closed_by_client": flow.websocket.closed_by_client,
"close_code": flow.websocket.close_code,
"close_reason": flow.websocket.close_reason,
"timestamp_end": flow.websocket.timestamp_end,
}
return f return f
@ -147,7 +173,7 @@ class APIError(tornado.web.HTTPError):
class RequestHandler(tornado.web.RequestHandler): class RequestHandler(tornado.web.RequestHandler):
application: "Application" application: "Application"
def write(self, chunk): def write(self, chunk: Union[str, bytes, dict, list]):
# Writing arrays on the top level is ok nowadays. # Writing arrays on the top level is ok nowadays.
# http://flask.pocoo.org/docs/0.11/security/#json-security # http://flask.pocoo.org/docs/0.11/security/#json-security
if isinstance(chunk, list): if isinstance(chunk, list):
@ -217,7 +243,7 @@ class IndexHandler(RequestHandler):
def get(self): def get(self):
token = self.xsrf_token # https://github.com/tornadoweb/tornado/issues/645 token = self.xsrf_token # https://github.com/tornadoweb/tornado/issues/645
assert token assert token
self.render("index.html", static=False, version=version.VERSION) self.render("index.html")
class FilterHelp(RequestHandler): class FilterHelp(RequestHandler):
@ -278,14 +304,6 @@ class DumpFlows(RequestHandler):
bio.close() bio.close()
class ExportFlow(RequestHandler):
def post(self, flow_id, format):
out = export.formats[format](self.flow)
self.write({
"export": always_str(out, "utf8", "backslashreplace")
})
class ClearAll(RequestHandler): class ClearAll(RequestHandler):
def post(self): def post(self):
self.view.clear() self.view.clear()
@ -329,12 +347,12 @@ class FlowHandler(RequestHandler):
self.view.remove([self.flow]) self.view.remove([self.flow])
def put(self, flow_id): def put(self, flow_id):
flow = self.flow flow: mitmproxy.flow.Flow = self.flow
flow.backup() flow.backup()
try: try:
for a, b in self.json.items(): for a, b in self.json.items():
if a == "request" and hasattr(flow, "request"): if a == "request" and hasattr(flow, "request"):
request = flow.request request: mitmproxy.http.Request = flow.request
for k, v in b.items(): for k, v in b.items():
if k in ["method", "scheme", "host", "path", "http_version"]: if k in ["method", "scheme", "host", "path", "http_version"]:
setattr(request, k, str(v)) setattr(request, k, str(v))
@ -354,7 +372,7 @@ class FlowHandler(RequestHandler):
raise APIError(400, f"Unknown update request.{k}: {v}") raise APIError(400, f"Unknown update request.{k}: {v}")
elif a == "response" and hasattr(flow, "response"): elif a == "response" and hasattr(flow, "response"):
response = flow.response response: mitmproxy.http.Response = flow.response
for k, v in b.items(): for k, v in b.items():
if k in ["msg", "http_version"]: if k in ["msg", "http_version"]:
setattr(response, k, str(v)) setattr(response, k, str(v))
@ -372,6 +390,8 @@ class FlowHandler(RequestHandler):
response.text = v response.text = v
else: else:
raise APIError(400, f"Unknown update response.{k}: {v}") raise APIError(400, f"Unknown update response.{k}: {v}")
elif a == "marked":
flow.marked = b
else: else:
raise APIError(400, f"Unknown update {a}: {b}") raise APIError(400, f"Unknown update {a}: {b}")
except APIError: except APIError:
@ -409,9 +429,6 @@ class FlowContent(RequestHandler):
def get(self, flow_id, message): def get(self, flow_id, message):
message = getattr(self.flow, message) message = getattr(self.flow, message)
if not message.raw_content:
raise APIError(400, "No content.")
content_encoding = message.headers.get("Content-Encoding", None) content_encoding = message.headers.get("Content-Encoding", None)
if content_encoding: if content_encoding:
content_encoding = re.sub(r"[^\w]", "", content_encoding) content_encoding = re.sub(r"[^\w]", "", content_encoding)
@ -436,40 +453,88 @@ class FlowContent(RequestHandler):
class FlowContentView(RequestHandler): class FlowContentView(RequestHandler):
def get(self, flow_id, message, content_view): def message_to_json(
message = getattr(self.flow, message) self,
viewname: str,
message: Union[http.Message, TCPMessage, WebSocketMessage],
flow: Union[HTTPFlow, TCPFlow],
max_lines: Optional[int] = None
):
description, lines, error = contentviews.get_message_content_view(viewname, message, flow)
if error:
self.master.log.error(error)
if max_lines:
lines = islice(lines, max_lines)
description, lines, error = contentviews.get_message_content_view( return dict(
content_view.replace('_', ' '), message, self.flow
)
# if error:
# add event log
self.write(dict(
lines=list(lines), lines=list(lines),
description=description description=description,
)) )
def get(self, flow_id, message, content_view):
flow = self.flow
assert isinstance(flow, (HTTPFlow, TCPFlow))
if self.request.arguments.get("lines"):
max_lines = int(self.request.arguments["lines"][0])
else:
max_lines = None
if message == "messages":
if isinstance(flow, HTTPFlow) and flow.websocket:
messages = flow.websocket.messages
elif isinstance(flow, TCPFlow):
messages = flow.messages
else:
raise APIError(400, f"This flow has no messages.")
msgs = []
for m in messages:
d = self.message_to_json(content_view, m, flow, max_lines)
d["from_client"] = m.from_client
d["timestamp"] = m.timestamp
msgs.append(d)
if max_lines:
max_lines -= len(d["lines"])
if max_lines <= 0:
break
self.write(msgs)
else:
message = getattr(self.flow, message)
self.write(self.message_to_json(content_view, message, flow, max_lines))
class Commands(RequestHandler): class Commands(RequestHandler):
def get(self): def get(self) -> None:
commands = {} commands = {}
for (name, command) in self.master.commands.commands.items(): for (name, cmd) in self.master.commands.commands.items():
commands[name] = { commands[name] = {
"args": [], "help": cmd.help,
"signature_help": command.signature_help(), "parameters": [
"description": command.help {
"name": param.name,
"type": command.typename(param.type),
"kind": str(param.kind),
}
for param in cmd.parameters
],
"return_type": command.typename(cmd.return_type) if cmd.return_type else None,
"signature_help": cmd.signature_help(),
} }
for parameter in command.parameters: self.write(commands)
commands[name]["args"].append(parameter.name)
self.write({"commands": commands})
def post(self):
result = self.master.commands.execute(self.json["command"]) class ExecuteCommand(RequestHandler):
if result is None: def post(self, cmd: str):
self.write({"result": ""}) # TODO: We should parse query strings here, this API is painful.
return try:
self.write({"result": result, "type": type(result).__name__, "history": self.master.commands.execute("commands.history.get")}) args = self.json['arguments']
except APIError:
args = []
result = self.master.commands.call_strings(cmd, args)
self.write({
"value": result,
"type": command.typename(type(result)) if result is not None else "none"
})
class Events(RequestHandler): class Events(RequestHandler):
@ -512,7 +577,7 @@ class Conf(RequestHandler):
conf = { conf = {
"static": False, "static": False,
"version": version.VERSION, "version": version.VERSION,
"contentViews": [v.name for v in contentviews.views] "contentViews": [v.name for v in contentviews.views if v.name != "Query"]
} }
self.write(f"MITMWEB_CONF = {json.dumps(conf)};") self.write(f"MITMWEB_CONF = {json.dumps(conf)};")
self.set_header("content-type", "application/javascript") self.set_header("content-type", "application/javascript")
@ -542,6 +607,7 @@ class Application(tornado.web.Application):
(r"/filter-help(?:\.json)?", FilterHelp), (r"/filter-help(?:\.json)?", FilterHelp),
(r"/updates", ClientConnection), (r"/updates", ClientConnection),
(r"/commands(?:\.json)?", Commands), (r"/commands(?:\.json)?", Commands),
(r"/commands/(?P<cmd>[a-z.]+)", ExecuteCommand),
(r"/events(?:\.json)?", Events), (r"/events(?:\.json)?", Events),
(r"/flows(?:\.json)?", Flows), (r"/flows(?:\.json)?", Flows),
(r"/flows/dump", DumpFlows), (r"/flows/dump", DumpFlows),
@ -553,10 +619,9 @@ class Application(tornado.web.Application):
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/duplicate", DuplicateFlow), (r"/flows/(?P<flow_id>[0-9a-f\-]+)/duplicate", DuplicateFlow),
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/replay", ReplayFlow), (r"/flows/(?P<flow_id>[0-9a-f\-]+)/replay", ReplayFlow),
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/revert", RevertFlow), (r"/flows/(?P<flow_id>[0-9a-f\-]+)/revert", RevertFlow),
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/export/(?P<format>[a-z][a-z_]+).json", ExportFlow), (r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/content.data", FlowContent),
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response)/content.data", FlowContent),
( (
r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response)/content/(?P<content_view>[0-9a-zA-Z\-\_]+)(?:\.json)?", r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/content/(?P<content_view>[0-9a-zA-Z\-\_%]+)(?:\.json)?",
FlowContentView), FlowContentView),
(r"/clear", ClearAll), (r"/clear", ClearAll),
(r"/options(?:\.json)?", Options), (r"/options(?:\.json)?", Options),

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -265,6 +265,18 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
"description": "Raw" "description": "Raw"
} }
def test_commands(self):
resp = self.fetch("/commands")
assert resp.code == 200
assert get_json(resp)["set"]["help"]
def test_command_execute(self):
resp = self.fetch("/commands/unknown", method="POST")
assert resp.code == 500
resp = self.fetch("/commands/commands.history.get", method="POST")
assert resp.code == 200
assert get_json(resp) == ["unknown", "commands.history.get"]
def test_events(self): def test_events(self):
resp = self.fetch("/events") resp = self.fetch("/events")
assert resp.code == 200 assert resp.code == 200
@ -368,14 +380,14 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
print("/** Auto-generated by test_app.py:TestApp.test_generate_options_js */") print("/** Auto-generated by test_app.py:TestApp.test_generate_options_js */")
print("export interface OptionsState {") print("export interface OptionsState {")
for _, opt in m.options.items(): for _, opt in sorted(m.options.items()):
print(f" {opt.name}: {ts_type(opt.typespec)}") print(f" {opt.name}: {ts_type(opt.typespec)}")
print("}") print("}")
print("") print("")
print("export type Option = keyof OptionsState") print("export type Option = keyof OptionsState")
print("") print("")
print("export const defaultState: OptionsState = {") print("export const defaultState: OptionsState = {")
for _, opt in m.options.items(): for _, opt in sorted(m.options.items()):
print(f" {opt.name}: {json.dumps(opt.default)},".replace(": null", ": undefined")) print(f" {opt.name}: {json.dumps(opt.default)},".replace(": null", ": undefined"))
print("}") print("}")

View File

@ -43,7 +43,7 @@ function styles_app_dev() {
function esbuild(dev) { function esbuild(dev) {
return gulp.src('src/js/app.jsx').pipe( return gulp.src('src/js/app.tsx').pipe(
gulpEsbuild({ gulpEsbuild({
outfile: 'app.js', outfile: 'app.js',
sourcemap: true, sourcemap: true,

View File

@ -1,11 +1,6 @@
//TODO: Move into some utils
.monospace() {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
}
.flow-detail { .flow-detail {
width: 100%; width: 100%;
overflow:hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -15,45 +10,52 @@
section { section {
overflow-y: scroll; overflow-y: scroll;
>article{ flex: 1;
overflow: auto; padding: 5px 12px 10px;
padding: 5px 12px 0;
} > footer {
>footer {
box-shadow: 0 0 3px gray; box-shadow: 0 0 3px gray;
padding: 2px; padding: 2px;
margin: 0; margin: 0;
height:23px; height: 23px;
} }
} }
section.detail, section.error{
overflow: auto;
padding: 5px 12px 0;
}
.first-line { .first-line {
.monospace(); font-family: @font-family-monospace;
background-color: #428bca; background-color: #428bca;
color: white; color: white;
margin: 0 -8px; margin: 0 -8px 2px;
padding: 4px 8px; padding: 4px 8px;
border-radius: 5px; border-radius: 5px;
word-break: break-all; word-break: break-all;
max-height: 100px; max-height: 100px;
overflow-y: auto; overflow-y: auto;
.inline-input.editable {
border-color: rgba(255,255,255,0.5);
}
} }
.request-line {
margin-bottom: 2px; .contentview {
margin: 0 -12px;
padding: 0 12px;
.controls {
display: flex;
align-items: center;
h5 {
flex: 1;
font-size: 12px;
font-weight: bold;
margin: 10px 0;
}
}
pre button:not(:only-child) {
margin-top: 6px;
}
} }
hr { hr {
margin: 0 0 5px; margin: 0;
} }
} }
@ -64,30 +66,40 @@
padding: 0 3px; padding: 0 3px;
border: solid transparent 1px; border: solid transparent 1px;
&.editable {
border-color: #ccc; &:hover {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 1.25%), 0 2px 4px rgba(0, 0, 0, 5%), 0 2px 6px rgba(0, 0, 0, 2.5%);
background-color: rgba(255, 255, 255, 0.1);
}
&[placeholder]:empty:not(:focus-visible):before {
content: attr(placeholder);
color: lightgray;
font-style: italic;
} }
&[contenteditable] { &[contenteditable] {
outline-width: 0;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 5%), 0 2px 4px rgba(0, 0, 0, 20%), 0 2px 6px rgba(0, 0, 0, 10%);
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
&.has-warning { &.has-warning {
color: rgb(255, 184, 184); color: rgb(255, 184, 184);
} }
&.has-success { &.has-success {
//color: green; //color: green;
} }
} }
} }
.view-all-content-btn{
float: right;
margin-bottom: 12px;
}
.flow-detail table { .flow-detail table {
.monospace(); td:nth-child(2) {
font-family: @font-family-monospace;
width: 70%;
}
width: 100%; width: 100%;
table-layout: fixed; table-layout: fixed;
word-break: break-all; word-break: break-all;
@ -109,34 +121,45 @@
.connection-table { .connection-table {
td:first-child { td:first-child {
width: 50%;
padding-right: 1em; padding-right: 1em;
} }
} }
.header-table { .headers, .trailers {
td { .kv-row {
line-height: 1.3em; margin-bottom: .3em;
} max-height: 12.4ex;
.header-name { overflow-y: auto;
width: 33%;
}
.header-value {
} }
// This exists so that you can copy .kv-key {
// and paste headers out of mitmweb. font-weight: bold;
.header-colon { }
position: absolute;
opacity: 0; .kv-value {
font-family: @font-family-monospace;
} }
.inline-input { .inline-input {
display: inline-block; background-color: white;
width: 100%;
height: 100%;
} }
.kv-add-row {
opacity: 0;
color: #666;
position: absolute;
bottom: 4px;
right: 4px;
transition: all 100ms ease-in-out;
}
&:hover .kv-add-row {
opacity: 1;
}
position: relative;
min-height: 2ex;
overflow-wrap: break-word;
} }
.connection-table, .timing-table { .connection-table, .timing-table {
@ -146,3 +169,23 @@
white-space: nowrap; white-space: nowrap;
} }
} }
dl.cert-attributes {
display: flex;
flex-flow: row;
flex-wrap: wrap;
margin-bottom: 0;
dt, dd {
text-overflow: ellipsis;
overflow: hidden;
}
dt {
flex: 0 0 2em;
}
dd {
flex: 0 0 calc(100% - 2em);
}
}

View File

@ -111,8 +111,12 @@
} }
.col-path { .col-path {
.marker {
}
.fa { .fa {
margin-left: 0; margin-left: 0;
font-size: 16px;
} }
.fa-repeat { .fa-repeat {

View File

@ -1,6 +1,7 @@
.flowview-image { .flowview-image {
text-align: center; text-align: center;
padding: 10px 0;
img { img {
max-width: 100%; max-width: 100%;

View File

@ -1,8 +1,8 @@
footer { footer {
box-shadow: 0 -1px 3px lightgray; box-shadow: 0 -1px 3px lightgray;
padding: 0px 10px 3px; padding: 0 0 4px 3px;
.label { .label {
margin-right: 3px; margin-right: 3px;
} }
} }

View File

@ -31,7 +31,10 @@ header {
height: @menu-height - @menu-legend-height; height: @menu-height - @menu-legend-height;
display: flow-root; display: flow-root;
> .btn { > a {
display: inline-block;
}
> .btn, > a > .btn {
height: @menu-height - @menu-legend-height; height: @menu-height - @menu-legend-height;
text-align: center; text-align: center;
margin: 0 1px; margin: 0 1px;

View File

@ -43,4 +43,8 @@
.resource-icon-redirect { .resource-icon-redirect {
background-image: url(images/resourceRedirectIcon.png); background-image: url(images/resourceRedirectIcon.png);
} }
.resource-icon-websocket {
background-image: url(images/resourceWebSocketIcon.png);
}

View File

@ -38,12 +38,7 @@
.nav-tabs-sm { .nav-tabs-sm {
> a { > a {
padding: 0px 7px; padding: 0 7px;
margin: 2px 2px -1px; margin: 2px 2px -1px;
} }
> a.nav-action {
float: right;
padding: 0;
margin: 1px 0 0px;
}
} }

View File

@ -3,3 +3,5 @@
@navbar-default-color: #303030; @navbar-default-color: #303030;
@navbar-default-bg: #ffffff; @navbar-default-bg: #ffffff;
@navbar-default-border: #e0e0e0; @navbar-default-border: #e0e0e0;
@font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
@font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import CommandBar from '../../../components/CommandBar' import CommandBar from '../../../components/CommandBar'
import { render } from "../../test-utils" import { render } from "../../test-utils"
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
@ -13,4 +13,4 @@ test('CommandBar Component', async () => {
await waitFor(() => { await waitFor(() => {
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
}) })

View File

@ -1,6 +1,6 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import withContentLoader from '../../../components/ContentView/ContentLoader' import withContentLoader from '../../../components/contentviews/useContent'
import { TFlow } from '../../ducks/tutils' import { TFlow } from '../../ducks/tutils'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import mockXMLHttpRequest from 'mock-xmlhttprequest' import mockXMLHttpRequest from 'mock-xmlhttprequest'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import ContentViewOptions from '../../../components/ContentView/ContentViewOptions' import ContentViewOptions from '../../../components/ContentView/ContentViewOptions'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import DownloadContentButton from '../../../components/ContentView/DownloadContentButton' import DownloadContentButton from '../../../components/ContentView/DownloadContentButton'
import { TFlow } from '../../ducks/tutils' import { TFlow } from '../../ducks/tutils'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import { ContentEmpty, ContentMissing, ContentTooLarge } from '../../../components/ContentView/MetaViews' import { ContentEmpty, ContentMissing, ContentTooLarge } from '../../../components/ContentView/MetaViews'
import { TFlow } from '../../ducks/tutils' import { TFlow } from '../../ducks/tutils'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import ShowFullContentButton from '../../../components/ContentView/ShowFullContentButton' import ShowFullContentButton from '../../../components/ContentView/ShowFullContentButton'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import UploadContentButton from '../../../components/ContentView/UploadContentButton' import UploadContentButton from '../../../components/ContentView/UploadContentButton'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import ViewSelector from '../../../components/ContentView/ViewSelector' import ViewSelector from '../../../components/ContentView/ViewSelector'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import ContentView from '../../components/ContentView' import ContentView from '../../components/ContentView'
import { TStore, TFlow } from '../ducks/tutils' import { TStore, TFlow } from '../ducks/tutils'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import EventLogList from '../../../components/EventLog/EventList' import EventLogList from '../../../components/EventLog/EventList'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'

View File

@ -1,6 +1,6 @@
jest.mock('../../components/EventLog/EventList') jest.mock('../../components/EventLog/EventList')
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import EventLog, {PureEventLog} from '../../components/EventLog' import EventLog, {PureEventLog} from '../../components/EventLog'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'

View File

@ -1,15 +1,15 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import { import {
IconColumn, icon,
MethodColumn, method,
PathColumn, path,
QuickActionsColumn, quickactions,
SizeColumn, size,
StatusColumn, status,
TimeColumn, time,
TimeStampColumn, timestamp,
TLSColumn tls
} from '../../../components/FlowTable/FlowColumns' } from '../../../components/FlowTable/FlowColumns'
import {TFlow, TStore} from '../../ducks/tutils' import {TFlow, TStore} from '../../ducks/tutils'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
@ -18,109 +18,109 @@ describe('Flowcolumns Components', () => {
let tflow = TFlow() let tflow = TFlow()
it('should render TLSColumn', () => { it('should render TLSColumn', () => {
let tlsColumn = renderer.create(<TLSColumn flow={tflow}/>), let tlsColumn = renderer.create(<tls flow={tflow}/>),
tree = tlsColumn.toJSON() tree = tlsColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
it('should render IconColumn', () => { it('should render IconColumn', () => {
let tflow = TFlow(), let tflow = TFlow(),
iconColumn = renderer.create(<IconColumn flow={tflow}/>), iconColumn = renderer.create(<icon flow={tflow}/>),
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
// plain // plain
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// not modified // not modified
tflow.response.status_code = 304 tflow.response.status_code = 304
iconColumn = renderer.create(<IconColumn flow={tflow}/>) iconColumn = renderer.create(<icon flow={tflow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// redirect // redirect
tflow.response.status_code = 302 tflow.response.status_code = 302
iconColumn = renderer.create(<IconColumn flow={tflow}/>) iconColumn = renderer.create(<icon flow={tflow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// image // image
let imageFlow = TFlow() let imageFlow = TFlow()
imageFlow.response.headers = [['Content-Type', 'image/jpeg']] imageFlow.response.headers = [['Content-Type', 'image/jpeg']]
iconColumn = renderer.create(<IconColumn flow={imageFlow}/>) iconColumn = renderer.create(<icon flow={imageFlow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// javascript // javascript
let jsFlow = TFlow() let jsFlow = TFlow()
jsFlow.response.headers = [['Content-Type', 'application/x-javascript']] jsFlow.response.headers = [['Content-Type', 'application/x-javascript']]
iconColumn = renderer.create(<IconColumn flow={jsFlow}/>) iconColumn = renderer.create(<icon flow={jsFlow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// css // css
let cssFlow = TFlow() let cssFlow = TFlow()
cssFlow.response.headers = [['Content-Type', 'text/css']] cssFlow.response.headers = [['Content-Type', 'text/css']]
iconColumn = renderer.create(<IconColumn flow={cssFlow}/>) iconColumn = renderer.create(<icon flow={cssFlow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// html // html
let htmlFlow = TFlow() let htmlFlow = TFlow()
htmlFlow.response.headers = [['Content-Type', 'text/html']] htmlFlow.response.headers = [['Content-Type', 'text/html']]
iconColumn = renderer.create(<IconColumn flow={htmlFlow}/>) iconColumn = renderer.create(<icon flow={htmlFlow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// default // default
let fooFlow = TFlow() let fooFlow = TFlow()
fooFlow.response.headers = [['Content-Type', 'foo']] fooFlow.response.headers = [['Content-Type', 'foo']]
iconColumn = renderer.create(<IconColumn flow={fooFlow}/>) iconColumn = renderer.create(<icon flow={fooFlow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
// no response // no response
tflow.response = null tflow.response = null
iconColumn = renderer.create(<IconColumn flow={tflow}/>) iconColumn = renderer.create(<icon flow={tflow}/>)
tree = iconColumn.toJSON() tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
it('should render pathColumn', () => { it('should render pathColumn', () => {
let tflow = TFlow(), let tflow = TFlow(),
pathColumn = renderer.create(<PathColumn flow={tflow}/>), pathColumn = renderer.create(<path flow={tflow}/>),
tree = pathColumn.toJSON() tree = pathColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
tflow.error.msg = 'Connection killed.' tflow.error.msg = 'Connection killed.'
tflow.intercepted = true tflow.intercepted = true
pathColumn = renderer.create(<PathColumn flow={tflow}/>) pathColumn = renderer.create(<path flow={tflow}/>)
tree = pathColumn.toJSON() tree = pathColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
it('should render MethodColumn', () => { it('should render MethodColumn', () => {
let methodColumn = renderer.create(<MethodColumn flow={tflow}/>), let methodColumn = renderer.create(<method flow={tflow}/>),
tree = methodColumn.toJSON() tree = methodColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
it('should render StatusColumn', () => { it('should render StatusColumn', () => {
let statusColumn = renderer.create(<StatusColumn flow={tflow}/>), let statusColumn = renderer.create(<status flow={tflow}/>),
tree = statusColumn.toJSON() tree = statusColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
it('should render SizeColumn', () => { it('should render SizeColumn', () => {
let sizeColumn = renderer.create(<SizeColumn flow={tflow}/>), let sizeColumn = renderer.create(<size flow={tflow}/>),
tree = sizeColumn.toJSON() tree = sizeColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
it('should render TimeColumn', () => { it('should render TimeColumn', () => {
let tflow = TFlow(), let tflow = TFlow(),
timeColumn = renderer.create(<TimeColumn flow={tflow}/>), timeColumn = renderer.create(<time flow={tflow}/>),
tree = timeColumn.toJSON() tree = timeColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
tflow.response = null tflow.response = null
timeColumn = renderer.create(<TimeColumn flow={tflow}/>) timeColumn = renderer.create(<time flow={tflow}/>)
tree = timeColumn.toJSON() tree = timeColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
it('should render TimeStampColumn', () => { it('should render TimeStampColumn', () => {
let timeStampColumn = renderer.create(<TimeStampColumn flow={tflow}/>), let timeStampColumn = renderer.create(<timestamp flow={tflow}/>),
tree = timeStampColumn.toJSON() tree = timeStampColumn.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
@ -129,7 +129,7 @@ describe('Flowcolumns Components', () => {
let store = TStore(), let store = TStore(),
provider = renderer.create( provider = renderer.create(
<Provider store={store}> <Provider store={store}>
<QuickActionsColumn flow={tflow}/> <quickactions flow={tflow}/>
</Provider>), </Provider>),
tree = provider.toJSON() tree = provider.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import FlowRow from '../../../components/FlowTable/FlowRow' import FlowRow from '../../../components/FlowTable/FlowRow'
import {testState} from '../../ducks/tutils' import {testState} from '../../ducks/tutils'
import {fireEvent, render, screen} from "../../test-utils"; import {fireEvent, render, screen} from "../../test-utils";

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import FlowTableHead from '../../../components/FlowTable/FlowTableHead' import FlowTableHead from '../../../components/FlowTable/FlowTableHead'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
import {TStore} from '../../ducks/tutils' import {TStore} from '../../ducks/tutils'
@ -23,7 +23,7 @@ test("FlowTableHead Component", async () => {
fireEvent.click(screen.getByText("Size")) fireEvent.click(screen.getByText("Size"))
expect(store.getActions()).toStrictEqual([ expect(store.getActions()).toStrictEqual([
setSort("SizeColumn", false) setSort("size", false)
] ]
) )
}) })

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import {PureFlowTable as FlowTable} from '../../components/FlowTable' import {PureFlowTable as FlowTable} from '../../components/FlowTable'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'

View File

@ -1,6 +1,6 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import Details, { TimeStamp, ConnectionInfo, CertificateInfo, Timing } from '../../../components/FlowView/Details' import Connection, { TimeStamp, ConnectionInfo, CertificateInfo, Timing } from '../../../components/FlowView/Connection'
import { TFlow } from '../../ducks/tutils' import { TFlow } from '../../ducks/tutils'
let tflow = TFlow() let tflow = TFlow()
@ -43,7 +43,7 @@ describe('Timing Component', () => {
describe('Details Component', () => { describe('Details Component', () => {
it('should render correctly', () => { it('should render correctly', () => {
let details = renderer.create(<Details flow={tflow}/>), let details = renderer.create(<Connection flow={tflow}/>),
tree = details.toJSON() tree = details.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
@ -53,7 +53,6 @@ describe('Details Component', () => {
tflowServerAddressNull.server_conn.address = null tflowServerAddressNull.server_conn.address = null
tflowServerAddressNull.server_conn.ip_address = null tflowServerAddressNull.server_conn.ip_address = null
tflowServerAddressNull.server_conn.alpn_proto_negotiated = null
tflowServerAddressNull.server_conn.sni = null tflowServerAddressNull.server_conn.sni = null
tflowServerAddressNull.server_conn.ssl_established = false tflowServerAddressNull.server_conn.ssl_established = false
tflowServerAddressNull.server_conn.tls_version = null tflowServerAddressNull.server_conn.tls_version = null
@ -61,8 +60,8 @@ describe('Details Component', () => {
tflowServerAddressNull.server_conn.timestamp_ssl_setup = null tflowServerAddressNull.server_conn.timestamp_ssl_setup = null
tflowServerAddressNull.server_conn.timestamp_start = null tflowServerAddressNull.server_conn.timestamp_start = null
tflowServerAddressNull.server_conn.timestamp_end = null tflowServerAddressNull.server_conn.timestamp_end = null
let details = renderer.create(<Details flow={tflowServerAddressNull}/>), let details = renderer.create(<Connection flow={tflowServerAddressNull}/>),
tree = details.toJSON() tree = details.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })

View File

@ -1,8 +1,8 @@
import React from 'react' import * as React from "react"
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import Headers, { HeaderEditor } from '../../../components/FlowView/Headers' import KeyValueListEditor, { HeaderEditor } from '../../../components/editors/KeyValueListEditor'
import { Key } from '../../../utils' import { Key } from '../../../utils'
describe('HeaderEditor Component', () => { describe('HeaderEditor Component', () => {
@ -54,12 +54,12 @@ describe('Headers Component', () => {
let changeFn = jest.fn(), let changeFn = jest.fn(),
mockMessage = { headers: [['k1', 'v1'], ['k2', '']] } mockMessage = { headers: [['k1', 'v1'], ['k2', '']] }
it('should handle correctly', () => { it('should handle correctly', () => {
let headers = renderer.create(<Headers onChange={changeFn} message={mockMessage}/>), let headers = renderer.create(<KeyValueListEditor onChange={changeFn} message={mockMessage}/>),
tree = headers.toJSON() tree = headers.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
let headers = TestUtils.renderIntoDocument(<Headers onChange={changeFn} message={mockMessage}/>), let headers = TestUtils.renderIntoDocument(<KeyValueListEditor onChange={changeFn} message={mockMessage}/>),
headerEditors = TestUtils.scryRenderedComponentsWithType(headers, HeaderEditor), headerEditors = TestUtils.scryRenderedComponentsWithType(headers, HeaderEditor),
key1Editor = headerEditors[0], key1Editor = headerEditors[0],
value1Editor = headerEditors[1], value1Editor = headerEditors[1],
@ -123,7 +123,7 @@ describe('Headers Component', () => {
it('should not delete last row when handle remove', () => { it('should not delete last row when handle remove', () => {
mockMessage = { headers: [['', '']] } mockMessage = { headers: [['', '']] }
headers = TestUtils.renderIntoDocument(<Headers onChange={changeFn} message={mockMessage}/>) headers = TestUtils.renderIntoDocument(<KeyValueListEditor onChange={changeFn} message={mockMessage}/>)
headers.onChange(0, 0, '') headers.onChange(0, 0, '')
expect(changeFn).toBeCalledWith([['Name', 'Value']]) expect(changeFn).toBeCalledWith([['Name', 'Value']])

View File

@ -1,15 +1,15 @@
jest.mock('../../../components/ContentView', () => () => null) jest.mock('../../../components/ContentView', () => () => null)
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import {ErrorView, Request, Response} from '../../../components/FlowView/Messages' import {ErrorView, Request, Response} from '../../../components/FlowView/HttpMessages'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
import {TFlow, TStore} from '../../ducks/tutils' import {TFlow, TStore} from '../../ducks/tutils'
import {updateEdit} from '../../../ducks/ui/flow' import {updateEdit} from '../../../ducks/ui/flow'
import {parseUrl} from '../../../flow/utils' import {parseUrl} from '../../../flow/utils'
import ContentView from '../../../components/ContentView' import ContentView from '../../../components/ContentView'
import ContentViewOptions from '../../../components/ContentView/ContentViewOptions' import ContentViewOptions from '../../../components/ContentView/ContentViewOptions'
import Headers from '../../../components/FlowView/Headers' import KeyValueListEditor from '../../../components/editors/KeyValueListEditor'
import ValueEditor from '../../../components/ValueEditor/ValueEditor' import ValueEditor from '../../../components/editors/ValueEditor'
global.fetch = jest.fn() global.fetch = jest.fn()
@ -59,7 +59,7 @@ describe('Request Component', () => {
}) })
it('should handle change on flow request header', () => { it('should handle change on flow request header', () => {
let headers = provider.root.findAllByType(Headers).filter(headers => headers.props.type === 'headers')[0] let headers = provider.root.findAllByType(KeyValueListEditor).filter(headers => headers.props.type === 'headers')[0]
headers.props.onChange('foo') headers.props.onChange('foo')
expect(store.getActions()).toEqual([updateEdit({request: {headers: 'foo'}})]) expect(store.getActions()).toEqual([updateEdit({request: {headers: 'foo'}})])
}) })
@ -118,7 +118,7 @@ describe('Response Component', () => {
}) })
it('should handle change on flow response headers', () => { it('should handle change on flow response headers', () => {
let headers = provider.root.findAllByType(Headers).filter(headers => headers.props.type === 'headers')[0] let headers = provider.root.findAllByType(KeyValueListEditor).filter(headers => headers.props.type === 'headers')[0]
headers.props.onChange('foo') headers.props.onChange('foo')
expect(store.getActions()).toEqual([updateEdit({response: {headers: 'foo'}})]) expect(store.getActions()).toEqual([updateEdit({response: {headers: 'foo'}})])
}) })

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import Nav, { NavAction } from '../../../components/FlowView/Nav' import Nav, { NavAction } from '../../../components/FlowView/Nav'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import ToggleEdit from '../../../components/FlowView/ToggleEdit' import ToggleEdit from '../../../components/FlowView/ToggleEdit'
import {TFlow} from '../../ducks/tutils' import {TFlow} from '../../ducks/tutils'
import {render} from "../../test-utils" import {render} from "../../test-utils"

View File

@ -19,7 +19,7 @@ exports[`HeaderEditor Component should render correctly 1`] = `
/> />
`; `;
exports[`Headers Component should handle correctly 1`] = ` exports[`KeyValueListEditor Component should handle correctly 1`] = `
<table <table
className="header-table" className="header-table"
> >

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import ConnectionIndicator from '../../../components/Header/ConnectionIndicator' import ConnectionIndicator from '../../../components/Header/ConnectionIndicator'
import * as connectionActions from '../../../ducks/connection' import * as connectionActions from '../../../ducks/connection'
import {render} from "../../test-utils" import {render} from "../../test-utils"

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import FileMenu from '../../../components/Header/FileMenu' import FileMenu from '../../../components/Header/FileMenu'
import {Provider} from "react-redux"; import {Provider} from "react-redux";

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import FilterDocs from '../../../components/Header/FilterDocs' import FilterDocs from '../../../components/Header/FilterDocs'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import FilterInput from '../../../components/Header/FilterInput' import FilterInput from '../../../components/Header/FilterInput'
import FilterDocs from '../../../components/Header/FilterDocs' import FilterDocs from '../../../components/Header/FilterDocs'

View File

@ -1,6 +1,6 @@
jest.mock('../../../flow/utils') jest.mock('../../../flow/utils')
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import FlowMenu from '../../../components/Header/FlowMenu' import FlowMenu from '../../../components/Header/FlowMenu'
import { TFlow, TStore }from '../../ducks/tutils' import { TFlow, TStore }from '../../ducks/tutils'

View File

@ -1,8 +1,8 @@
import React from 'react' import * as React from "react"
import MainMenu from '../../../components/Header/MainMenu' import StartMenu from '../../../components/Header/StartMenu'
import {render} from "../../test-utils" import {render} from "../../test-utils"
test("MainMenu", () => { test("MainMenu", () => {
const {asFragment} = render(<MainMenu/>); const {asFragment} = render(<StartMenu/>);
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}) })

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import {EventlogToggle, MenuToggle, OptionsToggle} from '../../../components/Header/MenuToggle' import {EventlogToggle, MenuToggle, OptionsToggle} from '../../../components/Header/MenuToggle'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import OptionMenu from '../../../components/Header/OptionMenu' import OptionMenu from '../../../components/Header/OptionMenu'

View File

@ -1,6 +1,104 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MainMenu 1`] = ` exports[`MainMenu 1`] = `
<DocumentFragment>
<div
class="main-menu"
>
<div
class="menu-group"
>
<div
class="menu-content"
>
<div
class="filter-input input-group"
>
<span
class="input-group-addon"
>
<i
class="fa fa-fw fa-search"
style="color: black;"
/>
</span>
<input
class="form-control"
placeholder="Search"
type="text"
value="~d address"
/>
</div>
<div
class="filter-input input-group"
>
<span
class="input-group-addon"
>
<i
class="fa fa-fw fa-tag"
style="color: rgb(0, 0, 0);"
/>
</span>
<input
class="form-control"
placeholder="Highlight"
type="text"
value="~u /path"
/>
</div>
</div>
<div
class="menu-legend"
>
Find
</div>
</div>
<div
class="menu-group"
>
<div
class="menu-content"
>
<div
class="filter-input input-group"
>
<span
class="input-group-addon"
>
<i
class="fa fa-fw fa-pause"
style="color: rgb(68, 68, 68);"
/>
</span>
<input
class="form-control"
placeholder="Intercept"
type="text"
value=""
/>
</div>
<button
class="btn-sm btn btn-default"
title="[a]ccept all"
>
<i
class="fa fa-forward text-success"
/>
 Resume All
</button>
</div>
<div
class="menu-legend"
>
Intercept
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`StartMenu 1`] = `
<DocumentFragment> <DocumentFragment>
<div <div
class="main-menu" class="main-menu"

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import Modal from '../../../components/Modal/Modal' import Modal from '../../../components/Modal/Modal'
import {render} from "../../test-utils" import {render} from "../../test-utils"
import {setActiveModal} from "../../../ducks/ui/modal"; import {setActiveModal} from "../../../ducks/ui/modal";

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import { PureOptionDefault } from '../../../components/Modal/OptionModal' import { PureOptionDefault } from '../../../components/Modal/OptionModal'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import { Options, ChoicesOption } from '../../../components/Modal/Option' import { Options, ChoicesOption } from '../../../components/Modal/Option'

View File

@ -1,7 +1,7 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import ValidateEditor from '../../../components/ValueEditor/ValidateEditor' import ValidateEditor from '../../../components/editors/ValidateEditor'
describe('ValidateEditor Component', () => { describe('ValidateEditor Component', () => {
let validateFn = jest.fn( content => content.length == 3), let validateFn = jest.fn( content => content.length == 3),
@ -9,14 +9,14 @@ describe('ValidateEditor Component', () => {
it('should render correctly', () => { it('should render correctly', () => {
let validateEditor = renderer.create( let validateEditor = renderer.create(
<ValidateEditor content="foo" onDone={doneFn} isValid={validateFn}/> <ValidateEditor content="foo" onEditDone={doneFn} isValid={validateFn}/>
), ),
tree = validateEditor.toJSON() tree = validateEditor.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
let validateEditor = TestUtils.renderIntoDocument( let validateEditor = TestUtils.renderIntoDocument(
<ValidateEditor content="foo" onDone={doneFn} isValid={validateFn}/> <ValidateEditor content="foo" onEditDone={doneFn} isValid={validateFn}/>
) )
it('should handle componentWillReceiveProps', () => { it('should handle componentWillReceiveProps', () => {
let mockProps = { let mockProps = {

View File

@ -1,7 +1,7 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import ValueEditor from '../../../components/ValueEditor/ValueEditor' import ValueEditor from '../../../components/editors/ValueEditor'
import { Key } from '../../../utils' import { Key } from '../../../utils'
describe('ValueEditor Component', () => { describe('ValueEditor Component', () => {
@ -9,14 +9,14 @@ describe('ValueEditor Component', () => {
let mockFn = jest.fn() let mockFn = jest.fn()
it ('should render correctly', () => { it ('should render correctly', () => {
let valueEditor = renderer.create( let valueEditor = renderer.create(
<ValueEditor content="foo" onDone={mockFn}/> <ValueEditor content="foo" onEditDone={mockFn}/>
), ),
tree = valueEditor.toJSON() tree = valueEditor.toJSON()
expect(tree).toMatchSnapshot() expect(tree).toMatchSnapshot()
}) })
let valueEditor = TestUtils.renderIntoDocument( let valueEditor = TestUtils.renderIntoDocument(
<ValueEditor content="<script>foo</script>" onDone={mockFn}/> <ValueEditor content="<script>foo</script>" onEditDone={mockFn}/>
) )
it('should handle this.blur', () => { it('should handle this.blur', () => {
valueEditor.input.blur = jest.fn() valueEditor.input.blur = jest.fn()

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import Button from '../../../components/common/Button' import Button from '../../../components/common/Button'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import DocsLink from '../../../components/common/DocsLink' import DocsLink from '../../../components/common/DocsLink'

View File

@ -1,4 +1,4 @@
import React from "react" import * as React from "react";
import Dropdown, {Divider, MenuItem, SubMenu} from '../../../components/common/Dropdown' import Dropdown, {Divider, MenuItem, SubMenu} from '../../../components/common/Dropdown'
import {fireEvent, render, screen, waitFor} from '@testing-library/react' import {fireEvent, render, screen, waitFor} from '@testing-library/react'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import FileChooser from '../../../components/common/FileChooser' import FileChooser from '../../../components/common/FileChooser'
import {render} from '@testing-library/react' import {render} from '@testing-library/react'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import Splitter from '../../../components/common/Splitter' import Splitter from '../../../components/common/Splitter'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import ToggleButton from '../../../components/common/ToggleButton' import ToggleButton from '../../../components/common/ToggleButton'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import renderer from 'react-test-renderer' import renderer from 'react-test-renderer'
import ToggleInputButton from '../../../components/common/ToggleInputButton' import ToggleInputButton from '../../../components/common/ToggleInputButton'
import { Key } from '../../../utils' import { Key } from '../../../utils'

View File

@ -1,4 +1,4 @@
import React from "react" import * as React from "react";
import ReactDOM from "react-dom" import ReactDOM from "react-dom"
import AutoScroll from '../../../components/helpers/AutoScroll' import AutoScroll from '../../../components/helpers/AutoScroll'
import { calcVScroll } from '../../../components/helpers/VirtualScroll' import { calcVScroll } from '../../../components/helpers/VirtualScroll'

View File

@ -1,12 +1,12 @@
import reduceEventLog, * as eventLogActions from '../../ducks/eventLog' import reduceEventLog, * as eventLogActions from '../../ducks/eventLog'
import reduceStore from '../../ducks/utils/store' import reduce from '../../ducks/utils/store'
describe('event log reducer', () => { describe('event log reducer', () => {
it('should return initial state', () => { it('should return initial state', () => {
expect(reduceEventLog(undefined, {})).toEqual({ expect(reduceEventLog(undefined, {})).toEqual({
visible: false, visible: false,
filters: { debug: false, info: true, web: true, warn: true, error: true }, filters: { debug: false, info: true, web: true, warn: true, error: true },
...reduceStore(undefined, {}), ...reduce(undefined, {}),
}) })
}) })
@ -15,7 +15,7 @@ describe('event log reducer', () => {
expect(reduceEventLog(state, eventLogActions.toggleFilter('info'))).toEqual({ expect(reduceEventLog(state, eventLogActions.toggleFilter('info'))).toEqual({
visible: false, visible: false,
filters: { ...state.filters, info: false}, filters: { ...state.filters, info: false},
...reduceStore(state, {}) ...reduce(state, {})
}) })
}) })
@ -24,7 +24,7 @@ describe('event log reducer', () => {
expect(reduceEventLog(state, eventLogActions.toggleVisibility())).toEqual({ expect(reduceEventLog(state, eventLogActions.toggleVisibility())).toEqual({
visible: true, visible: true,
filters: {...state.filters}, filters: {...state.filters},
...reduceStore(undefined, {}) ...reduce(undefined, {})
}) })
}) })

View File

@ -2,7 +2,7 @@ jest.mock('../../utils')
import reduceFlows from "../../ducks/flows" import reduceFlows from "../../ducks/flows"
import * as flowActions from "../../ducks/flows" import * as flowActions from "../../ducks/flows"
import reduceStore from "../../ducks/utils/store" import reduce from "../../ducks/utils/store"
import { fetchApi } from "../../utils" import { fetchApi } from "../../utils"
import { createStore } from "./tutils" import { createStore } from "./tutils"
@ -18,7 +18,7 @@ describe('flow reducer', () => {
filter: null, filter: null,
sort: { column: null, desc: false }, sort: { column: null, desc: false },
selected: [], selected: [],
...reduceStore(undefined, {}) ...reduce(undefined, {})
}) })
}) })

View File

@ -1,8 +1,8 @@
import reduceStore, * as storeActions from '../../../ducks/utils/store' import reduce, * as storeActions from '../../../ducks/utils/store'
describe('store reducer', () => { describe('store reducer', () => {
it('should return initial state', () => { it('should return initial state', () => {
expect(reduceStore(undefined, {})).toEqual({ expect(reduce(undefined, {})).toEqual({
byId: {}, byId: {},
list: [], list: [],
listIndex: {}, listIndex: {},
@ -14,8 +14,8 @@ describe('store reducer', () => {
it('should handle add action', () => { it('should handle add action', () => {
let a = {id: 1}, let a = {id: 1},
b = {id: 9}, b = {id: 9},
state = reduceStore(undefined, {}) state = reduce(undefined, {})
expect(state = reduceStore(state, storeActions.add(a))).toEqual({ expect(state = reduce(state, storeActions.add(a))).toEqual({
byId: { 1: a }, byId: { 1: a },
listIndex: { 1: 0 }, listIndex: { 1: 0 },
list: [ a ], list: [ a ],
@ -23,7 +23,7 @@ describe('store reducer', () => {
viewIndex: { 1: 0 }, viewIndex: { 1: 0 },
}) })
expect(state = reduceStore(state, storeActions.add(b))).toEqual({ expect(state = reduce(state, storeActions.add(b))).toEqual({
byId: { 1: a, 9: b }, byId: { 1: a, 9: b },
listIndex: { 1: 0, 9: 1 }, listIndex: { 1: 0, 9: 1 },
list: [ a, b ], list: [ a, b ],
@ -33,7 +33,7 @@ describe('store reducer', () => {
// add item and sort them // add item and sort them
let c = {id: 0} let c = {id: 0}
expect(reduceStore(state, storeActions.add(c, undefined, expect(reduce(state, storeActions.add(c, undefined,
(a, b) => {return a.id - b.id}))).toEqual({ (a, b) => {return a.id - b.id}))).toEqual({
byId: {...state.byId, 0: c }, byId: {...state.byId, 0: c },
list: [...state.list, c ], list: [...state.list, c ],
@ -46,15 +46,15 @@ describe('store reducer', () => {
it('should not add the item with duplicated id', () => { it('should not add the item with duplicated id', () => {
let a = {id: 1}, let a = {id: 1},
state = reduceStore(undefined, storeActions.add(a)) state = reduce(undefined, storeActions.add(a))
expect(reduceStore(state, storeActions.add(a))).toEqual(state) expect(reduce(state, storeActions.add(a))).toEqual(state)
}) })
it('should handle update action', () => { it('should handle update action', () => {
let a = {id: 1, foo: "foo"}, let a = {id: 1, foo: "foo"},
updated = {...a, foo: "bar"}, updated = {...a, foo: "bar"},
state = reduceStore(undefined, storeActions.add(a)) state = reduce(undefined, storeActions.add(a))
expect(reduceStore(state, storeActions.update(updated))).toEqual({ expect(reduce(state, storeActions.update(updated))).toEqual({
byId: { 1: updated }, byId: { 1: updated },
list: [ updated ], list: [ updated ],
listIndex: { 1: 0 }, listIndex: { 1: 0 },
@ -65,8 +65,8 @@ describe('store reducer', () => {
it('should handle update action with filter', () => { it('should handle update action with filter', () => {
let a = {id: 0}, b = {id: 1}, let a = {id: 0}, b = {id: 1},
state = reduceStore(undefined, storeActions.receive([a, b])) state = reduce(undefined, storeActions.receive([a, b]))
state = reduceStore(state, storeActions.update(b, state = reduce(state, storeActions.update(b,
item => {return item.id != 1})) item => {return item.id != 1}))
expect(state).toEqual({ expect(state).toEqual({
byId: { 0: a, 1: b }, byId: { 0: a, 1: b },
@ -75,7 +75,7 @@ describe('store reducer', () => {
view: [ a ], view: [ a ],
viewIndex: { 0: 0 } viewIndex: { 0: 0 }
}) })
expect(reduceStore(state, storeActions.update(b, expect(reduce(state, storeActions.update(b,
item => {return item.id != 0}))).toEqual({ item => {return item.id != 0}))).toEqual({
byId: { 0: a, 1: b }, byId: { 0: a, 1: b },
list: [ a, b ], list: [ a, b ],
@ -88,8 +88,8 @@ describe('store reducer', () => {
it('should handle update action with sort', () => { it('should handle update action with sort', () => {
let a = {id: 2}, let a = {id: 2},
b = {id: 3}, b = {id: 3},
state = reduceStore(undefined, storeActions.receive([a, b])) state = reduce(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.update(b, undefined, expect(reduce(state, storeActions.update(b, undefined,
(a, b) => {return b.id - a.id}))).toEqual({ (a, b) => {return b.id - a.id}))).toEqual({
// sort by id in descending order // sort by id in descending order
byId: { 2: a, 3: b }, byId: { 2: a, 3: b },
@ -99,8 +99,8 @@ describe('store reducer', () => {
viewIndex: { 2: 1, 3: 0 }, viewIndex: { 2: 1, 3: 0 },
}) })
let state1 = reduceStore(undefined, storeActions.receive([b, a])) let state1 = reduce(undefined, storeActions.receive([b, a]))
expect(reduceStore(state1, storeActions.update(b, undefined, expect(reduce(state1, storeActions.update(b, undefined,
(a, b) => {return a.id - b.id}))).toEqual({ (a, b) => {return a.id - b.id}))).toEqual({
// sort by id in ascending order // sort by id in ascending order
byId: { 2: a, 3: b }, byId: { 2: a, 3: b },
@ -114,8 +114,8 @@ describe('store reducer', () => {
it('should set filter', () => { it('should set filter', () => {
let a = { id: 1 }, let a = { id: 1 },
b = { id: 2 }, b = { id: 2 },
state = reduceStore(undefined, storeActions.receive([a, b])) state = reduce(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.setFilter( expect(reduce(state, storeActions.setFilter(
item => {return item.id != 1} item => {return item.id != 1}
))).toEqual({ ))).toEqual({
byId: { 1 :a, 2: b }, byId: { 1 :a, 2: b },
@ -129,8 +129,8 @@ describe('store reducer', () => {
it('should set sort', () => { it('should set sort', () => {
let a = { id: 1 }, let a = { id: 1 },
b = { id: 2 }, b = { id: 2 },
state = reduceStore(undefined, storeActions.receive([a, b])) state = reduce(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.setSort( expect(reduce(state, storeActions.setSort(
(a, b) => { return b.id - a.id } (a, b) => { return b.id - a.id }
))).toEqual({ ))).toEqual({
byId: { 1: a, 2: b }, byId: { 1: a, 2: b },
@ -143,8 +143,8 @@ describe('store reducer', () => {
it('should handle remove action', () => { it('should handle remove action', () => {
let a = { id: 1 }, b = { id: 2}, let a = { id: 1 }, b = { id: 2},
state = reduceStore(undefined, storeActions.receive([a, b])) state = reduce(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.remove(1))).toEqual({ expect(reduce(state, storeActions.remove(1))).toEqual({
byId: { 2: b }, byId: { 2: b },
list: [ b ], list: [ b ],
listIndex: { 2: 0 }, listIndex: { 2: 0 },
@ -152,13 +152,13 @@ describe('store reducer', () => {
viewIndex: { 2: 0 }, viewIndex: { 2: 0 },
}) })
expect(reduceStore(state, storeActions.remove(3))).toEqual(state) expect(reduce(state, storeActions.remove(3))).toEqual(state)
}) })
it('should handle receive list', () => { it('should handle receive list', () => {
let a = { id: 1 }, b = { id: 2 }, let a = { id: 1 }, b = { id: 2 },
list = [ a, b ] list = [ a, b ]
expect(reduceStore(undefined, storeActions.receive(list))).toEqual({ expect(reduce(undefined, storeActions.receive(list))).toEqual({
byId: { 1: a, 2: b }, byId: { 1: a, 2: b },
list: [ a, b ], list: [ a, b ],
listIndex: {1: 0, 2: 1}, listIndex: {1: 0, 2: 1},

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import {render as rtlRender} from '@testing-library/react' import {render as rtlRender} from '@testing-library/react'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
// Import your own reducer // Import your own reducer

View File

@ -71,20 +71,3 @@ describe('getDiff', () => {
expect(utils.getDiff(obj1, obj2)).toEqual({ b: {foo: 2}, c:[4]}) expect(utils.getDiff(obj1, obj2)).toEqual({ b: {foo: 2}, c:[4]})
}) })
}) })
describe('pure', () => {
let tFunc = function({ className }) {
return (<p className={ className }>foo</p>)
},
puredFunc = utils.pure(tFunc),
f = new puredFunc('bar')
it('should display function name', () => {
expect(utils.pure(tFunc).displayName).toEqual('tFunc')
})
it('should render properties', () => {
expect(f.render()).toEqual(tFunc('bar'))
})
})

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import {render} from 'react-dom' import {render} from 'react-dom'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
@ -11,14 +11,17 @@ import {store} from "./ducks";
useUrlState(store) useUrlState(store)
// @ts-ignore
if (window.MITMWEB_STATIC) { if (window.MITMWEB_STATIC) {
// @ts-ignore
window.backend = new StaticBackend(store) window.backend = new StaticBackend(store)
} else { } else {
// @ts-ignore
window.backend = new WebSocketBackend(store) window.backend = new WebSocketBackend(store)
} }
window.addEventListener('error', msg => { window.addEventListener('error', (e: ErrorEvent) => {
store.dispatch(addLog(msg)) store.dispatch(addLog(`${e.message}\n${e.error.stack}`))
}) })
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@ -37,8 +37,8 @@ export function Results({results}: ResultProps) {
useEffect(() => { useEffect(() => {
if (resultElement) { if (resultElement) {
resultElement.current.addEventListener('DOMNodeInserted', event => { resultElement.current.addEventListener('DOMNodeInserted', (event) => {
const { currentTarget: target } = event; const target = event.currentTarget as Element;
target.scroll({ top: target.scrollHeight, behavior: 'auto' }); target.scroll({ top: target.scrollHeight, behavior: 'auto' });
}); });
} }
@ -99,7 +99,7 @@ export default function CommandBar() {
.then(data => { .then(data => {
setAllCommands(data["commands"]) setAllCommands(data["commands"])
setCompletionCandidate(getAvailableCommands(data["commands"])) setCompletionCandidate(getAvailableCommands(data["commands"]))
setAvailableCommands(Object.keys(data["commands"])) setAvailableCommands(Object.keys(data))
}) })
}, []) }, [])
@ -213,4 +213,4 @@ export default function CommandBar() {
</div> </div>
</div> </div>
) )
} }

View File

@ -1,59 +0,0 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { Edit, ViewServer, ViewImage } from './ContentView/ContentViews'
import * as MetaViews from './ContentView/MetaViews'
import ShowFullContentButton from './ContentView/ShowFullContentButton'
import { displayLarge, updateEdit } from '../ducks/ui/flow'
ContentView.propTypes = {
// It may seem a bit weird at the first glance:
// Every view takes the flow and the message as props, e.g.
// <Auto flow={flow} message={flow.request}/>
flow: PropTypes.object.isRequired,
message: PropTypes.object.isRequired,
}
ContentView.isContentTooLarge = msg => msg.contentLength > 1024 * 1024 * (ViewImage.matches(msg) ? 10 : 0.2)
function ContentView(props) {
const { flow, message, contentView, isDisplayLarge, displayLarge, onContentChange, readonly } = props
if (message.contentLength === 0 && readonly) {
return <MetaViews.ContentEmpty {...props}/>
}
if (message.contentLength === null && readonly) {
return <MetaViews.ContentMissing {...props}/>
}
if (!isDisplayLarge && ContentView.isContentTooLarge(message)) {
return <MetaViews.ContentTooLarge {...props} onClick={displayLarge}/>
}
let view;
if(contentView === "Edit") {
view = <Edit flow={flow} message={message} onChange={onContentChange}/>
} else {
view = <ViewServer flow={flow} message={message} contentView={contentView}/>
}
return (
<div className="contentview">
{view}
<ShowFullContentButton/>
</div>
)
}
export default connect(
state => ({
contentView: state.ui.flow.contentView,
isDisplayLarge: state.ui.flow.displayLarge,
}),
{
displayLarge,
updateEdit
}
)(ContentView)

View File

@ -1,20 +0,0 @@
import React from 'react'
import CodeMirror from "../../contrib/CodeMirror"
type CodeEditorProps = {
content: string,
onChange: Function,
}
export default function CodeEditor ( { content, onChange}: CodeEditorProps ){
let options = {
lineNumbers: true
};
return (
<div className="codeeditor" onKeyDown={e => e.stopPropagation()}>
<CodeMirror value={content} onChange={onChange} options={options}/>
</div>
)
}

View File

@ -1,106 +0,0 @@
import React from 'react'
import { MessageUtils } from '../../flow/utils'
import { Flow, HTTPMessage } from '../../flow'
type ContentLoaderProps = {
content: string,
contentView: object,
flow: Flow,
message: HTTPMessage,
}
type ContentLoaderStates = {
content: string | undefined,
request: { abort: () => void }| undefined,
}
export default function withContentLoader(View) {
return class extends React.Component<ContentLoaderProps, ContentLoaderStates> {
static displayName: string = View.displayName || View.name
static matches: (message: any) => boolean = View.matches
constructor(props) {
super(props)
this.state = {
content: undefined,
request: undefined,
}
}
componentDidMount() {
this.updateContent(this.props)
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (
nextProps.message.content !== this.props.message.content ||
nextProps.message.contentHash !== this.props.message.contentHash ||
nextProps.contentView !== this.props.contentView
) {
this.updateContent(nextProps)
}
}
componentWillUnmount() {
if (this.state.request) {
this.state.request.abort()
}
}
updateContent(props) {
if (this.state.request) {
this.state.request.abort()
}
// We have a few special cases where we do not need to make an HTTP request.
if (props.message.content !== undefined) {
return this.setState({request: undefined, content: props.message.content})
}
if (props.message.contentLength === 0) {
return this.setState({request: undefined, content: ""})
}
let requestUrl = MessageUtils.getContentURL(props.flow, props.message, props.contentView)
// We use XMLHttpRequest instead of fetch() because fetch() is not (yet) abortable.
let request = new XMLHttpRequest();
request.addEventListener("load", this.requestComplete.bind(this, request));
request.addEventListener("error", this.requestFailed.bind(this, request));
request.open("GET", requestUrl);
request.send();
this.setState({request, content: undefined})
}
requestComplete(request, e) {
if (request !== this.state.request) {
return // Stale request
}
this.setState({
content: request.responseText,
request: undefined
})
}
requestFailed(request, e) {
if (request !== this.state.request) {
return // Stale request
}
console.error(e)
// FIXME: Better error handling
this.setState({
content: "Error getting content.",
request: undefined
})
}
render() {
return this.state.content !== undefined ? (
<View content={this.state.content} {...this.props}/>
) : (
<div className="text-center">
<i className="fa fa-spinner fa-spin"></i>
</div>
)
}
}
};

View File

@ -1,28 +0,0 @@
import React from 'react'
import ViewSelector from './ViewSelector'
import UploadContentButton from './UploadContentButton'
import DownloadContentButton from './DownloadContentButton'
import { useAppSelector } from "../../ducks";
import { Flow, HTTPMessage } from '../../flow'
type ContentViewOptionsProps = {
flow: Flow,
message: HTTPMessage,
uploadContent: (content: string) => Promise<Response>,
}
export default function ContentViewOptions({ flow, message, uploadContent }: ContentViewOptionsProps) {
const contentViewDescription = useAppSelector(state => state.ui.flow.viewDescription)
const readonly = useAppSelector(state => state.ui.flow.modifiedFlow);
return (
<div className="view-options">
{readonly ? <ViewSelector /> : <span><b>View:</b> edit</span>}
&nbsp;
<DownloadContentButton flow={flow} message={message}/>
&nbsp;
{!readonly && <UploadContentButton uploadContent={uploadContent}/> }
&nbsp;
{readonly && <span>{contentViewDescription}</span>}
</div>
)
}

View File

@ -1,97 +0,0 @@
import React, { useEffect, useState } from 'react'
import { setContentViewDescription, setContent } from '../../ducks/ui/flow'
import withContentLoader from './ContentLoader'
import { MessageUtils } from '../../flow/utils'
import CodeEditor from './CodeEditor'
import { useAppDispatch, useAppSelector } from "../../ducks";
const isImage = /^image\/(png|jpe?g|gif|webp|vnc.microsoft.icon|x-icon)$/i
ViewImage.matches = msg => isImage.test(MessageUtils.getContentType(msg))
type ViewImageProps = {
flow: object,
message: object,
}
function ViewImage({ flow, message }: ViewImageProps) {
return (
<div className="flowview-image">
<img src={MessageUtils.getContentURL(flow, message)} alt="preview" className="img-thumbnail"/>
</div>
)
}
type EditProps = {
content: string,
onChange: (content: string) => any,
}
function PureEdit({ content, onChange }: EditProps) {
return <CodeEditor content={content} onChange={onChange}/>
}
const Edit = withContentLoader(PureEdit)
type PureViewServerProps = {
flow: object,
message: object,
content: string,
}
type PureViewServerStates = {
lines: [style: string, text: string][][],
description: string,
}
export function PureViewServer({flow, message, content}: PureViewServerProps) {
const [data, setData] = useState<PureViewServerStates>({
lines: [],
description: "",
})
const dispatch = useAppDispatch(),
showFullContent: boolean = useAppSelector(state => state.ui.flow.showFullContent),
maxLines: number = useAppSelector(state => state.ui.flow.maxContentLines)
let lines = showFullContent ? data.lines : data.lines?.slice(0, maxLines)
useEffect(() => {
setContentView({flow, message, content})
}, [flow, message, content])
const setContentView = (props) => {
try {
setData(JSON.parse(props.content))
}catch(err) {
setData({lines: [], description: err.message})
}
dispatch(setContentViewDescription(props.contentView !== data.description ? data.description : ''))
dispatch(setContent(data.lines))
}
return (
<div>
{ViewImage.matches(message) && <ViewImage flow={flow} message={message}/>}
<pre>
{lines.map((line, i) =>
<div key={`line${i}`}>
{line.map((element, j) => {
let [style, text] = element
return (
<span key={`tuple${j}`} className={style}>
{text}
</span>
)
})}
</div>
)}
</pre>
</div>
)
}
const ViewServer = withContentLoader(PureViewServer)
export { Edit, ViewServer, ViewImage }

View File

@ -1,19 +0,0 @@
import React from 'react'
import { MessageUtils } from "../../flow/utils"
import { Flow, HTTPMessage } from '../../flow'
type DownloadContentButtonProps = {
flow: Flow,
message: HTTPMessage,
}
export default function DownloadContentButton({ flow, message }: DownloadContentButtonProps) {
return (
<a className="btn btn-default btn-xs"
href={MessageUtils.getContentURL(flow, message)}
title="Download the content of the flow.">
<i className="fa fa-download"/>
</a>
)
}

View File

@ -1,49 +0,0 @@
import React from 'react'
import { formatSize } from '../../utils'
import UploadContentButton from './UploadContentButton'
import DownloadContentButton from './DownloadContentButton'
import { HTTPFlow, HTTPMessage } from '../../flow'
interface ContentProps {
flow: HTTPFlow,
message: HTTPMessage,
}
interface ContentTooLargeProps extends ContentProps {
onClick: () => void,
uploadContent: () => any,
}
export function ContentEmpty({ flow, message }: ContentProps) {
return (
<div className="alert alert-info">
No {flow.request === message ? 'request' : 'response'} content.
</div>
)
}
export function ContentMissing({ flow, message }: ContentProps) {
return (
<div className="alert alert-info">
{flow.request === message ? 'Request' : 'Response'} content missing.
</div>
)
}
export function ContentTooLarge({ message, onClick, uploadContent, flow }: ContentTooLargeProps) {
return (
<div>
<div className="alert alert-warning">
<button onClick={onClick} className="btn btn-xs btn-warning pull-right">Display anyway</button>
{formatSize(message.contentLength)} content size.
</div>
<div className="view-options text-center">
<UploadContentButton uploadContent={uploadContent}/>
&nbsp;
<DownloadContentButton flow={flow} message={message}/>
</div>
</div>
)
}

View File

@ -1,23 +0,0 @@
import React from 'react'
import Button from '../common/Button';
import { setShowFullContent } from '../../ducks/ui/flow'
import {useAppDispatch, useAppSelector} from "../../ducks";
export default function ShowFullContentButton() {
const dispatch = useAppDispatch(),
showFullContent = useAppSelector(state => state.ui.flow.showFullContent),
visibleLines = useAppSelector(state => state.ui.flow.maxContentLines),
contentLines = useAppSelector(state => state.ui.flow.content.length)
return (
!showFullContent ? (
<div>
<Button className="view-all-content-btn btn-xs" onClick={() => dispatch(setShowFullContent())}>
Show full content
</Button>
<span className="pull-right"> {visibleLines}/{contentLines} are visible &nbsp; </span>
</div>
) : null
)
}

View File

@ -1,18 +0,0 @@
import React from 'react'
import FileChooser from '../common/FileChooser'
type UploadContentButtonProps = {
uploadContent: (content: string) => Promise<Response>,
}
export default function UploadContentButton({ uploadContent }: UploadContentButtonProps) {
return (
<FileChooser
icon="fa-upload"
title="Upload a file to replace the content."
onOpenFile={uploadContent}
className="btn btn-default btn-xs"/>
)
}

View File

@ -1,26 +0,0 @@
import React from 'react'
import {setContentView} from '../../ducks/ui/flow';
import Dropdown, {MenuItem} from '../common/Dropdown'
import {useAppDispatch, useAppSelector} from "../../ducks";
export default React.memo(function ViewSelector() {
const dispatch = useAppDispatch(),
contentViews = useAppSelector(state => state.conf.contentViews || []),
activeView = useAppSelector(state => state.ui.flow.contentView);
let inner = <span><b>View:</b> {activeView.toLowerCase()} <span className="caret"/></span>
return (
<Dropdown
text={inner}
className="btn btn-default btn-xs pull-left"
options={{placement: "top-start"}}>
{contentViews.map(name =>
<MenuItem key={name} onClick={() => dispatch(setContentView(name))}>
{name.toLowerCase().replace('_', ' ')}
</MenuItem>
)}
</Dropdown>
)
});

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { connect } from 'react-redux' import { connect } from 'react-redux'

View File

@ -1,12 +1,13 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {useDispatch} from 'react-redux' import {useDispatch} from 'react-redux'
import classnames from 'classnames' import classnames from 'classnames'
import {RequestUtils, ResponseUtils} from '../../flow/utils' import {endTime, getTotalSize, RequestUtils, ResponseUtils, startTime} from '../../flow/utils'
import {fetchApi, formatSize, formatTimeDelta, formatTimeStamp} from '../../utils' import {formatSize, formatTimeDelta, formatTimeStamp} from '../../utils'
import * as flowActions from "../../ducks/flows"; import * as flowActions from "../../ducks/flows";
import {addInterceptFilter} from "../../ducks/options" import {addInterceptFilter} from "../../ducks/options"
import Dropdown, {MenuItem, SubMenu} from "../common/Dropdown"; import Dropdown, {MenuItem, SubMenu} from "../common/Dropdown";
import {Flow} from "../../flow"; import {Flow} from "../../flow";
import {copy} from "../../flow/export";
type FlowColumnProps = { type FlowColumnProps = {
@ -16,38 +17,38 @@ type FlowColumnProps = {
interface FlowColumn { interface FlowColumn {
(props: FlowColumnProps): JSX.Element; (props: FlowColumnProps): JSX.Element;
headerClass: string; headerName: string; // Shown in the UI
headerName: string; sortKey: (flow: Flow) => any;
} }
export const TLSColumn: FlowColumn = ({flow}) => { export const tls: FlowColumn = ({flow}) => {
return ( return (
<td className={classnames('col-tls', flow.client_conn.tls_established ? 'col-tls-https' : 'col-tls-http')}/> <td className={classnames('col-tls', flow.client_conn.tls_established ? 'col-tls-https' : 'col-tls-http')}/>
) )
} }
tls.headerName = ''
tls.sortKey = flow => flow.type === "http" && flow.request.scheme
TLSColumn.headerClass = 'col-tls' export const icon: FlowColumn = ({flow}) => {
TLSColumn.headerName = ''
export const IconColumn: FlowColumn = ({flow}) => {
return ( return (
<td className="col-icon"> <td className="col-icon">
<div className={classnames('resource-icon', getIcon(flow))}/> <div className={classnames('resource-icon', getIcon(flow))}/>
</td> </td>
) )
} }
icon.headerName = ''
IconColumn.headerClass = 'col-icon' icon.sortKey = flow => 0
IconColumn.headerName = ''
const getIcon = (flow: Flow): string => { const getIcon = (flow: Flow): string => {
if (flow.type !== "http" || !flow.response) { if (flow.type !== "http" || !flow.response) {
return 'resource-icon-plain' return 'resource-icon-plain'
} }
if (flow.websocket) {
return 'resource-icon-websocket'
}
var contentType = ResponseUtils.getContentType(flow.response) || '' var contentType = ResponseUtils.getContentType(flow.response) || ''
// @todo We should assign a type to the flow somewhere else.
if (flow.response.status_code === 304) { if (flow.response.status_code === 304) {
return 'resource-icon-not-modified' return 'resource-icon-not-modified'
} }
@ -70,7 +71,7 @@ const getIcon = (flow: Flow): string => {
return 'resource-icon-plain' return 'resource-icon-plain'
} }
export const PathColumn: FlowColumn = ({flow}) => { export const path: FlowColumn = ({flow}) => {
let err; let err;
if (flow.error) { if (flow.error) {
if (flow.error.msg === "Connection killed.") { if (flow.error.msg === "Connection killed.") {
@ -88,24 +89,23 @@ export const PathColumn: FlowColumn = ({flow}) => {
<i className="fa fa-fw fa-pause pull-right"/> <i className="fa fa-fw fa-pause pull-right"/>
)} )}
{err} {err}
<span className="marker pull-right">{flow.marked}</span>
{flow.type === "http" ? RequestUtils.pretty_url(flow.request) : null} {flow.type === "http" ? RequestUtils.pretty_url(flow.request) : null}
</td> </td>
) )
}; };
path.headerName = 'Path'
path.sortKey = flow => flow.type === "http" && RequestUtils.pretty_url(flow.request)
PathColumn.headerClass = 'col-path' export const method: FlowColumn = ({flow}) => {
PathColumn.headerName = 'Path'
export const MethodColumn: FlowColumn = ({flow}) => {
return ( return (
<td className="col-method">{flow.type === "http" ? flow.request.method : flow.type.toLowerCase()}</td> <td className="col-method">{flow.type === "http" ? flow.request.method : flow.type.toLowerCase()}</td>
) )
}; };
method.headerName = 'Method'
method.sortKey = flow => flow.type === "http" && flow.request.method
MethodColumn.headerClass = 'col-method' export const status: FlowColumn = ({flow}) => {
MethodColumn.headerName = 'Method'
export const StatusColumn: FlowColumn = ({flow}) => {
let color = 'darkred'; let color = 'darkred';
if (flow.type !== "http" || !flow.response) if (flow.type !== "http" || !flow.response)
@ -127,75 +127,65 @@ export const StatusColumn: FlowColumn = ({flow}) => {
<td className="col-status" style={{color: color}}>{flow.response.status_code}</td> <td className="col-status" style={{color: color}}>{flow.response.status_code}</td>
) )
} }
status.headerName = 'Status'
status.sortKey = flow => flow.type === "http" && flow.response && flow.response.status_code
StatusColumn.headerClass = 'col-status' export const size: FlowColumn = ({flow}) => {
StatusColumn.headerName = 'Status'
export const SizeColumn: FlowColumn = ({flow}) => {
return ( return (
<td className="col-size">{formatSize(getTotalSize(flow))}</td> <td className="col-size">{formatSize(getTotalSize(flow))}</td>
) )
}; };
size.headerName = 'Size'
size.sortKey = flow => getTotalSize(flow)
const getTotalSize = (flow: Flow): number => {
if (flow.type !== "http")
return 0
let total = flow.request.contentLength
if (flow.response) {
total += flow.response.contentLength || 0
}
return total
}
SizeColumn.headerClass = 'col-size' export const time: FlowColumn = ({flow}) => {
SizeColumn.headerName = 'Size' const start = startTime(flow), end = endTime(flow);
export const TimeColumn: FlowColumn = ({flow}) => {
return ( return (
<td className="col-time"> <td className="col-time">
{flow.type === "http" && flow.response?.timestamp_end ? ( {start && end ? (
formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start)) formatTimeDelta(1000 * (end - start))
) : ( ) : (
'...' '...'
)} )}
</td> </td>
) )
} }
time.headerName = 'Time'
time.sortKey = flow => {
const start = startTime(flow), end = endTime(flow);
return start && end && end - start;
}
TimeColumn.headerClass = 'col-time' export const timestamp: FlowColumn = ({flow}) => {
TimeColumn.headerName = 'Time' const start = startTime(flow);
export const TimeStampColumn: FlowColumn = ({flow}) => {
return ( return (
<td className="col-start"> <td className="col-start">
{flow.type === "http" && flow.request.timestamp_start ? ( {start ? (
formatTimeStamp(flow.request.timestamp_start) formatTimeStamp(start)
) : ( ) : (
'...' '...'
)} )}
</td> </td>
) )
} }
timestamp.headerName = 'Start time'
timestamp.sortKey = flow => startTime(flow)
TimeStampColumn.headerClass = 'col-timestamp' const markers = {
TimeStampColumn.headerName = 'TimeStamp' ":red_circle:": "🔴",
":orange_circle:": "🟠",
":yellow_circle:": "🟡",
":green_circle:": "🟢",
":large_blue_circle:": "🔵",
":purple_circle:": "🟣",
":brown_circle:": "🟤",
}
export const QuickActionsColumn: FlowColumn = ({flow}) => { export const quickactions: FlowColumn = ({flow}) => {
const dispatch = useDispatch() const dispatch = useDispatch()
let [open, setOpen] = useState(false) let [open, setOpen] = useState(false)
const copy = (format: string) => {
if (!flow) {
return
}
fetchApi(`/flows/${flow.id}/export/${format}.json`, {method: 'POST'})
.then(response => response.json())
.then(data => {
navigator.clipboard.writeText(data.export)
})
}
let resume_or_replay: React.ReactNode | null = null; let resume_or_replay: React.ReactNode | null = null;
if (flow.intercepted) { if (flow.intercepted) {
resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.resume(flow))}> resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.resume(flow))}>
@ -221,11 +211,21 @@ export const QuickActionsColumn: FlowColumn = ({flow}) => {
onOpen={setOpen} onOpen={setOpen}
options={{placement: "bottom-end"}}> options={{placement: "bottom-end"}}>
<SubMenu title="Copy..."> <SubMenu title="Copy...">
<MenuItem onClick={() => copy("raw_request")}>Copy raw request</MenuItem> <MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
<MenuItem onClick={() => copy("raw_response")}>Copy raw response</MenuItem> <MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
<MenuItem onClick={() => copy("raw")}>Copy raw request and response</MenuItem> <MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
<MenuItem onClick={() => copy("curl")}>Copy as cURL</MenuItem> <MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
<MenuItem onClick={() => copy("httpie")}>Copy as HTTPie</MenuItem> <MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
</SubMenu>
<SubMenu title="Mark..." className="markers-menu">
<MenuItem onClick={() => dispatch(flowActions.update(flow, {marked: ""}))}> (no marker)</MenuItem>
{Object.entries(markers).map(([name, sym]) =>
<MenuItem
key={name}
onClick={() => dispatch(flowActions.update(flow, {marked: name}))}>
{sym} {name.replace(/[:_]/g, " ")}
</MenuItem>
)}
</SubMenu> </SubMenu>
<SubMenu title="Intercept requests like this"> <SubMenu title="Intercept requests like this">
<MenuItem onClick={() => filt(`~q ${flow.request.host}`)}> <MenuItem onClick={() => filt(`~q ${flow.request.host}`)}>
@ -260,21 +260,5 @@ export const QuickActionsColumn: FlowColumn = ({flow}) => {
) )
} }
QuickActionsColumn.headerClass = 'col-quickactions' quickactions.headerName = ''
QuickActionsColumn.headerName = '' quickactions.sortKey = flow => 0;
export const columns: { [key: string]: FlowColumn } = {};
for (let col of [
TLSColumn,
IconColumn,
PathColumn,
MethodColumn,
StatusColumn,
TimeStampColumn,
SizeColumn,
TimeColumn,
QuickActionsColumn,
]) {
columns[col.name.replace(/Column$/, "").toLowerCase()] = col;
}

View File

@ -3,7 +3,7 @@ import classnames from 'classnames'
import {Flow} from "../../flow"; import {Flow} from "../../flow";
import {useAppDispatch, useAppSelector} from "../../ducks"; import {useAppDispatch, useAppSelector} from "../../ducks";
import {select} from '../../ducks/flows' import {select} from '../../ducks/flows'
import {columns, QuickActionsColumn} from "./FlowColumns"; import * as columns from "./FlowColumns";
type FlowRowProps = { type FlowRowProps = {
flow: Flow flow: Flow
@ -33,7 +33,7 @@ export default React.memo(function FlowRow({flow, selected, highlighted}: FlowRo
dispatch(select(flow.id)); dispatch(select(flow.id));
}, [flow]); }, [flow]);
const displayColumns = displayColumnNames.map(x => columns[x]).concat(QuickActionsColumn); const displayColumns = displayColumnNames.map(x => columns[x]).filter(x => x).concat(columns.quickactions);
return ( return (
<tr className={className} onClick={onClick}> <tr className={className} onClick={onClick}>

View File

@ -1,6 +1,6 @@
import React from 'react' import * as React from "react"
import classnames from 'classnames' import classnames from 'classnames'
import {columns, QuickActionsColumn} from './FlowColumns' import * as columns from './FlowColumns'
import {setSort} from '../../ducks/flows' import {setSort} from '../../ducks/flows'
import {useAppDispatch, useAppSelector} from "../../ducks"; import {useAppDispatch, useAppSelector} from "../../ducks";
@ -12,12 +12,12 @@ export default React.memo(function FlowTableHead() {
displayColumnNames = useAppSelector(state => state.options.web_columns); displayColumnNames = useAppSelector(state => state.options.web_columns);
const sortType = sortDesc ? 'sort-desc' : 'sort-asc' const sortType = sortDesc ? 'sort-desc' : 'sort-asc'
const displayColumns = displayColumnNames.map(x => columns[x]).concat(QuickActionsColumn); const displayColumns = displayColumnNames.map(x => columns[x]).filter(x => x).concat(columns.quickactions);
return ( return (
<tr> <tr>
{displayColumns.map(Column => ( {displayColumns.map(Column => (
<th className={classnames(Column.headerClass, sortColumn === Column.name && sortType)} <th className={classnames(`col-${Column.name}`, sortColumn === Column.name && sortType)}
key={Column.name} key={Column.name}
onClick={() => dispatch(setSort(Column.name, Column.name !== sortColumn ? false : !sortDesc))}> onClick={() => dispatch(setSort(Column.name, Column.name !== sortColumn ? false : !sortDesc))}>
{Column.headerName} {Column.headerName}

View File

@ -1,44 +1,66 @@
import React from 'react' import * as React from "react"
import _ from 'lodash' import {FunctionComponent} from "react"
import {Request, Response} from './FlowView/HttpMessages'
import Connection from './FlowView/Connection'
import Error from "./FlowView/Error"
import Timing from "./FlowView/Timing"
import WebSocket from "./FlowView/WebSocket"
import Nav from './FlowView/Nav' import {selectTab} from '../ducks/ui/flow'
import { ErrorView as Error, Request, Response } from './FlowView/Messages'
import Details from './FlowView/Details'
import { selectTab } from '../ducks/ui/flow'
import {useAppDispatch, useAppSelector} from "../ducks"; import {useAppDispatch, useAppSelector} from "../ducks";
import {Flow} from "../flow";
import classnames from "classnames";
export const allTabs = { Request, Response, Error, Details } type TabProps = {
flow: Flow
}
export const allTabs: { [name: string]: FunctionComponent<TabProps> } = {
request: Request,
response: Response,
error: Error,
connection: Connection,
timing: Timing,
websocket: WebSocket
}
export function tabsForFlow(flow: Flow): string[] {
const tabs = ['request', 'response', 'websocket', 'error'].filter(k => flow[k])
tabs.push("connection")
tabs.push("timing")
return tabs;
}
export default function FlowView() { export default function FlowView() {
const dispatch = useAppDispatch(), const dispatch = useAppDispatch(),
flow = useAppSelector(state => state.flows.byId[state.flows.selected[0]]) flow = useAppSelector(state => state.flows.byId[state.flows.selected[0]]),
tabs = tabsForFlow(flow);
let tabName = useAppSelector(state => state.ui.flow.tab) let active = useAppSelector(state => state.ui.flow.tab)
if (tabs.indexOf(active) < 0) {
// only display available tab names if (active === 'response' && flow.error) {
const tabs = ['request', 'response', 'error'].filter(k => flow[k]) active = 'error'
tabs.push("details") } else if (active === 'error' && "response" in flow) {
active = 'response'
if (tabs.indexOf(tabName) < 0) {
if (tabName === 'response' && flow.error) {
tabName = 'error'
} else if (tabName === 'error' && flow.response) {
tabName = 'response'
} else { } else {
tabName = tabs[0] active = tabs[0]
} }
} }
const Tab = allTabs[active];
const Tab = allTabs[_.capitalize(tabName)]
return ( return (
<div className="flow-detail"> <div className="flow-detail">
<Nav <nav className="nav-tabs nav-tabs-sm">
tabs={tabs} {tabs.map(tabId => (
active={tabName} <a key={tabId} href="#" className={classnames({active: active === tabId})}
onSelectTab={(tab: string) => dispatch(selectTab(tab))} onClick={event => {
/> event.preventDefault()
dispatch(selectTab(tabId))
}}>
{allTabs[tabId].name}
</a>
))}
</nav>
<Tab flow={flow}/> <Tab flow={flow}/>
</div> </div>
) )

View File

@ -0,0 +1,151 @@
import * as React from "react"
import {formatTimeStamp} from '../../utils'
import {Client, Flow, Server} from '../../flow'
type ConnectionInfoProps = {
conn: Client | Server
}
export function ConnectionInfo({conn}: ConnectionInfoProps) {
let address_info: JSX.Element | null = null;
if ("address" in conn) {
// Server
address_info = <>
<tr>
<td>Address:</td>
<td>{conn.address?.join(':')}</td>
</tr>
{conn.peername && (
<tr>
<td>Resolved address:</td>
<td>{conn.peername.join(':')}</td>
</tr>
)}
{conn.sockname && (
<tr>
<td>Source address:</td>
<td>{conn.sockname.join(':')}</td>
</tr>
)}
</>;
} else {
// Client
if (conn.peername?.[0]) {
address_info = <>
<tr>
<td>Address:</td>
<td>{conn.peername?.join(':')}</td>
</tr>
</>
}
}
return (
<table className="connection-table">
<tbody>
{address_info}
{conn.sni ? (
<tr>
<td><abbr title="TLS Server Name Indication">SNI</abbr>:</td>
<td>{conn.sni}</td>
</tr>
): null}
{conn.alpn ? (
<tr>
<td><abbr title="ALPN protocol negotiated">ALPN</abbr>:</td>
<td>{conn.alpn}</td>
</tr>
) : null}
{conn.tls_version ? (
<tr>
<td>TLS Version:</td>
<td>{conn.tls_version}</td>
</tr>
): null}
{conn.cipher ? (
<tr>
<td>TLS Cipher:</td>
<td>{conn.cipher}</td>
</tr>
): null}
</tbody>
</table>
)
}
function attrList(data: [string, string][]): JSX.Element {
return <dl className="cert-attributes">
{data.map(([k, v]) =>
<React.Fragment key={k}>
<dt>{k}</dt>
<dd>{v}</dd>
</React.Fragment>
)}
</dl>
}
export function CertificateInfo({flow}: { flow: Flow }): JSX.Element {
const cert = flow.server_conn?.cert;
if (!cert)
return <></>;
return <>
<h4 key="name">Server Certificate</h4>
<table className="certificate-table">
<tbody>
<tr>
<td>Type</td>
<td>{cert.keyinfo[0]}, {cert.keyinfo[1]} bits</td>
</tr>
<tr>
<td>SHA256 digest</td>
<td>{cert.sha256}</td>
</tr>
<tr>
<td>Valid from</td>
<td>{formatTimeStamp(cert.notbefore, {milliseconds: false})}</td>
</tr>
<tr>
<td>Valid to</td>
<td>{formatTimeStamp(cert.notafter, {milliseconds: false})}</td>
</tr>
<tr>
<td>Subject Alternative Names</td>
<td>{cert.altnames.join(", ")}</td>
</tr>
<tr>
<td>Subject</td>
<td>{attrList(cert.subject)}</td>
</tr>
<tr>
<td>Issuer</td>
<td>{attrList(cert.issuer)}</td>
</tr>
<tr>
<td>Serial</td>
<td>{cert.serial}</td>
</tr>
</tbody>
</table>
</>
}
export default function Connection({flow}: { flow: Flow }) {
return (
<section className="detail">
<h4>Client Connection</h4>
<ConnectionInfo conn={flow.client_conn}/>
{
flow.server_conn?.address &&
<>
<h4>Server Connection</h4>
<ConnectionInfo conn={flow.server_conn}/>
</>
}
<CertificateInfo flow={flow}/>
</section>
)
}

View File

@ -1,173 +0,0 @@
import React from 'react'
import {formatTimeDelta, formatTimeStamp} from '../../utils'
import { Flow, HTTPMessage, Connection } from '../../flow'
type TimeStampProps = {
t: number,
deltaTo: number,
title: string,
}
export function TimeStamp({t, deltaTo, title}: TimeStampProps) {
return t ? (
<tr>
<td>{title}:</td>
<td>
{formatTimeStamp(t)}
{deltaTo && (
<span className="text-muted">
({formatTimeDelta(1000 * (t - deltaTo))})
</span>
)}
</td>
</tr>
) : (
<tr></tr>
)
}
type ConnectionInfoProps = {
conn: Connection,
}
export function ConnectionInfo({conn}: ConnectionInfoProps) {
return (
<table className="connection-table">
<tbody>
<tr key="address">
<td>Address:</td>
<td>{conn.address?.join(':')}</td>
</tr>
{conn.sni && (
<tr key="sni">
<td><abbr title="TLS Server Name Indication">TLS SNI:</abbr></td>
<td>{conn.sni}</td>
</tr>
)}
{conn.tls_version && (
<tr key="tls_version">
<td>TLS version:</td>
<td>{conn.tls_version}</td>
</tr>
)}
{conn.cipher_name && (
<tr key="cipher_name">
<td>cipher name:</td>
<td>{conn.cipher_name}</td>
</tr>
)}
{conn.alpn_proto_negotiated && (
<tr key="ALPN">
<td><abbr title="ALPN protocol negotiated">ALPN:</abbr></td>
<td>{conn.alpn_proto_negotiated}</td>
</tr>
)}
{conn.ip_address && (
<tr key="ip_address">
<td>Resolved address:</td>
<td>{conn.ip_address.join(':')}</td>
</tr>
)}
{conn.source_address && (
<tr key="source_address">
<td>Source address:</td>
<td>{conn.source_address.join(':')}</td>
</tr>
)}
</tbody>
</table>
)
}
export function CertificateInfo({flow}) {
// @todo We should fetch human-readable certificate representation from the server
return (
<div>
{flow.client_conn.cert && [
<h4 key="name">Client Certificate</h4>,
<pre key="value" style={{maxHeight: 100}}>{flow.client_conn.cert}</pre>
]}
{flow.server_conn.cert && [
<h4 key="name">Server Certificate</h4>,
<pre key="value" style={{maxHeight: 100}}>{flow.server_conn.cert}</pre>
]}
</div>
)
}
export function Timing({flow}) {
const {server_conn: sc, client_conn: cc, request: req, response: res} = flow
const timestamps = [
{
title: "Server conn. initiated",
t: sc.timestamp_start,
deltaTo: req.timestamp_start
}, {
title: "Server conn. TCP handshake",
t: sc.timestamp_tcp_setup,
deltaTo: req.timestamp_start
}, {
title: "Server conn. SSL handshake",
t: sc.timestamp_ssl_setup,
deltaTo: req.timestamp_start
}, {
title: "Client conn. established",
t: cc.timestamp_start,
deltaTo: req.timestamp_start
}, {
title: "Client conn. SSL handshake",
t: cc.timestamp_ssl_setup,
deltaTo: req.timestamp_start
}, {
title: "First request byte",
t: req.timestamp_start
}, {
title: "Request complete",
t: req.timestamp_end,
deltaTo: req.timestamp_start
}, res && {
title: "First response byte",
t: res.timestamp_start,
deltaTo: req.timestamp_start
}, res && {
title: "Response complete",
t: res.timestamp_end,
deltaTo: req.timestamp_start
}
]
return (
<div>
<h4>Timing</h4>
<table className="timing-table">
<tbody>
{timestamps.filter(v => v).sort((a, b) => a.t - b.t).map(item => (
<TimeStamp key={item.title} {...item}/>
))}
</tbody>
</table>
</div>
)
}
export default function Details({flow}) {
return (
<section className="detail">
<h4>Client Connection</h4>
<ConnectionInfo conn={flow.client_conn}/>
{flow.server_conn.address &&
[
<h4 key="sc">Server Connection</h4>,
<ConnectionInfo key="sc-ci" conn={flow.server_conn}/>
]
}
<CertificateInfo flow={flow}/>
<Timing flow={flow}/>
</section>
)
}

View File

@ -0,0 +1,20 @@
import {HTTPFlow} from "../../flow";
import {formatTimeStamp} from "../../utils";
import * as React from "react";
type ErrorProps = {
flow: HTTPFlow & { error: Error }
}
export default function Error({flow}: ErrorProps) {
return (
<section className="error">
<div className="alert alert-warning">
{flow.error.msg}
<div>
<small>{formatTimeStamp(flow.error.timestamp)}</small>
</div>
</div>
</section>
)
}

View File

@ -1,162 +0,0 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ReactDOM from 'react-dom'
import ValueEditor from '../ValueEditor/ValueEditor'
import { Key } from '../../utils'
export class HeaderEditor extends Component {
constructor(props) {
super(props)
this.onKeyDown = this.onKeyDown.bind(this)
}
render() {
let { onTab, ...props } = this.props
return <ValueEditor
{...props}
onKeyDown={this.onKeyDown}
/>
}
focus() {
ReactDOM.findDOMNode(this).focus()
}
onKeyDown(e) {
switch (e.keyCode) {
case Key.BACKSPACE:
var s = window.getSelection().getRangeAt(0)
if (s.startOffset === 0 && s.endOffset === 0) {
this.props.onRemove(e)
}
break
case Key.ENTER:
case Key.TAB:
if (!e.shiftKey) {
this.props.onTab(e)
}
break
}
}
}
export default class Headers extends Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
message: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
}
static defaultProps = {
type: 'headers',
}
onChange(row, col, val) {
const nextHeaders = _.cloneDeep(this.props.message[this.props.type])
nextHeaders[row][col] = val
if (!nextHeaders[row][0] && !nextHeaders[row][1]) {
// do not delete last row
if (nextHeaders.length === 1) {
nextHeaders[0][0] = 'Name'
nextHeaders[0][1] = 'Value'
} else {
nextHeaders.splice(row, 1)
// manually move selection target if this has been the last row.
if (row === nextHeaders.length) {
this._nextSel = `${row - 1}-value`
}
}
}
this.props.onChange(nextHeaders)
}
edit() {
this.refs['0-key'].focus()
}
onTab(row, col, e) {
const headers = this.props.message[this.props.type]
if (col === 0) {
this._nextSel = `${row}-value`
return
}
if (row !== headers.length - 1) {
this._nextSel = `${row + 1}-key`
return
}
e.preventDefault()
const nextHeaders = _.cloneDeep(this.props.message[this.props.type])
nextHeaders.push(['Name', 'Value'])
this.props.onChange(nextHeaders)
this._nextSel = `${row + 1}-key`
}
componentDidUpdate() {
if (this._nextSel && this.refs[this._nextSel]) {
this.refs[this._nextSel].focus()
this._nextSel = undefined
}
}
onRemove(row, col, e) {
if (col === 1) {
e.preventDefault()
this.refs[`${row}-key`].focus()
} else if (row > 0) {
e.preventDefault()
this.refs[`${row - 1}-value`].focus()
}
}
render() {
const { message, readonly } = this.props
if (message[this.props.type]) {
return (
<table className="header-table">
<tbody>
{message[this.props.type].map((header, i) => (
<tr key={i}>
<td className="header-name">
<HeaderEditor
ref={`${i}-key`}
content={header[0]}
readonly={readonly}
onDone={val => this.onChange(i, 0, val)}
onRemove={event => this.onRemove(i, 0, event)}
onTab={event => this.onTab(i, 0, event)}
/>
<span className="header-colon">:</span>
</td>
<td className="header-value">
<HeaderEditor
ref={`${i}-value`}
content={header[1]}
readonly={readonly}
onDone={val => this.onChange(i, 1, val)}
onRemove={event => this.onRemove(i, 1, event)}
onTab={event => this.onTab(i, 1, event)}
/>
</td>
</tr>
))}
</tbody>
</table>
)
} else {
return (
<table className="header-table">
<tbody>
</tbody>
</table>
)
}
}
}

View File

@ -0,0 +1,146 @@
import * as React from "react"
import {isValidHttpVersion, MessageUtils, parseUrl, RequestUtils} from '../../flow/utils'
import ValidateEditor from '../editors/ValidateEditor'
import ValueEditor from '../editors/ValueEditor'
import {useAppDispatch, useAppSelector} from "../../ducks";
import {HTTPFlow, HTTPMessage, HTTPResponse} from '../../flow'
import * as flowActions from '../../ducks/flows'
import KeyValueListEditor from "../editors/KeyValueListEditor";
import HttpMessage from "../contentviews/HttpMessage";
type RequestLineProps = {
flow: HTTPFlow,
}
function RequestLine({flow}: RequestLineProps) {
const dispatch = useAppDispatch();
return (
<div className="first-line request-line">
<div>
<ValidateEditor
content={flow.request.method}
onEditDone={method => dispatch(flowActions.update(flow, {request: {method}}))}
isValid={method => method.length > 0}
/>
&nbsp;
<ValidateEditor
content={RequestUtils.pretty_url(flow.request)}
onEditDone={url => dispatch(flowActions.update(flow, {request: {path: '', ...parseUrl(url)}}))}
isValid={url => !!parseUrl(url)?.host}
/>
&nbsp;
<ValidateEditor
content={flow.request.http_version}
onEditDone={http_version => dispatch(flowActions.update(flow, {request: {http_version}}))}
isValid={isValidHttpVersion}
/>
</div>
</div>
)
}
type ResponseLineProps = {
flow: HTTPFlow & { response: HTTPResponse },
}
function ResponseLine({flow}: ResponseLineProps) {
const dispatch = useAppDispatch();
return (
<div className="first-line response-line">
<ValidateEditor
content={flow.response.http_version}
onEditDone={nextVer => dispatch(flowActions.update(flow, {response: {http_version: nextVer}}))}
isValid={isValidHttpVersion}
/>
&nbsp;
<ValidateEditor
content={flow.response.status_code + ''}
onEditDone={code => dispatch(flowActions.update(flow, {response: {code: parseInt(code)}}))}
isValid={code => /^\d+$/.test(code)}
/>
{flow.response.http_version !== "HTTP/2.0" &&
<>&nbsp;
<ValueEditor
content={flow.response.reason}
onEditDone={msg => dispatch(flowActions.update(flow, {response: {msg}}))}
/>
</>
}
</div>
)
}
type HeadersProps = {
flow: HTTPFlow,
message: HTTPMessage
}
function Headers({flow, message}: HeadersProps) {
const dispatch = useAppDispatch();
const part = flow.request === message ? "request" : "response";
return (
<KeyValueListEditor
className="headers"
data={message.headers}
onChange={headers => dispatch(flowActions.update(flow, {[part]: {headers}}))}
/>
);
}
type TrailersProps = {
flow: HTTPFlow,
message: HTTPMessage
}
function Trailers({flow, message}: TrailersProps) {
const dispatch = useAppDispatch();
const part = flow.request === message ? "request" : "response";
const hasTrailers = !!MessageUtils.get_first_header(message, /^trailer$/i)
if (!hasTrailers)
return null;
return <>
<hr/>
<h5>HTTP Trailers</h5>
<KeyValueListEditor
className="trailers"
data={message.trailers}
onChange={trailers => dispatch(flowActions.update(flow, {[part]: {trailers}}))}
/>
</>;
}
const Message = React.memo(function Message({flow, message}: { flow: HTTPFlow, message: HTTPMessage }) {
const part = flow.request === message ? "request" : "response";
const FirstLine = flow.request === message ? RequestLine : ResponseLine;
return (
<section className={part}>
<FirstLine flow={flow}/>
<Headers flow={flow} message={message}/>
<hr/>
<HttpMessage key={flow.id + part} flow={flow} message={message}/>
<Trailers flow={flow} message={message}/>
</section>
)
});
export function Request() {
const flow = useAppSelector(state => state.flows.byId[state.flows.selected[0]]) as HTTPFlow;
return <Message flow={flow} message={flow.request}/>;
}
export function Response() {
const flow = useAppSelector(state => state.flows.byId[state.flows.selected[0]]) as HTTPFlow & { response: HTTPResponse }
return <Message flow={flow} message={flow.response}/>;
}

View File

@ -1,198 +0,0 @@
import React from 'react'
import { RequestUtils, isValidHttpVersion, parseUrl } from '../../flow/utils'
import { formatTimeStamp } from '../../utils'
import ContentView from '../ContentView'
import ContentViewOptions from '../ContentView/ContentViewOptions'
import ValidateEditor from '../ValueEditor/ValidateEditor'
import ValueEditor from '../ValueEditor/ValueEditor'
import HideInStatic from '../common/HideInStatic'
import Headers from './Headers'
import { updateEdit as updateFlow } from '../../ducks/ui/flow'
import { uploadContent } from '../../ducks/flows'
import ToggleEdit from './ToggleEdit'
import { useAppDispatch, useAppSelector } from "../../ducks";
import { HTTPFlow, HTTPMessage } from '../../flow'
type RequestLineProps = {
flow: HTTPFlow,
readonly: boolean,
}
function RequestLine({ flow, readonly }: RequestLineProps) {
const dispatch = useAppDispatch()
return (
<div className="first-line request-line">
<div>
<ValueEditor
content={flow.request.method}
readonly={readonly}
onDone={method => dispatch(updateFlow({ request: { method } }))}
/>
&nbsp;
<ValidateEditor
content={RequestUtils.pretty_url(flow.request)}
readonly={readonly}
onDone={url => dispatch(updateFlow({ request: {path: '', ...parseUrl(url)}}))}
isValid={url => !!parseUrl(url).host}
/>
&nbsp;
<ValidateEditor
content={flow.request.http_version}
readonly={readonly}
onDone={http_version => dispatch(updateFlow({ request: { http_version } }))}
isValid={isValidHttpVersion}
/>
</div>
</div>
)
}
type ResponseLineProps = {
flow: HTTPFlow,
readonly: boolean,
}
function ResponseLine({ flow, readonly }: ResponseLineProps) {
const dispatch = useAppDispatch()
return (
<div className="first-line response-line">
<ValidateEditor
content={flow.response?.http_version}
readonly={readonly}
onDone={nextVer => dispatch(updateFlow({ response: { http_version: nextVer } }))}
isValid={isValidHttpVersion}
/>
&nbsp;
<ValidateEditor
content={flow.response?.status_code + ''}
readonly={readonly}
onDone={code => dispatch(updateFlow({ response: { code: parseInt(code) } }))}
isValid={code => /^\d+$/.test(code)}
/>
&nbsp;
<ValueEditor
content={flow.response?.reason}
readonly={readonly}
onDone={msg => dispatch(updateFlow({ response: { msg } }))}
/>
</div>
)
}
export function Request() {
const dispatch = useAppDispatch(),
flow = useAppSelector(state => state.ui.flow.modifiedFlow || state.flows.byId[state.flows.selected[0]]),
isEdit = useAppSelector(state => !!state.ui.flow.modifiedFlow)
let noContent = !isEdit && (flow.request.contentLength == 0 || flow.request.contentLength == null)
return (
<section className="request">
<article>
<ToggleEdit/>
<RequestLine
flow={flow}
readonly={!isEdit} />
<Headers
message={flow.request}
readonly={!isEdit}
onChange={headers => dispatch(updateFlow({ request: { headers } }))}
/>
<hr/>
<ContentView
readonly={!isEdit}
flow={flow}
onContentChange={content => dispatch(updateFlow({ request: {content}}))}
message={flow.request}/>
<hr/>
<Headers
message={flow.request}
readonly={!isEdit}
onChange={trailers => dispatch(updateFlow({ request: { trailers } }))}
type='trailers'
/>
</article>
<HideInStatic>
{!noContent &&
<footer>
<ContentViewOptions
flow={flow}
message={flow.request}
uploadContent={content => dispatch(uploadContent(flow, content, "request"))}/>
</footer>
}
</HideInStatic>
</section>
)
}
export function Response() {
const dispatch = useAppDispatch(),
flow = useAppSelector(state => state.ui.flow.modifiedFlow || state.flows.byId[state.flows.selected[0]]),
isEdit = useAppSelector(state => !!state.ui.flow.modifiedFlow)
let noContent = !isEdit && (flow.response.contentLength == 0 || flow.response.contentLength == null)
return (
<section className="response">
<article>
<ToggleEdit/>
<ResponseLine
flow={flow}
readonly={!isEdit} />
<Headers
message={flow.response}
readonly={!isEdit}
onChange={headers => dispatch(updateFlow({ response: { headers } }))}
/>
<hr/>
<ContentView
readonly={!isEdit}
flow={flow}
onContentChange={content => dispatch(updateFlow({ response: {content}}))}
message={flow.response}
/>
<hr/>
<Headers
message={flow.response}
readonly={!isEdit}
onChange={trailers => dispatch(updateFlow({ response: { trailers } }))}
type='trailers'
/>
</article>
<HideInStatic>
{!noContent &&
<footer >
<ContentViewOptions
flow={flow}
message={flow.response}
uploadContent={content => dispatch(uploadContent(flow, content, "response"))} />
</footer>
}
</HideInStatic>
</section>
)
}
type ErrorViewProps = {
flow: HTTPFlow
}
export function ErrorView({ flow }: ErrorViewProps) {
return (
<section className="error">
<div className="alert alert-warning">
{flow.error?.msg}
<div>
<small>{formatTimeStamp(flow.error?.timestamp)}</small>
</div>
</div>
</section>
)
}

View File

@ -1,47 +0,0 @@
import React from 'react'
import classnames from 'classnames'
import _ from 'lodash'
type NavActionProps = {
icon: string,
title: string,
onClick: (e: any) => void,
}
export function NavAction({ icon, title, onClick }: NavActionProps) {
return (
<a title={title}
href="#"
className="nav-action"
onClick={event => {
event.preventDefault()
onClick(event)
}}>
<i className={`fa fa-fw ${icon}`}></i>
</a>
)
}
type NavProps = {
active: string,
tabs: string[],
onSelectTab: (e: string) => void,
}
export default function Nav({ active, tabs, onSelectTab }: NavProps) {
return (
<nav className="nav-tabs nav-tabs-sm">
{tabs.map(tab => (
<a key={tab}
href="#"
className={classnames({ active: active === tab })}
onClick={event => {
event.preventDefault()
onSelectTab(tab)
}}>
{_.capitalize(tab)}
</a>
))}
</nav>
)
}

View File

@ -0,0 +1,94 @@
import {Flow} from "../../flow";
import * as React from "react";
import {formatTimeDelta, formatTimeStamp} from "../../utils";
export type TimeStampProps = {
t: number,
deltaTo?: number,
title: string,
}
export function TimeStamp({t, deltaTo, title}: TimeStampProps) {
return t ? (
<tr>
<td>{title}:</td>
<td>
{formatTimeStamp(t)}
{deltaTo && (
<span className="text-muted">
({formatTimeDelta(1000 * (t - deltaTo))})
</span>
)}
</td>
</tr>
) : (
<tr/>
)
}
export default function Timing({flow}: { flow: Flow }) {
let ref: number;
if (flow.type === "http") {
ref = flow.request.timestamp_start
} else {
ref = flow.client_conn.timestamp_start
}
const timestamps: Partial<TimeStampProps>[] = [
{
title: "Server conn. initiated",
t: flow.server_conn?.timestamp_start,
deltaTo: ref
}, {
title: "Server conn. TCP handshake",
t: flow.server_conn?.timestamp_tcp_setup,
deltaTo: ref
}, {
title: "Server conn. TLS handshake",
t: flow.server_conn?.timestamp_tls_setup,
deltaTo: ref
}, {
title: "Client conn. established",
t: flow.client_conn.timestamp_start,
deltaTo: flow.type === "http" ? ref : undefined
}, {
title: "Client conn. TLS handshake",
t: flow.client_conn.timestamp_tls_setup,
deltaTo: ref
}
]
if (flow.type === "http") {
timestamps.push(...[
{
title: "First request byte",
t: flow.request.timestamp_start
}, {
title: "Request complete",
t: flow.request.timestamp_end,
deltaTo: ref
}, {
title: "First response byte",
t: flow.response?.timestamp_start,
deltaTo: ref
}, {
title: "Response complete",
t: flow.response?.timestamp_end,
deltaTo: ref
}
]);
}
return (
<section className="timing">
<h4>Timing</h4>
<table className="timing-table">
<tbody>
{timestamps
.filter((v): v is TimeStampProps => !!v.t)
.sort((a, b) => a.t - b.t)
.map(props => <TimeStamp key={props.title} {...props}/>)}
</tbody>
</table>
</section>
)
}

View File

@ -1,25 +0,0 @@
import React from 'react'
import {useAppDispatch, useAppSelector} from "../../ducks";
import { startEdit, stopEdit } from '../../ducks/ui/flow'
export default function ToggleEdit() {
const dispatch = useAppDispatch(),
isEdit = useAppSelector(state => !!state.ui.flow.modifiedFlow),
modifiedFlow = useAppSelector(state => state.ui.flow.modifiedFlow|| state.flows.byId[state.flows.selected[0]]),
flow = useAppSelector(state => state.flows.byId[state.flows.selected[0]])
return (
<div className="edit-flow-container">
{isEdit ?
<a className="edit-flow" title="Finish Edit" onClick={() => dispatch(stopEdit(flow, modifiedFlow))}>
<i className="fa fa-check"/>
</a>
:
<a className="edit-flow" title="Edit Flow" onClick={() => dispatch(startEdit(flow))}>
<i className="fa fa-pencil"/>
</a>
}
</div>
)
}

View File

@ -0,0 +1,66 @@
import {HTTPFlow, WebSocketData} from "../../flow";
import * as React from "react";
import {useCallback, useMemo, useState} from "react";
import {ContentViewData, SHOW_MAX_LINES, useContent} from "../contentviews/useContent";
import {MessageUtils} from "../../flow/utils";
import ViewSelector from "../contentviews/ViewSelector";
import {useAppDispatch, useAppSelector} from "../../ducks";
import {setContentViewFor} from "../../ducks/ui/flow";
import LineRenderer from "../contentviews/LineRenderer";
import {formatTimeStamp} from "../../utils";
export default function WebSocket({flow}: { flow: HTTPFlow & { websocket: WebSocketData } }) {
const dispatch = useAppDispatch();
const contentView = useAppSelector(state => state.ui.flow.contentViewFor[flow.id + "ws"] || "Auto");
let [maxLines, setMaxLines] = useState<number>(SHOW_MAX_LINES);
const showMore = useCallback(() => setMaxLines(Math.max(1024, maxLines * 2)), [maxLines]);
const content = useContent(
MessageUtils.getContentURL(flow, "messages", contentView, maxLines + 1),
flow.id + flow.websocket.messages_meta.count
);
const messages = useMemo<ContentViewData[] | undefined>(() => content && JSON.parse(content), [content]) || [];
return (
<section className="websocket">
<h4>WebSocket</h4>
<div className="contentview">
<div className="controls">
<h5>{flow.websocket.messages_meta.count} Messages</h5>
<ViewSelector value={contentView}
onChange={cv => dispatch(setContentViewFor(flow.id + "ws", cv))}/>
</div>
{messages.map((d: ContentViewData, i) => {
const className = `fa fa-fw fa-arrow-${d.from_client ? "right text-primary" : "left text-danger"}`;
const renderer = <div key={i}>
<small>
<i className={className}/>
<span className="pull-right">{d.timestamp && formatTimeStamp(d.timestamp)}</span>
</small>
<LineRenderer lines={d.lines} maxLines={maxLines} showMore={showMore}/>
</div>;
maxLines -= d.lines.length;
return renderer;
})}
</div>
<CloseSummary websocket={flow.websocket}/>
</section>
)
}
function CloseSummary({websocket}: {websocket: WebSocketData}){
if(!websocket.timestamp_end)
return null;
const reason = websocket.close_reason ? `(${websocket.close_reason})` : ""
return <div>
<i className="fa fa-fw fa-window-close text-muted"/>
&nbsp;
Closed by {websocket.closed_by_client ? "client": "server"} with code {websocket.close_code} {reason}.
<small className="pull-right">
{formatTimeStamp(websocket.timestamp_end)}
</small>
</div>
}

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from 'react'
import {formatSize} from '../utils' import {formatSize} from '../utils'
import HideInStatic from '../components/common/HideInStatic' import HideInStatic from '../components/common/HideInStatic'
import {useAppSelector} from "../ducks"; import {useAppSelector} from "../ducks";
@ -7,7 +7,7 @@ export default function Footer() {
const version = useAppSelector(state => state.conf.version); const version = useAppSelector(state => state.conf.version);
let { let {
mode, intercept, showhost, upstream_cert, rawtcp, http2, websocket, anticache, anticomp, mode, intercept, showhost, upstream_cert, rawtcp, http2, websocket, anticache, anticomp,
stickyauth, stickycookie, stream_large_bodies, listen_host, listen_port, server stickyauth, stickycookie, stream_large_bodies, listen_host, listen_port, server, ssl_insecure
} = useAppSelector(state => state.options); } = useAppSelector(state => state.options);
return ( return (
@ -18,6 +18,9 @@ export default function Footer() {
{intercept && ( {intercept && (
<span className="label label-success">Intercept: {intercept}</span> <span className="label label-success">Intercept: {intercept}</span>
)} )}
{ssl_insecure && (
<span className="label label-danger">ssl_insecure</span>
)}
{showhost && ( {showhost && (
<span className="label label-success">showhost</span> <span className="label label-success">showhost</span>
)} )}
@ -57,8 +60,8 @@ export default function Footer() {
</span>) </span>)
} }
</HideInStatic> </HideInStatic>
<span className="label label-info" title="Mitmproxy Version"> <span className="label label-default" title="Mitmproxy Version">
{version} mitmproxy {version}
</span> </span>
</div> </div>
</footer> </footer>

View File

@ -1,63 +0,0 @@
import React, { Component } from 'react'
import { connect } from 'react-redux'
import classnames from 'classnames'
import MainMenu from './Header/MainMenu'
import OptionMenu from './Header/OptionMenu'
import FileMenu from './Header/FileMenu'
import FlowMenu from './Header/FlowMenu'
import {setActiveMenu} from '../ducks/ui/header'
import ConnectionIndicator from "./Header/ConnectionIndicator"
import HideInStatic from './common/HideInStatic'
class Header extends Component {
static entries = [MainMenu, OptionMenu]
handleClick(active, e) {
e.preventDefault()
this.props.setActiveMenu(active.title)
}
render() {
const { selectedFlowId, activeMenu} = this.props
let entries = [...Header.entries]
if(selectedFlowId)
entries.push(FlowMenu)
// Make sure to have a fallback in case FlowMenu is selected but we don't have any flows
// (e.g. because they are all deleted or not yet received)
const Active = _.find(entries, (e) => e.title == activeMenu) || MainMenu
return (
<header>
<nav className="nav-tabs nav-tabs-lg">
<FileMenu/>
{entries.map(Entry => (
<a key={Entry.title}
href="#"
className={classnames({ active: Entry === Active})}
onClick={e => this.handleClick(Entry, e)}>
{Entry.title}
</a>
))}
<HideInStatic>
<ConnectionIndicator/>
</HideInStatic>
</nav>
<div>
<Active/>
</div>
</header>
)
}
}
export default connect(
state => ({
selectedFlowId: state.flows.selected[0],
activeMenu: state.ui.header.activeMenu,
}),
{
setActiveMenu,
}
)(Header)

View File

@ -0,0 +1,64 @@
import React, {useState} from 'react'
import classnames from 'classnames'
import StartMenu from './Header/StartMenu'
import OptionMenu from './Header/OptionMenu'
import FileMenu from './Header/FileMenu'
import FlowMenu from './Header/FlowMenu'
import ConnectionIndicator from "./Header/ConnectionIndicator"
import HideInStatic from './common/HideInStatic'
import {useAppSelector} from "../ducks";
interface Menu {
(): JSX.Element;
title: string;
}
export default function Header() {
const selectedFlows = useAppSelector(state => state.flows.selected.filter(id => id in state.flows.byId)),
[ActiveMenu, setActiveMenu] = useState<Menu>(() => StartMenu),
[wasFlowSelected, setWasFlowSelected] = useState(false);
let entries: Menu[] = [StartMenu, OptionMenu];
if (selectedFlows.length > 0) {
if (!wasFlowSelected) {
setActiveMenu(() => FlowMenu);
setWasFlowSelected(true);
}
entries.push(FlowMenu)
} else {
if (wasFlowSelected) {
setWasFlowSelected(false);
}
if (ActiveMenu === FlowMenu) {
setActiveMenu(() => StartMenu)
}
}
function handleClick(active: Menu, e) {
e.preventDefault()
setActiveMenu(() => active)
}
return (
<header>
<nav className="nav-tabs nav-tabs-lg">
<FileMenu/>
{entries.map(Entry => (
<a key={Entry.title}
href="#"
className={classnames({active: Entry === ActiveMenu})}
onClick={e => handleClick(Entry, e)}>
{Entry.title}
</a>
))}
<HideInStatic>
<ConnectionIndicator/>
</HideInStatic>
</nav>
<div>
<ActiveMenu/>
</div>
</header>
)
}

View File

@ -1,4 +1,4 @@
import React from "react" import * as React from "react";
import {ConnectionState} from "../../ducks/connection" import {ConnectionState} from "../../ducks/connection"
import {useAppSelector} from "../../ducks"; import {useAppSelector} from "../../ducks";

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import {useDispatch} from 'react-redux' import {useDispatch} from 'react-redux'
import FileChooser from '../common/FileChooser' import FileChooser from '../common/FileChooser'
import Dropdown, {Divider, MenuItem} from '../common/Dropdown' import Dropdown, {Divider, MenuItem} from '../common/Dropdown'
@ -9,10 +9,7 @@ import HideInStatic from "../common/HideInStatic";
export default React.memo(function FileMenu() { export default React.memo(function FileMenu() {
const dispatch = useDispatch(); const dispatch = useDispatch();
return ( return (
<Dropdown className="pull-left special" text="mitmproxy" options={{"placement": "bottom-start"}}> <Dropdown className="pull-left special" text="File" options={{"placement": "bottom-start"}}>
<MenuItem onClick={() => confirm('Delete all flows?') && dispatch(flowsActions.clear())}>
<i className="fa fa-fw fa-trash"/>&nbsp;Clear All
</MenuItem>
<li> <li>
<FileChooser <FileChooser
icon="fa-folder-open" icon="fa-folder-open"
@ -30,6 +27,9 @@ export default React.memo(function FileMenu() {
<MenuItem onClick={() => dispatch(flowsActions.download())}> <MenuItem onClick={() => dispatch(flowsActions.download())}>
<i className="fa fa-fw fa-floppy-o"/>&nbsp;Save... <i className="fa fa-fw fa-floppy-o"/>&nbsp;Save...
</MenuItem> </MenuItem>
<MenuItem onClick={() => confirm('Delete all flows?') && dispatch(flowsActions.clear())}>
<i className="fa fa-fw fa-trash"/>&nbsp;Clear All
</MenuItem>
<HideInStatic> <HideInStatic>
<Divider/> <Divider/>
<li> <li>

View File

@ -1,76 +1,121 @@
import React from "react" import * as React from "react";
import Button from "../common/Button" import Button from "../common/Button"
import { MessageUtils } from "../../flow/utils.js" import {MessageUtils} from "../../flow/utils.js"
import HideInStatic from "../common/HideInStatic"; import HideInStatic from "../common/HideInStatic";
import { useAppDispatch, useAppSelector } from "../../ducks"; import {useAppDispatch, useAppSelector} from "../../ducks";
import { import {
resume as resumeFlow,
replay as replayFlow,
duplicate as duplicateFlow, duplicate as duplicateFlow,
revert as revertFlow, kill as killFlow,
remove as removeFlow, remove as removeFlow,
kill as killFlow replay as replayFlow,
resume as resumeFlow,
revert as revertFlow
} from "../../ducks/flows" } from "../../ducks/flows"
import Dropdown, {MenuItem} from "../common/Dropdown";
import {copy} from "../../flow/export";
import {Flow} from "../../flow";
FlowMenu.title = 'Flow' FlowMenu.title = 'Flow'
export default function FlowMenu() { export default function FlowMenu(): JSX.Element {
const dispatch = useAppDispatch(), const dispatch = useAppDispatch(),
flow = useAppSelector(state => state.flows.byId[state.flows.selected[0]]) flow = useAppSelector(state => state.flows.byId[state.flows.selected[0]])
if (!flow) if (!flow)
return <div/> return <div/>
return ( return (
<div className="flow-menu"> <div className="flow-menu">
<HideInStatic> <HideInStatic>
<div className="menu-group"> <div className="menu-group">
<div className="menu-content"> <div className="menu-content">
<Button title="[r]eplay flow" icon="fa-repeat text-primary" <Button title="[r]eplay flow" icon="fa-repeat text-primary"
onClick={() => dispatch(replayFlow(flow))}> onClick={() => dispatch(replayFlow(flow))}
Replay disabled={!(flow.type === "http" && !flow.websocket)}
</Button> >
<Button title="[D]uplicate flow" icon="fa-copy text-info" Replay
onClick={() => dispatch(duplicateFlow(flow))}> </Button>
Duplicate <Button title="[D]uplicate flow" icon="fa-copy text-info"
</Button> onClick={() => dispatch(duplicateFlow(flow))}>
<Button disabled={!flow || !flow.modified} title="revert changes to flow [V]" Duplicate
icon="fa-history text-warning" onClick={() => dispatch(revertFlow(flow))}> </Button>
Revert <Button disabled={!flow || !flow.modified} title="revert changes to flow [V]"
</Button> icon="fa-history text-warning" onClick={() => dispatch(revertFlow(flow))}>
<Button title="[d]elete flow" icon="fa-trash text-danger" Revert
onClick={() => dispatch(removeFlow(flow))}> </Button>
Delete <Button title="[d]elete flow" icon="fa-trash text-danger"
</Button> onClick={() => dispatch(removeFlow(flow))}>
Delete
</Button>
</div>
<div className="menu-legend">Flow Modification</div>
</div> </div>
<div className="menu-legend">Flow Modification</div>
</div>
</HideInStatic> </HideInStatic>
<div className="menu-group"> <div className="menu-group">
<div className="menu-content"> <div className="menu-content">
<Button title="download" icon="fa-download" <DownloadButton flow={flow}/>
onClick={() => window.location = MessageUtils.getContentURL(flow, flow.response)}> <Dropdown className="" text={
Download <Button title="Export flow." icon="fa-clone" onClick={() => 1}>Export</Button>
</Button> } options={{"placement": "bottom-start"}}>
<MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
<MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
<MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
<MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
<MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
</Dropdown>
</div> </div>
<div className="menu-legend">Export</div> <div className="menu-legend">Export</div>
</div> </div>
<HideInStatic> <HideInStatic>
<div className="menu-group"> <div className="menu-group">
<div className="menu-content"> <div className="menu-content">
<Button disabled={!flow || !flow.intercepted} title="[a]ccept intercepted flow" <Button disabled={!flow || !flow.intercepted} title="[a]ccept intercepted flow"
icon="fa-play text-success" onClick={() => dispatch(resumeFlow(flow))}> icon="fa-play text-success" onClick={() => dispatch(resumeFlow(flow))}>
Resume Resume
</Button> </Button>
<Button disabled={!flow || !flow.intercepted} title="kill intercepted flow [x]" <Button disabled={!flow || !flow.intercepted} title="kill intercepted flow [x]"
icon="fa-times text-danger" onClick={() => dispatch(killFlow(flow))}> icon="fa-times text-danger" onClick={() => dispatch(killFlow(flow))}>
Abort Abort
</Button> </Button>
</div>
<div className="menu-legend">Interception</div>
</div> </div>
<div className="menu-legend">Interception</div>
</div>
</HideInStatic> </HideInStatic>
</div> </div>
) )
} }
function DownloadButton({flow}: { flow: Flow }) {
if (flow.type !== "http")
return null;
if (flow.request.contentLength && !flow.response?.contentLength) {
return <Button icon="fa-download"
onClick={() => window.location.href = MessageUtils.getContentURL(flow, flow.request)}
>Download</Button>
}
if (flow.response) {
const response = flow.response;
if (!flow.request.contentLength && flow.response.contentLength) {
return <Button icon="fa-download"
onClick={() => window.location.href = MessageUtils.getContentURL(flow, response)}
>Download</Button>
}
if (flow.request.contentLength && flow.response.contentLength) {
return <Dropdown text={
<Button icon="fa-download" onClick={() => 1}>Download</Button>
} options={{"placement": "bottom-start"}}>
<MenuItem onClick={() => window.location.href = MessageUtils.getContentURL(flow, flow.request)}>Download
request</MenuItem>
<MenuItem onClick={() => window.location.href = MessageUtils.getContentURL(flow, response)}>Download
response</MenuItem>
</Dropdown>
}
}
return null;
}

View File

@ -1,13 +1,14 @@
import React, {ChangeEvent} from "react" import * as React from "react";
import {useDispatch} from "react-redux" import {useDispatch} from "react-redux"
import {toggleVisibility} from "../../ducks/eventLog" import * as eventLogActions from "../../ducks/eventLog"
import {useAppDispatch, useAppSelector} from "../../ducks"; import * as commandBarActions from "../../ducks/commandBar"
import * as optionsActions from "../../ducks/options"; import {useAppDispatch, useAppSelector} from "../../ducks"
import * as optionsActions from "../../ducks/options"
type MenuToggleProps = { type MenuToggleProps = {
value: boolean value: boolean
onChange: (e: ChangeEvent) => void onChange: (e: React.ChangeEvent) => void
children: React.ReactNode children: React.ReactNode
} }
@ -51,9 +52,23 @@ export function EventlogToggle() {
return ( return (
<MenuToggle <MenuToggle
value={visible} value={visible}
onChange={() => dispatch(toggleVisibility())} onChange={() => dispatch(eventLogActions.toggleVisibility())}
> >
Display Event Log Display Event Log
</MenuToggle> </MenuToggle>
) )
} }
export function CommandBarToggle() {
const dispatch = useDispatch(),
visible = useAppSelector(state => state.commandBar.visible);
return (
<MenuToggle
value={visible}
onChange={() => dispatch(commandBarActions.toggleVisibility())}
>
Display Command Bar
</MenuToggle>
)
}

View File

@ -1,5 +1,5 @@
import React from "react" import * as React from "react";
import { EventlogToggle, OptionsToggle } from "./MenuToggle" import {CommandBarToggle, EventlogToggle, OptionsToggle} from "./MenuToggle"
import Button from "../common/Button" import Button from "../common/Button"
import DocsLink from "../common/DocsLink" import DocsLink from "../common/DocsLink"
import HideInStatic from "../common/HideInStatic"; import HideInStatic from "../common/HideInStatic";
@ -44,6 +44,7 @@ export default function OptionMenu() {
<div className="menu-group"> <div className="menu-group">
<div className="menu-content"> <div className="menu-content">
<EventlogToggle/> <EventlogToggle/>
<CommandBarToggle/>
</div> </div>
<div className="menu-legend">View Options</div> <div className="menu-legend">View Options</div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React from "react" import * as React from "react";
import FilterInput from "./FilterInput" import FilterInput from "./FilterInput"
import * as flowsActions from "../../ducks/flows" import * as flowsActions from "../../ducks/flows"
import {setFilter, setHighlight} from "../../ducks/flows" import {setFilter, setHighlight} from "../../ducks/flows"
@ -6,9 +6,9 @@ import Button from "../common/Button"
import {update as updateOptions} from "../../ducks/options"; import {update as updateOptions} from "../../ducks/options";
import {useAppDispatch, useAppSelector} from "../../ducks"; import {useAppDispatch, useAppSelector} from "../../ducks";
MainMenu.title = "Start" StartMenu.title = "Start"
export default function MainMenu() { export default function StartMenu() {
return ( return (
<div className="main-menu"> <div className="main-menu">
<div className="menu-group"> <div className="menu-group">

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import Splitter from './common/Splitter' import Splitter from './common/Splitter'
import FlowTable from './FlowTable' import FlowTable from './FlowTable'
import FlowView from './FlowView' import FlowView from './FlowView'

View File

@ -1,4 +1,4 @@
import React from 'react' import * as React from "react"
import ModalList from './ModalList' import ModalList from './ModalList'
import { useAppSelector } from "../../ducks"; import { useAppSelector } from "../../ducks";

Some files were not shown because too many files have changed in this diff Show More