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 re
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.web
@ -13,7 +14,7 @@ import tornado.websocket
import mitmproxy.flow
import mitmproxy.tools.web.master # noqa
from mitmproxy import contentviews
from mitmproxy import certs, command, contentviews
from mitmproxy import flowfilter
from mitmproxy import http
from mitmproxy import io
@ -21,7 +22,27 @@ from mitmproxy import log
from mitmproxy import optmanager
from mitmproxy import version
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.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:
@ -37,7 +58,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"is_replay": flow.is_replay,
"type": flow.type,
"modified": flow.modified(),
"marked": flow.marked,
"marked": render_marker(flow.marked).replace(SYMBOL_MARK, "🔴") if flow.marked else "",
}
if flow.client_conn:
@ -46,6 +67,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"peername": flow.client_conn.peername,
"sockname": flow.client_conn.sockname,
"tls_established": flow.client_conn.tls_established,
"cert": cert_to_json(flow.client_conn.certificate_list),
"sni": flow.client_conn.sni,
"cipher": flow.client_conn.cipher,
"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_tls_setup": flow.client_conn.timestamp_tls_setup,
"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:
@ -67,6 +84,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"sockname": flow.server_conn.sockname,
"address": flow.server_conn.address,
"tls_established": flow.server_conn.tls_established,
"cert": cert_to_json(flow.server_conn.certificate_list),
"sni": flow.server_conn.sni,
"cipher": flow.server_conn.cipher,
"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_tls_setup": flow.server_conn.timestamp_tls_setup,
"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:
f["error"] = flow.error.get_state()
@ -87,7 +101,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
content_length: Optional[int]
content_hash: Optional[str]
if flow.request:
if flow.request.raw_content:
if flow.request.raw_content is not None:
content_length = len(flow.request.raw_content)
content_hash = hashlib.sha256(flow.request.raw_content).hexdigest()
else:
@ -109,7 +123,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"pretty_host": flow.request.pretty_host,
}
if flow.response:
if flow.response.raw_content:
if flow.response.raw_content is not None:
content_length = len(flow.response.raw_content)
content_hash = hashlib.sha256(flow.response.raw_content).hexdigest()
else:
@ -129,6 +143,18 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
if flow.response.data.trailers:
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
@ -147,7 +173,7 @@ class APIError(tornado.web.HTTPError):
class RequestHandler(tornado.web.RequestHandler):
application: "Application"
def write(self, chunk):
def write(self, chunk: Union[str, bytes, dict, list]):
# Writing arrays on the top level is ok nowadays.
# http://flask.pocoo.org/docs/0.11/security/#json-security
if isinstance(chunk, list):
@ -217,7 +243,7 @@ class IndexHandler(RequestHandler):
def get(self):
token = self.xsrf_token # https://github.com/tornadoweb/tornado/issues/645
assert token
self.render("index.html", static=False, version=version.VERSION)
self.render("index.html")
class FilterHelp(RequestHandler):
@ -278,14 +304,6 @@ class DumpFlows(RequestHandler):
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):
def post(self):
self.view.clear()
@ -329,12 +347,12 @@ class FlowHandler(RequestHandler):
self.view.remove([self.flow])
def put(self, flow_id):
flow = self.flow
flow: mitmproxy.flow.Flow = self.flow
flow.backup()
try:
for a, b in self.json.items():
if a == "request" and hasattr(flow, "request"):
request = flow.request
request: mitmproxy.http.Request = flow.request
for k, v in b.items():
if k in ["method", "scheme", "host", "path", "http_version"]:
setattr(request, k, str(v))
@ -354,7 +372,7 @@ class FlowHandler(RequestHandler):
raise APIError(400, f"Unknown update request.{k}: {v}")
elif a == "response" and hasattr(flow, "response"):
response = flow.response
response: mitmproxy.http.Response = flow.response
for k, v in b.items():
if k in ["msg", "http_version"]:
setattr(response, k, str(v))
@ -372,6 +390,8 @@ class FlowHandler(RequestHandler):
response.text = v
else:
raise APIError(400, f"Unknown update response.{k}: {v}")
elif a == "marked":
flow.marked = b
else:
raise APIError(400, f"Unknown update {a}: {b}")
except APIError:
@ -409,9 +429,6 @@ class FlowContent(RequestHandler):
def get(self, flow_id, message):
message = getattr(self.flow, message)
if not message.raw_content:
raise APIError(400, "No content.")
content_encoding = message.headers.get("Content-Encoding", None)
if content_encoding:
content_encoding = re.sub(r"[^\w]", "", content_encoding)
@ -436,40 +453,88 @@ class FlowContent(RequestHandler):
class FlowContentView(RequestHandler):
def get(self, flow_id, message, content_view):
message = getattr(self.flow, message)
def message_to_json(
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(
content_view.replace('_', ' '), message, self.flow
)
# if error:
# add event log
self.write(dict(
return dict(
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):
def get(self):
def get(self) -> None:
commands = {}
for (name, command) in self.master.commands.commands.items():
for (name, cmd) in self.master.commands.commands.items():
commands[name] = {
"args": [],
"signature_help": command.signature_help(),
"description": command.help
"help": cmd.help,
"parameters": [
{
"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:
commands[name]["args"].append(parameter.name)
self.write({"commands": commands})
self.write(commands)
def post(self):
result = self.master.commands.execute(self.json["command"])
if result is None:
self.write({"result": ""})
return
self.write({"result": result, "type": type(result).__name__, "history": self.master.commands.execute("commands.history.get")})
class ExecuteCommand(RequestHandler):
def post(self, cmd: str):
# TODO: We should parse query strings here, this API is painful.
try:
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):
@ -512,7 +577,7 @@ class Conf(RequestHandler):
conf = {
"static": False,
"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.set_header("content-type", "application/javascript")
@ -542,6 +607,7 @@ class Application(tornado.web.Application):
(r"/filter-help(?:\.json)?", FilterHelp),
(r"/updates", ClientConnection),
(r"/commands(?:\.json)?", Commands),
(r"/commands/(?P<cmd>[a-z.]+)", ExecuteCommand),
(r"/events(?:\.json)?", Events),
(r"/flows(?:\.json)?", Flows),
(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\-]+)/replay", ReplayFlow),
(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)/content.data", FlowContent),
(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/(?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),
(r"/clear", ClearAll),
(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"
}
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):
resp = self.fetch("/events")
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("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("}")
print("")
print("export type Option = keyof OptionsState")
print("")
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("}")

View File

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

View File

@ -1,11 +1,6 @@
//TODO: Move into some utils
.monospace() {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
}
.flow-detail {
width: 100%;
overflow:hidden;
overflow: hidden;
display: flex;
flex-direction: column;
@ -15,45 +10,52 @@
section {
overflow-y: scroll;
>article{
overflow: auto;
padding: 5px 12px 0;
}
>footer {
flex: 1;
padding: 5px 12px 10px;
> footer {
box-shadow: 0 0 3px gray;
padding: 2px;
margin: 0;
height:23px;
height: 23px;
}
}
section.detail, section.error{
overflow: auto;
padding: 5px 12px 0;
}
.first-line {
.monospace();
font-family: @font-family-monospace;
background-color: #428bca;
color: white;
margin: 0 -8px;
margin: 0 -8px 2px;
padding: 4px 8px;
border-radius: 5px;
word-break: break-all;
max-height: 100px;
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 {
margin: 0 0 5px;
margin: 0;
}
}
@ -64,30 +66,40 @@
padding: 0 3px;
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] {
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);
&.has-warning {
color: rgb(255, 184, 184);
}
&.has-success {
//color: green;
}
}
}
.view-all-content-btn{
float: right;
margin-bottom: 12px;
}
.flow-detail table {
.monospace();
td:nth-child(2) {
font-family: @font-family-monospace;
width: 70%;
}
width: 100%;
table-layout: fixed;
word-break: break-all;
@ -109,34 +121,45 @@
.connection-table {
td:first-child {
width: 50%;
padding-right: 1em;
}
}
.header-table {
td {
line-height: 1.3em;
}
.header-name {
width: 33%;
}
.header-value {
.headers, .trailers {
.kv-row {
margin-bottom: .3em;
max-height: 12.4ex;
overflow-y: auto;
}
// This exists so that you can copy
// and paste headers out of mitmweb.
.header-colon {
position: absolute;
opacity: 0;
.kv-key {
font-weight: bold;
}
.kv-value {
font-family: @font-family-monospace;
}
.inline-input {
display: inline-block;
width: 100%;
height: 100%;
background-color: white;
}
.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 {
@ -146,3 +169,23 @@
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 {
.marker {
}
.fa {
margin-left: 0;
font-size: 16px;
}
.fa-repeat {

View File

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

View File

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

View File

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

View File

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

View File

@ -38,12 +38,7 @@
.nav-tabs-sm {
> a {
padding: 0px 7px;
padding: 0 7px;
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-bg: #ffffff;
@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 { render } from "../../test-utils"
import fetchMock from 'fetch-mock';
@ -13,4 +13,4 @@ test('CommandBar Component', async () => {
await waitFor(() => {
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 withContentLoader from '../../../components/ContentView/ContentLoader'
import withContentLoader from '../../../components/contentviews/useContent'
import { TFlow } from '../../ducks/tutils'
import TestUtils from 'react-dom/test-utils'
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 ContentViewOptions from '../../../components/ContentView/ContentViewOptions'
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 TestUtils from 'react-dom/test-utils'
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 DownloadContentButton from '../../../components/ContentView/DownloadContentButton'
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 { ContentEmpty, ContentMissing, ContentTooLarge } from '../../../components/ContentView/MetaViews'
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 { Provider } from 'react-redux'
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 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 ViewSelector from '../../../components/ContentView/ViewSelector'
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 ContentView from '../../components/ContentView'
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 TestUtils from 'react-dom/test-utils'

View File

@ -1,6 +1,6 @@
jest.mock('../../components/EventLog/EventList')
import React from 'react'
import * as React from "react"
import renderer from 'react-test-renderer'
import EventLog, {PureEventLog} from '../../components/EventLog'
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 {
IconColumn,
MethodColumn,
PathColumn,
QuickActionsColumn,
SizeColumn,
StatusColumn,
TimeColumn,
TimeStampColumn,
TLSColumn
icon,
method,
path,
quickactions,
size,
status,
time,
timestamp,
tls
} from '../../../components/FlowTable/FlowColumns'
import {TFlow, TStore} from '../../ducks/tutils'
import {Provider} from 'react-redux'
@ -18,109 +18,109 @@ describe('Flowcolumns Components', () => {
let tflow = TFlow()
it('should render TLSColumn', () => {
let tlsColumn = renderer.create(<TLSColumn flow={tflow}/>),
let tlsColumn = renderer.create(<tls flow={tflow}/>),
tree = tlsColumn.toJSON()
expect(tree).toMatchSnapshot()
})
it('should render IconColumn', () => {
let tflow = TFlow(),
iconColumn = renderer.create(<IconColumn flow={tflow}/>),
iconColumn = renderer.create(<icon flow={tflow}/>),
tree = iconColumn.toJSON()
// plain
expect(tree).toMatchSnapshot()
// not modified
tflow.response.status_code = 304
iconColumn = renderer.create(<IconColumn flow={tflow}/>)
iconColumn = renderer.create(<icon flow={tflow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
// redirect
tflow.response.status_code = 302
iconColumn = renderer.create(<IconColumn flow={tflow}/>)
iconColumn = renderer.create(<icon flow={tflow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
// image
let imageFlow = TFlow()
imageFlow.response.headers = [['Content-Type', 'image/jpeg']]
iconColumn = renderer.create(<IconColumn flow={imageFlow}/>)
iconColumn = renderer.create(<icon flow={imageFlow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
// javascript
let jsFlow = TFlow()
jsFlow.response.headers = [['Content-Type', 'application/x-javascript']]
iconColumn = renderer.create(<IconColumn flow={jsFlow}/>)
iconColumn = renderer.create(<icon flow={jsFlow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
// css
let cssFlow = TFlow()
cssFlow.response.headers = [['Content-Type', 'text/css']]
iconColumn = renderer.create(<IconColumn flow={cssFlow}/>)
iconColumn = renderer.create(<icon flow={cssFlow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
// html
let htmlFlow = TFlow()
htmlFlow.response.headers = [['Content-Type', 'text/html']]
iconColumn = renderer.create(<IconColumn flow={htmlFlow}/>)
iconColumn = renderer.create(<icon flow={htmlFlow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
// default
let fooFlow = TFlow()
fooFlow.response.headers = [['Content-Type', 'foo']]
iconColumn = renderer.create(<IconColumn flow={fooFlow}/>)
iconColumn = renderer.create(<icon flow={fooFlow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
// no response
tflow.response = null
iconColumn = renderer.create(<IconColumn flow={tflow}/>)
iconColumn = renderer.create(<icon flow={tflow}/>)
tree = iconColumn.toJSON()
expect(tree).toMatchSnapshot()
})
it('should render pathColumn', () => {
let tflow = TFlow(),
pathColumn = renderer.create(<PathColumn flow={tflow}/>),
pathColumn = renderer.create(<path flow={tflow}/>),
tree = pathColumn.toJSON()
expect(tree).toMatchSnapshot()
tflow.error.msg = 'Connection killed.'
tflow.intercepted = true
pathColumn = renderer.create(<PathColumn flow={tflow}/>)
pathColumn = renderer.create(<path flow={tflow}/>)
tree = pathColumn.toJSON()
expect(tree).toMatchSnapshot()
})
it('should render MethodColumn', () => {
let methodColumn = renderer.create(<MethodColumn flow={tflow}/>),
let methodColumn = renderer.create(<method flow={tflow}/>),
tree = methodColumn.toJSON()
expect(tree).toMatchSnapshot()
})
it('should render StatusColumn', () => {
let statusColumn = renderer.create(<StatusColumn flow={tflow}/>),
let statusColumn = renderer.create(<status flow={tflow}/>),
tree = statusColumn.toJSON()
expect(tree).toMatchSnapshot()
})
it('should render SizeColumn', () => {
let sizeColumn = renderer.create(<SizeColumn flow={tflow}/>),
let sizeColumn = renderer.create(<size flow={tflow}/>),
tree = sizeColumn.toJSON()
expect(tree).toMatchSnapshot()
})
it('should render TimeColumn', () => {
let tflow = TFlow(),
timeColumn = renderer.create(<TimeColumn flow={tflow}/>),
timeColumn = renderer.create(<time flow={tflow}/>),
tree = timeColumn.toJSON()
expect(tree).toMatchSnapshot()
tflow.response = null
timeColumn = renderer.create(<TimeColumn flow={tflow}/>)
timeColumn = renderer.create(<time flow={tflow}/>)
tree = timeColumn.toJSON()
expect(tree).toMatchSnapshot()
})
it('should render TimeStampColumn', () => {
let timeStampColumn = renderer.create(<TimeStampColumn flow={tflow}/>),
let timeStampColumn = renderer.create(<timestamp flow={tflow}/>),
tree = timeStampColumn.toJSON()
expect(tree).toMatchSnapshot()
})
@ -129,7 +129,7 @@ describe('Flowcolumns Components', () => {
let store = TStore(),
provider = renderer.create(
<Provider store={store}>
<QuickActionsColumn flow={tflow}/>
<quickactions flow={tflow}/>
</Provider>),
tree = provider.toJSON()
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 {testState} from '../../ducks/tutils'
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 {Provider} from 'react-redux'
import {TStore} from '../../ducks/tutils'
@ -23,7 +23,7 @@ test("FlowTableHead Component", async () => {
fireEvent.click(screen.getByText("Size"))
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 {PureFlowTable as FlowTable} from '../../components/FlowTable'
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 Details, { TimeStamp, ConnectionInfo, CertificateInfo, Timing } from '../../../components/FlowView/Details'
import Connection, { TimeStamp, ConnectionInfo, CertificateInfo, Timing } from '../../../components/FlowView/Connection'
import { TFlow } from '../../ducks/tutils'
let tflow = TFlow()
@ -43,7 +43,7 @@ describe('Timing Component', () => {
describe('Details Component', () => {
it('should render correctly', () => {
let details = renderer.create(<Details flow={tflow}/>),
let details = renderer.create(<Connection flow={tflow}/>),
tree = details.toJSON()
expect(tree).toMatchSnapshot()
})
@ -53,7 +53,6 @@ describe('Details Component', () => {
tflowServerAddressNull.server_conn.address = null
tflowServerAddressNull.server_conn.ip_address = null
tflowServerAddressNull.server_conn.alpn_proto_negotiated = null
tflowServerAddressNull.server_conn.sni = null
tflowServerAddressNull.server_conn.ssl_established = false
tflowServerAddressNull.server_conn.tls_version = null
@ -61,8 +60,8 @@ describe('Details Component', () => {
tflowServerAddressNull.server_conn.timestamp_ssl_setup = null
tflowServerAddressNull.server_conn.timestamp_start = null
tflowServerAddressNull.server_conn.timestamp_end = null
let details = renderer.create(<Details flow={tflowServerAddressNull}/>),
let details = renderer.create(<Connection flow={tflowServerAddressNull}/>),
tree = details.toJSON()
expect(tree).toMatchSnapshot()
})

View File

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

View File

@ -1,15 +1,15 @@
jest.mock('../../../components/ContentView', () => () => null)
import React from 'react'
import * as React from "react"
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 {TFlow, TStore} from '../../ducks/tutils'
import {updateEdit} from '../../../ducks/ui/flow'
import {parseUrl} from '../../../flow/utils'
import ContentView from '../../../components/ContentView'
import ContentViewOptions from '../../../components/ContentView/ContentViewOptions'
import Headers from '../../../components/FlowView/Headers'
import ValueEditor from '../../../components/ValueEditor/ValueEditor'
import KeyValueListEditor from '../../../components/editors/KeyValueListEditor'
import ValueEditor from '../../../components/editors/ValueEditor'
global.fetch = jest.fn()
@ -59,7 +59,7 @@ describe('Request Component', () => {
})
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')
expect(store.getActions()).toEqual([updateEdit({request: {headers: 'foo'}})])
})
@ -118,7 +118,7 @@ describe('Response Component', () => {
})
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')
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 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 {TFlow} from '../../ducks/tutils'
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
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 * as connectionActions from '../../../ducks/connection'
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 FileMenu from '../../../components/Header/FileMenu'
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 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 FilterInput from '../../../components/Header/FilterInput'
import FilterDocs from '../../../components/Header/FilterDocs'

View File

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

View File

@ -1,8 +1,8 @@
import React from 'react'
import MainMenu from '../../../components/Header/MainMenu'
import * as React from "react"
import StartMenu from '../../../components/Header/StartMenu'
import {render} from "../../test-utils"
test("MainMenu", () => {
const {asFragment} = render(<MainMenu/>);
const {asFragment} = render(<StartMenu/>);
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 {EventlogToggle, MenuToggle, OptionsToggle} from '../../../components/Header/MenuToggle'
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 { Provider } from 'react-redux'
import OptionMenu from '../../../components/Header/OptionMenu'

View File

@ -1,6 +1,104 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
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>
<div
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 {render} from "../../test-utils"
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 { 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 { 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 TestUtils from 'react-dom/test-utils'
import ValidateEditor from '../../../components/ValueEditor/ValidateEditor'
import ValidateEditor from '../../../components/editors/ValidateEditor'
describe('ValidateEditor Component', () => {
let validateFn = jest.fn( content => content.length == 3),
@ -9,14 +9,14 @@ describe('ValidateEditor Component', () => {
it('should render correctly', () => {
let validateEditor = renderer.create(
<ValidateEditor content="foo" onDone={doneFn} isValid={validateFn}/>
<ValidateEditor content="foo" onEditDone={doneFn} isValid={validateFn}/>
),
tree = validateEditor.toJSON()
expect(tree).toMatchSnapshot()
})
let validateEditor = TestUtils.renderIntoDocument(
<ValidateEditor content="foo" onDone={doneFn} isValid={validateFn}/>
<ValidateEditor content="foo" onEditDone={doneFn} isValid={validateFn}/>
)
it('should handle componentWillReceiveProps', () => {
let mockProps = {

View File

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

View File

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

View File

@ -2,7 +2,7 @@ jest.mock('../../utils')
import reduceFlows 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 { createStore } from "./tutils"
@ -18,7 +18,7 @@ describe('flow reducer', () => {
filter: null,
sort: { column: null, desc: false },
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', () => {
it('should return initial state', () => {
expect(reduceStore(undefined, {})).toEqual({
expect(reduce(undefined, {})).toEqual({
byId: {},
list: [],
listIndex: {},
@ -14,8 +14,8 @@ describe('store reducer', () => {
it('should handle add action', () => {
let a = {id: 1},
b = {id: 9},
state = reduceStore(undefined, {})
expect(state = reduceStore(state, storeActions.add(a))).toEqual({
state = reduce(undefined, {})
expect(state = reduce(state, storeActions.add(a))).toEqual({
byId: { 1: a },
listIndex: { 1: 0 },
list: [ a ],
@ -23,7 +23,7 @@ describe('store reducer', () => {
viewIndex: { 1: 0 },
})
expect(state = reduceStore(state, storeActions.add(b))).toEqual({
expect(state = reduce(state, storeActions.add(b))).toEqual({
byId: { 1: a, 9: b },
listIndex: { 1: 0, 9: 1 },
list: [ a, b ],
@ -33,7 +33,7 @@ describe('store reducer', () => {
// add item and sort them
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({
byId: {...state.byId, 0: c },
list: [...state.list, c ],
@ -46,15 +46,15 @@ describe('store reducer', () => {
it('should not add the item with duplicated id', () => {
let a = {id: 1},
state = reduceStore(undefined, storeActions.add(a))
expect(reduceStore(state, storeActions.add(a))).toEqual(state)
state = reduce(undefined, storeActions.add(a))
expect(reduce(state, storeActions.add(a))).toEqual(state)
})
it('should handle update action', () => {
let a = {id: 1, foo: "foo"},
updated = {...a, foo: "bar"},
state = reduceStore(undefined, storeActions.add(a))
expect(reduceStore(state, storeActions.update(updated))).toEqual({
state = reduce(undefined, storeActions.add(a))
expect(reduce(state, storeActions.update(updated))).toEqual({
byId: { 1: updated },
list: [ updated ],
listIndex: { 1: 0 },
@ -65,8 +65,8 @@ describe('store reducer', () => {
it('should handle update action with filter', () => {
let a = {id: 0}, b = {id: 1},
state = reduceStore(undefined, storeActions.receive([a, b]))
state = reduceStore(state, storeActions.update(b,
state = reduce(undefined, storeActions.receive([a, b]))
state = reduce(state, storeActions.update(b,
item => {return item.id != 1}))
expect(state).toEqual({
byId: { 0: a, 1: b },
@ -75,7 +75,7 @@ describe('store reducer', () => {
view: [ a ],
viewIndex: { 0: 0 }
})
expect(reduceStore(state, storeActions.update(b,
expect(reduce(state, storeActions.update(b,
item => {return item.id != 0}))).toEqual({
byId: { 0: a, 1: b },
list: [ a, b ],
@ -88,8 +88,8 @@ describe('store reducer', () => {
it('should handle update action with sort', () => {
let a = {id: 2},
b = {id: 3},
state = reduceStore(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.update(b, undefined,
state = reduce(undefined, storeActions.receive([a, b]))
expect(reduce(state, storeActions.update(b, undefined,
(a, b) => {return b.id - a.id}))).toEqual({
// sort by id in descending order
byId: { 2: a, 3: b },
@ -99,8 +99,8 @@ describe('store reducer', () => {
viewIndex: { 2: 1, 3: 0 },
})
let state1 = reduceStore(undefined, storeActions.receive([b, a]))
expect(reduceStore(state1, storeActions.update(b, undefined,
let state1 = reduce(undefined, storeActions.receive([b, a]))
expect(reduce(state1, storeActions.update(b, undefined,
(a, b) => {return a.id - b.id}))).toEqual({
// sort by id in ascending order
byId: { 2: a, 3: b },
@ -114,8 +114,8 @@ describe('store reducer', () => {
it('should set filter', () => {
let a = { id: 1 },
b = { id: 2 },
state = reduceStore(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.setFilter(
state = reduce(undefined, storeActions.receive([a, b]))
expect(reduce(state, storeActions.setFilter(
item => {return item.id != 1}
))).toEqual({
byId: { 1 :a, 2: b },
@ -129,8 +129,8 @@ describe('store reducer', () => {
it('should set sort', () => {
let a = { id: 1 },
b = { id: 2 },
state = reduceStore(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.setSort(
state = reduce(undefined, storeActions.receive([a, b]))
expect(reduce(state, storeActions.setSort(
(a, b) => { return b.id - a.id }
))).toEqual({
byId: { 1: a, 2: b },
@ -143,8 +143,8 @@ describe('store reducer', () => {
it('should handle remove action', () => {
let a = { id: 1 }, b = { id: 2},
state = reduceStore(undefined, storeActions.receive([a, b]))
expect(reduceStore(state, storeActions.remove(1))).toEqual({
state = reduce(undefined, storeActions.receive([a, b]))
expect(reduce(state, storeActions.remove(1))).toEqual({
byId: { 2: b },
list: [ b ],
listIndex: { 2: 0 },
@ -152,13 +152,13 @@ describe('store reducer', () => {
viewIndex: { 2: 0 },
})
expect(reduceStore(state, storeActions.remove(3))).toEqual(state)
expect(reduce(state, storeActions.remove(3))).toEqual(state)
})
it('should handle receive list', () => {
let a = { id: 1 }, b = { id: 2 },
list = [ a, b ]
expect(reduceStore(undefined, storeActions.receive(list))).toEqual({
expect(reduce(undefined, storeActions.receive(list))).toEqual({
byId: { 1: a, 2: b },
list: [ a, b ],
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 {Provider} from 'react-redux'
// Import your own reducer

View File

@ -71,20 +71,3 @@ describe('getDiff', () => {
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 {Provider} from 'react-redux'
@ -11,14 +11,17 @@ import {store} from "./ducks";
useUrlState(store)
// @ts-ignore
if (window.MITMWEB_STATIC) {
// @ts-ignore
window.backend = new StaticBackend(store)
} else {
// @ts-ignore
window.backend = new WebSocketBackend(store)
}
window.addEventListener('error', msg => {
store.dispatch(addLog(msg))
window.addEventListener('error', (e: ErrorEvent) => {
store.dispatch(addLog(`${e.message}\n${e.error.stack}`))
})
document.addEventListener('DOMContentLoaded', () => {

View File

@ -37,8 +37,8 @@ export function Results({results}: ResultProps) {
useEffect(() => {
if (resultElement) {
resultElement.current.addEventListener('DOMNodeInserted', event => {
const { currentTarget: target } = event;
resultElement.current.addEventListener('DOMNodeInserted', (event) => {
const target = event.currentTarget as Element;
target.scroll({ top: target.scrollHeight, behavior: 'auto' });
});
}
@ -99,7 +99,7 @@ export default function CommandBar() {
.then(data => {
setAllCommands(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>
)
}
}

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 ReactDOM from 'react-dom'
import { connect } from 'react-redux'

View File

@ -1,12 +1,13 @@
import React, {useState} from 'react'
import {useDispatch} from 'react-redux'
import classnames from 'classnames'
import {RequestUtils, ResponseUtils} from '../../flow/utils'
import {fetchApi, formatSize, formatTimeDelta, formatTimeStamp} from '../../utils'
import {endTime, getTotalSize, RequestUtils, ResponseUtils, startTime} from '../../flow/utils'
import {formatSize, formatTimeDelta, formatTimeStamp} from '../../utils'
import * as flowActions from "../../ducks/flows";
import {addInterceptFilter} from "../../ducks/options"
import Dropdown, {MenuItem, SubMenu} from "../common/Dropdown";
import {Flow} from "../../flow";
import {copy} from "../../flow/export";
type FlowColumnProps = {
@ -16,38 +17,38 @@ type FlowColumnProps = {
interface FlowColumn {
(props: FlowColumnProps): JSX.Element;
headerClass: string;
headerName: string;
headerName: string; // Shown in the UI
sortKey: (flow: Flow) => any;
}
export const TLSColumn: FlowColumn = ({flow}) => {
export const tls: FlowColumn = ({flow}) => {
return (
<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'
TLSColumn.headerName = ''
export const IconColumn: FlowColumn = ({flow}) => {
export const icon: FlowColumn = ({flow}) => {
return (
<td className="col-icon">
<div className={classnames('resource-icon', getIcon(flow))}/>
</td>
)
}
IconColumn.headerClass = 'col-icon'
IconColumn.headerName = ''
icon.headerName = ''
icon.sortKey = flow => 0
const getIcon = (flow: Flow): string => {
if (flow.type !== "http" || !flow.response) {
return 'resource-icon-plain'
}
if (flow.websocket) {
return 'resource-icon-websocket'
}
var contentType = ResponseUtils.getContentType(flow.response) || ''
// @todo We should assign a type to the flow somewhere else.
if (flow.response.status_code === 304) {
return 'resource-icon-not-modified'
}
@ -70,7 +71,7 @@ const getIcon = (flow: Flow): string => {
return 'resource-icon-plain'
}
export const PathColumn: FlowColumn = ({flow}) => {
export const path: FlowColumn = ({flow}) => {
let err;
if (flow.error) {
if (flow.error.msg === "Connection killed.") {
@ -88,24 +89,23 @@ export const PathColumn: FlowColumn = ({flow}) => {
<i className="fa fa-fw fa-pause pull-right"/>
)}
{err}
<span className="marker pull-right">{flow.marked}</span>
{flow.type === "http" ? RequestUtils.pretty_url(flow.request) : null}
</td>
)
};
path.headerName = 'Path'
path.sortKey = flow => flow.type === "http" && RequestUtils.pretty_url(flow.request)
PathColumn.headerClass = 'col-path'
PathColumn.headerName = 'Path'
export const MethodColumn: FlowColumn = ({flow}) => {
export const method: FlowColumn = ({flow}) => {
return (
<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'
MethodColumn.headerName = 'Method'
export const StatusColumn: FlowColumn = ({flow}) => {
export const status: FlowColumn = ({flow}) => {
let color = 'darkred';
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>
)
}
status.headerName = 'Status'
status.sortKey = flow => flow.type === "http" && flow.response && flow.response.status_code
StatusColumn.headerClass = 'col-status'
StatusColumn.headerName = 'Status'
export const SizeColumn: FlowColumn = ({flow}) => {
export const size: FlowColumn = ({flow}) => {
return (
<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'
SizeColumn.headerName = 'Size'
export const TimeColumn: FlowColumn = ({flow}) => {
export const time: FlowColumn = ({flow}) => {
const start = startTime(flow), end = endTime(flow);
return (
<td className="col-time">
{flow.type === "http" && flow.response?.timestamp_end ? (
formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start))
{start && end ? (
formatTimeDelta(1000 * (end - start))
) : (
'...'
)}
</td>
)
}
time.headerName = 'Time'
time.sortKey = flow => {
const start = startTime(flow), end = endTime(flow);
return start && end && end - start;
}
TimeColumn.headerClass = 'col-time'
TimeColumn.headerName = 'Time'
export const TimeStampColumn: FlowColumn = ({flow}) => {
export const timestamp: FlowColumn = ({flow}) => {
const start = startTime(flow);
return (
<td className="col-start">
{flow.type === "http" && flow.request.timestamp_start ? (
formatTimeStamp(flow.request.timestamp_start)
{start ? (
formatTimeStamp(start)
) : (
'...'
)}
</td>
)
}
timestamp.headerName = 'Start time'
timestamp.sortKey = flow => startTime(flow)
TimeStampColumn.headerClass = 'col-timestamp'
TimeStampColumn.headerName = 'TimeStamp'
const markers = {
":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()
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;
if (flow.intercepted) {
resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.resume(flow))}>
@ -221,11 +211,21 @@ export const QuickActionsColumn: FlowColumn = ({flow}) => {
onOpen={setOpen}
options={{placement: "bottom-end"}}>
<SubMenu title="Copy...">
<MenuItem onClick={() => copy("raw_request")}>Copy raw request</MenuItem>
<MenuItem onClick={() => copy("raw_response")}>Copy raw response</MenuItem>
<MenuItem onClick={() => copy("raw")}>Copy raw request and response</MenuItem>
<MenuItem onClick={() => copy("curl")}>Copy as cURL</MenuItem>
<MenuItem onClick={() => copy("httpie")}>Copy as HTTPie</MenuItem>
<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>
</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 title="Intercept requests like this">
<MenuItem onClick={() => filt(`~q ${flow.request.host}`)}>
@ -260,21 +260,5 @@ export const QuickActionsColumn: FlowColumn = ({flow}) => {
)
}
QuickActionsColumn.headerClass = 'col-quickactions'
QuickActionsColumn.headerName = ''
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;
}
quickactions.headerName = ''
quickactions.sortKey = flow => 0;

View File

@ -3,7 +3,7 @@ import classnames from 'classnames'
import {Flow} from "../../flow";
import {useAppDispatch, useAppSelector} from "../../ducks";
import {select} from '../../ducks/flows'
import {columns, QuickActionsColumn} from "./FlowColumns";
import * as columns from "./FlowColumns";
type FlowRowProps = {
flow: Flow
@ -33,7 +33,7 @@ export default React.memo(function FlowRow({flow, selected, highlighted}: FlowRo
dispatch(select(flow.id));
}, [flow]);
const displayColumns = displayColumnNames.map(x => columns[x]).concat(QuickActionsColumn);
const displayColumns = displayColumnNames.map(x => columns[x]).filter(x => x).concat(columns.quickactions);
return (
<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 {columns, QuickActionsColumn} from './FlowColumns'
import * as columns from './FlowColumns'
import {setSort} from '../../ducks/flows'
import {useAppDispatch, useAppSelector} from "../../ducks";
@ -12,12 +12,12 @@ export default React.memo(function FlowTableHead() {
displayColumnNames = useAppSelector(state => state.options.web_columns);
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 (
<tr>
{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}
onClick={() => dispatch(setSort(Column.name, Column.name !== sortColumn ? false : !sortDesc))}>
{Column.headerName}

View File

@ -1,44 +1,66 @@
import React from 'react'
import _ from 'lodash'
import * as React from "react"
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 { ErrorView as Error, Request, Response } from './FlowView/Messages'
import Details from './FlowView/Details'
import { selectTab } from '../ducks/ui/flow'
import {selectTab} from '../ducks/ui/flow'
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() {
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)
// only display available tab names
const tabs = ['request', 'response', 'error'].filter(k => flow[k])
tabs.push("details")
if (tabs.indexOf(tabName) < 0) {
if (tabName === 'response' && flow.error) {
tabName = 'error'
} else if (tabName === 'error' && flow.response) {
tabName = 'response'
let active = useAppSelector(state => state.ui.flow.tab)
if (tabs.indexOf(active) < 0) {
if (active === 'response' && flow.error) {
active = 'error'
} else if (active === 'error' && "response" in flow) {
active = 'response'
} else {
tabName = tabs[0]
active = tabs[0]
}
}
const Tab = allTabs[_.capitalize(tabName)]
const Tab = allTabs[active];
return (
<div className="flow-detail">
<Nav
tabs={tabs}
active={tabName}
onSelectTab={(tab: string) => dispatch(selectTab(tab))}
/>
<nav className="nav-tabs nav-tabs-sm">
{tabs.map(tabId => (
<a key={tabId} href="#" className={classnames({active: active === tabId})}
onClick={event => {
event.preventDefault()
dispatch(selectTab(tabId))
}}>
{allTabs[tabId].name}
</a>
))}
</nav>
<Tab flow={flow}/>
</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 HideInStatic from '../components/common/HideInStatic'
import {useAppSelector} from "../ducks";
@ -7,7 +7,7 @@ export default function Footer() {
const version = useAppSelector(state => state.conf.version);
let {
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);
return (
@ -18,6 +18,9 @@ export default function Footer() {
{intercept && (
<span className="label label-success">Intercept: {intercept}</span>
)}
{ssl_insecure && (
<span className="label label-danger">ssl_insecure</span>
)}
{showhost && (
<span className="label label-success">showhost</span>
)}
@ -57,8 +60,8 @@ export default function Footer() {
</span>)
}
</HideInStatic>
<span className="label label-info" title="Mitmproxy Version">
{version}
<span className="label label-default" title="Mitmproxy Version">
mitmproxy {version}
</span>
</div>
</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 {useAppSelector} from "../../ducks";

View File

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

View File

@ -1,76 +1,121 @@
import React from "react"
import * as React from "react";
import Button from "../common/Button"
import { MessageUtils } from "../../flow/utils.js"
import {MessageUtils} from "../../flow/utils.js"
import HideInStatic from "../common/HideInStatic";
import { useAppDispatch, useAppSelector } from "../../ducks";
import {useAppDispatch, useAppSelector} from "../../ducks";
import {
resume as resumeFlow,
replay as replayFlow,
duplicate as duplicateFlow,
revert as revertFlow,
kill as killFlow,
remove as removeFlow,
kill as killFlow
replay as replayFlow,
resume as resumeFlow,
revert as revertFlow
} from "../../ducks/flows"
import Dropdown, {MenuItem} from "../common/Dropdown";
import {copy} from "../../flow/export";
import {Flow} from "../../flow";
FlowMenu.title = 'Flow'
export default function FlowMenu() {
export default function FlowMenu(): JSX.Element {
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)
return <div/>
return (
<div className="flow-menu">
<HideInStatic>
<div className="menu-group">
<div className="menu-content">
<Button title="[r]eplay flow" icon="fa-repeat text-primary"
onClick={() => dispatch(replayFlow(flow))}>
Replay
</Button>
<Button title="[D]uplicate flow" icon="fa-copy text-info"
onClick={() => dispatch(duplicateFlow(flow))}>
Duplicate
</Button>
<Button disabled={!flow || !flow.modified} title="revert changes to flow [V]"
icon="fa-history text-warning" onClick={() => dispatch(revertFlow(flow))}>
Revert
</Button>
<Button title="[d]elete flow" icon="fa-trash text-danger"
onClick={() => dispatch(removeFlow(flow))}>
Delete
</Button>
<div className="menu-group">
<div className="menu-content">
<Button title="[r]eplay flow" icon="fa-repeat text-primary"
onClick={() => dispatch(replayFlow(flow))}
disabled={!(flow.type === "http" && !flow.websocket)}
>
Replay
</Button>
<Button title="[D]uplicate flow" icon="fa-copy text-info"
onClick={() => dispatch(duplicateFlow(flow))}>
Duplicate
</Button>
<Button disabled={!flow || !flow.modified} title="revert changes to flow [V]"
icon="fa-history text-warning" onClick={() => dispatch(revertFlow(flow))}>
Revert
</Button>
<Button title="[d]elete flow" icon="fa-trash text-danger"
onClick={() => dispatch(removeFlow(flow))}>
Delete
</Button>
</div>
<div className="menu-legend">Flow Modification</div>
</div>
<div className="menu-legend">Flow Modification</div>
</div>
</HideInStatic>
<div className="menu-group">
<div className="menu-content">
<Button title="download" icon="fa-download"
onClick={() => window.location = MessageUtils.getContentURL(flow, flow.response)}>
Download
</Button>
<DownloadButton flow={flow}/>
<Dropdown className="" text={
<Button title="Export flow." icon="fa-clone" onClick={() => 1}>Export</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 className="menu-legend">Export</div>
</div>
<HideInStatic>
<div className="menu-group">
<div className="menu-content">
<Button disabled={!flow || !flow.intercepted} title="[a]ccept intercepted flow"
icon="fa-play text-success" onClick={() => dispatch(resumeFlow(flow))}>
Resume
</Button>
<Button disabled={!flow || !flow.intercepted} title="kill intercepted flow [x]"
icon="fa-times text-danger" onClick={() => dispatch(killFlow(flow))}>
Abort
</Button>
<div className="menu-group">
<div className="menu-content">
<Button disabled={!flow || !flow.intercepted} title="[a]ccept intercepted flow"
icon="fa-play text-success" onClick={() => dispatch(resumeFlow(flow))}>
Resume
</Button>
<Button disabled={!flow || !flow.intercepted} title="kill intercepted flow [x]"
icon="fa-times text-danger" onClick={() => dispatch(killFlow(flow))}>
Abort
</Button>
</div>
<div className="menu-legend">Interception</div>
</div>
<div className="menu-legend">Interception</div>
</div>
</HideInStatic>
</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 {toggleVisibility} from "../../ducks/eventLog"
import {useAppDispatch, useAppSelector} from "../../ducks";
import * as optionsActions from "../../ducks/options";
import * as eventLogActions from "../../ducks/eventLog"
import * as commandBarActions from "../../ducks/commandBar"
import {useAppDispatch, useAppSelector} from "../../ducks"
import * as optionsActions from "../../ducks/options"
type MenuToggleProps = {
value: boolean
onChange: (e: ChangeEvent) => void
onChange: (e: React.ChangeEvent) => void
children: React.ReactNode
}
@ -51,9 +52,23 @@ export function EventlogToggle() {
return (
<MenuToggle
value={visible}
onChange={() => dispatch(toggleVisibility())}
onChange={() => dispatch(eventLogActions.toggleVisibility())}
>
Display Event Log
</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 { EventlogToggle, OptionsToggle } from "./MenuToggle"
import * as React from "react";
import {CommandBarToggle, EventlogToggle, OptionsToggle} from "./MenuToggle"
import Button from "../common/Button"
import DocsLink from "../common/DocsLink"
import HideInStatic from "../common/HideInStatic";
@ -44,6 +44,7 @@ export default function OptionMenu() {
<div className="menu-group">
<div className="menu-content">
<EventlogToggle/>
<CommandBarToggle/>
</div>
<div className="menu-legend">View Options</div>
</div>

View File

@ -1,4 +1,4 @@
import React from "react"
import * as React from "react";
import FilterInput from "./FilterInput"
import * as flowsActions 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 {useAppDispatch, useAppSelector} from "../../ducks";
MainMenu.title = "Start"
StartMenu.title = "Start"
export default function MainMenu() {
export default function StartMenu() {
return (
<div className="main-menu">
<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 FlowTable from './FlowTable'
import FlowView from './FlowView'

View File

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

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