aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--mitmproxy/builtins/dumper.py3
-rw-r--r--mitmproxy/builtins/replace.py4
-rw-r--r--mitmproxy/console/flowview.py2
-rw-r--r--mitmproxy/console/grideditor.py719
-rw-r--r--mitmproxy/console/grideditor/__init__.py2
-rw-r--r--mitmproxy/console/grideditor/base.py425
-rw-r--r--mitmproxy/console/grideditor/col_bytes.py103
-rw-r--r--mitmproxy/console/grideditor/col_subgrid.py51
-rw-r--r--mitmproxy/console/grideditor/col_text.py55
-rw-r--r--mitmproxy/console/grideditor/editors.py239
-rw-r--r--mitmproxy/console/help.py3
-rw-r--r--mitmproxy/console/master.py26
-rw-r--r--mitmproxy/console/options.py2
-rw-r--r--mitmproxy/console/searchable.py4
-rw-r--r--mitmproxy/console/statusbar.py2
-rw-r--r--mitmproxy/console/window.py52
-rw-r--r--mitmproxy/web/app.py13
-rw-r--r--mitmproxy/web/static/images/favicon.icobin0 -> 365133 bytes
-rw-r--r--mitmproxy/web/templates/index.html3
-rw-r--r--netlib/encoding.py2
-rw-r--r--netlib/http/request.py26
-rw-r--r--netlib/http/url.py42
-rw-r--r--netlib/strutils.py3
-rw-r--r--release/setup.py2
-rw-r--r--setup.py8
-rw-r--r--test/netlib/http/test_url.py44
-rw-r--r--tox.ini2
-rw-r--r--web/package.json10
-rw-r--r--web/src/css/flowdetail.less14
-rw-r--r--web/src/images/favicon.icobin0 -> 365133 bytes
-rw-r--r--web/src/js/components/ContentView.jsx67
-rw-r--r--web/src/js/components/ContentView/CodeEditor.jsx21
-rw-r--r--web/src/js/components/ContentView/ContentLoader.jsx104
-rw-r--r--web/src/js/components/ContentView/ContentViews.jsx45
-rw-r--r--web/src/js/components/ContentView/DownloadContentButton.jsx18
-rw-r--r--web/src/js/components/ContentView/MetaViews.jsx18
-rw-r--r--web/src/js/components/ContentView/UploadContentButton.jsx28
-rw-r--r--web/src/js/components/ContentView/ViewSelector.jsx51
-rw-r--r--web/src/js/components/FlowView/Headers.jsx3
-rw-r--r--web/src/js/components/FlowView/Messages.jsx21
-rw-r--r--web/src/js/components/FlowView/ToggleEdit.jsx7
-rw-r--r--web/src/js/components/ValueEditor/ValueEditor.jsx2
-rw-r--r--web/src/js/components/common/CodeEditor.jsx30
-rw-r--r--web/src/js/ducks/flows.js5
-rw-r--r--web/src/js/ducks/ui/flow.js37
-rw-r--r--web/src/js/utils.js13
-rw-r--r--web/src/templates/index.html3
47 files changed, 1344 insertions, 990 deletions
diff --git a/mitmproxy/builtins/dumper.py b/mitmproxy/builtins/dumper.py
index 74c2e6b2..59f9349d 100644
--- a/mitmproxy/builtins/dumper.py
+++ b/mitmproxy/builtins/dumper.py
@@ -231,7 +231,8 @@ class Dumper(object):
self._echo_message(f.response)
if f.error:
- self.echo(" << {}".format(f.error.msg), bold=True, fg="red")
+ msg = strutils.escape_control_characters(f.error.msg)
+ self.echo(" << {}".format(msg), bold=True, fg="red")
def match(self, f):
if self.flow_detail == 0:
diff --git a/mitmproxy/builtins/replace.py b/mitmproxy/builtins/replace.py
index 74d30c05..2c94fbb5 100644
--- a/mitmproxy/builtins/replace.py
+++ b/mitmproxy/builtins/replace.py
@@ -13,8 +13,8 @@ class Replace:
.replacements is a list of tuples (fpat, rex, s):
fpatt: a string specifying a filter pattern.
- rex: a regular expression.
- s: the replacement string
+ rex: a regular expression, as bytes.
+ s: the replacement string, as bytes
"""
lst = []
for fpatt, rex, s in options.replacements:
diff --git a/mitmproxy/console/flowview.py b/mitmproxy/console/flowview.py
index fe46ba18..c354563f 100644
--- a/mitmproxy/console/flowview.py
+++ b/mitmproxy/console/flowview.py
@@ -632,7 +632,7 @@ class FlowView(tabs.Tabs):
message="Tab to the request or response",
expire=1
)
- elif key in "bfgmxvzEC" and not conn:
+ elif key in set("bfgmxvzEC") and not conn:
signals.status_message.send(
message = "Tab to the request or response",
expire = 1
diff --git a/mitmproxy/console/grideditor.py b/mitmproxy/console/grideditor.py
deleted file mode 100644
index 87700fd7..00000000
--- a/mitmproxy/console/grideditor.py
+++ /dev/null
@@ -1,719 +0,0 @@
-from __future__ import absolute_import, print_function, division
-
-import copy
-import os
-import re
-
-import urwid
-
-from mitmproxy import exceptions
-from mitmproxy import filt
-from mitmproxy.builtins import script
-from mitmproxy.console import common
-from mitmproxy.console import signals
-from netlib import strutils
-from netlib.http import cookies
-from netlib.http import user_agents
-
-FOOTER = [
- ('heading_key', "enter"), ":edit ",
- ('heading_key', "q"), ":back ",
-]
-FOOTER_EDITING = [
- ('heading_key', "esc"), ":stop editing ",
-]
-
-
-class TextColumn:
- subeditor = None
-
- def __init__(self, heading):
- self.heading = heading
-
- def text(self, obj):
- return SEscaped(obj or "")
-
- def blank(self):
- return ""
-
- def keypress(self, key, editor):
- if key == "r":
- if editor.walker.get_current_value() is not None:
- signals.status_prompt_path.send(
- self,
- prompt = "Read file",
- callback = editor.read_file
- )
- elif key == "R":
- if editor.walker.get_current_value() is not None:
- signals.status_prompt_path.send(
- editor,
- prompt = "Read unescaped file",
- callback = editor.read_file,
- args = (True,)
- )
- elif key == "e":
- o = editor.walker.get_current_value()
- if o is not None:
- n = editor.master.spawn_editor(o.encode("string-escape"))
- n = strutils.clean_hanging_newline(n)
- editor.walker.set_current_value(n, False)
- editor.walker._modified()
- elif key in ["enter"]:
- editor.walker.start_edit()
- else:
- return key
-
-
-class SubgridColumn:
-
- def __init__(self, heading, subeditor):
- self.heading = heading
- self.subeditor = subeditor
-
- def text(self, obj):
- p = cookies._format_pairs(obj, sep="\n")
- return urwid.Text(p)
-
- def blank(self):
- return []
-
- def keypress(self, key, editor):
- if key in "rRe":
- signals.status_message.send(
- self,
- message = "Press enter to edit this field.",
- expire = 1000
- )
- return
- elif key in ["enter"]:
- editor.master.view_grideditor(
- self.subeditor(
- editor.master,
- editor.walker.get_current_value(),
- editor.set_subeditor_value,
- editor.walker.focus,
- editor.walker.focus_col
- )
- )
- else:
- return key
-
-
-class SEscaped(urwid.WidgetWrap):
-
- def __init__(self, txt):
- txt = txt.encode("string-escape")
- w = urwid.Text(txt, wrap="any")
- urwid.WidgetWrap.__init__(self, w)
-
- def get_text(self):
- return self._w.get_text()[0]
-
- def keypress(self, size, key):
- return key
-
- def selectable(self):
- return True
-
-
-class SEdit(urwid.WidgetWrap):
-
- def __init__(self, txt):
- txt = txt.encode("string-escape")
- w = urwid.Edit(edit_text=txt, wrap="any", multiline=True)
- w = urwid.AttrWrap(w, "editfield")
- urwid.WidgetWrap.__init__(self, w)
-
- def get_text(self):
- return self._w.get_text()[0].strip()
-
- def selectable(self):
- return True
-
-
-class GridRow(urwid.WidgetWrap):
-
- def __init__(self, focused, editing, editor, values):
- self.focused, self.editing, self.editor = focused, editing, editor
-
- errors = values[1]
- self.fields = []
- for i, v in enumerate(values[0]):
- if focused == i and editing:
- self.editing = SEdit(v)
- self.fields.append(self.editing)
- else:
- w = self.editor.columns[i].text(v)
- if focused == i:
- if i in errors:
- w = urwid.AttrWrap(w, "focusfield_error")
- else:
- w = urwid.AttrWrap(w, "focusfield")
- elif i in errors:
- w = urwid.AttrWrap(w, "field_error")
- self.fields.append(w)
-
- fspecs = self.fields[:]
- if len(self.fields) > 1:
- fspecs[0] = ("fixed", self.editor.first_width + 2, fspecs[0])
- w = urwid.Columns(
- fspecs,
- dividechars = 2
- )
- if focused is not None:
- w.set_focus_column(focused)
- urwid.WidgetWrap.__init__(self, w)
-
- def get_edit_value(self):
- return self.editing.get_text()
-
- def keypress(self, s, k):
- if self.editing:
- w = self._w.column_widths(s)[self.focused]
- k = self.editing.keypress((w,), k)
- return k
-
- def selectable(self):
- return True
-
-
-class GridWalker(urwid.ListWalker):
-
- """
- Stores rows as a list of (rows, errors) tuples, where rows is a list
- and errors is a set with an entry of each offset in rows that is an
- error.
- """
-
- def __init__(self, lst, editor):
- self.lst = [(i, set([])) for i in lst]
- self.editor = editor
- self.focus = 0
- self.focus_col = 0
- self.editing = False
-
- def _modified(self):
- self.editor.show_empty_msg()
- return urwid.ListWalker._modified(self)
-
- def add_value(self, lst):
- self.lst.append((lst[:], set([])))
- self._modified()
-
- def get_current_value(self):
- if self.lst:
- return self.lst[self.focus][0][self.focus_col]
-
- def set_current_value(self, val, unescaped):
- if not unescaped:
- try:
- val = val.decode("string-escape")
- except ValueError:
- signals.status_message.send(
- self,
- message = "Invalid Python-style string encoding.",
- expire = 1000
- )
- return
- errors = self.lst[self.focus][1]
- emsg = self.editor.is_error(self.focus_col, val)
- if emsg:
- signals.status_message.send(message = emsg, expire = 1)
- errors.add(self.focus_col)
- else:
- errors.discard(self.focus_col)
- self.set_value(val, self.focus, self.focus_col, errors)
-
- def set_value(self, val, focus, focus_col, errors=None):
- if not errors:
- errors = set([])
- row = list(self.lst[focus][0])
- row[focus_col] = val
- self.lst[focus] = [tuple(row), errors]
- self._modified()
-
- def delete_focus(self):
- if self.lst:
- del self.lst[self.focus]
- self.focus = min(len(self.lst) - 1, self.focus)
- self._modified()
-
- def _insert(self, pos):
- self.focus = pos
- self.lst.insert(
- self.focus,
- [
- [c.blank() for c in self.editor.columns], set([])
- ]
- )
- self.focus_col = 0
- self.start_edit()
-
- def insert(self):
- return self._insert(self.focus)
-
- def add(self):
- return self._insert(min(self.focus + 1, len(self.lst)))
-
- def start_edit(self):
- col = self.editor.columns[self.focus_col]
- if self.lst and not col.subeditor:
- self.editing = GridRow(
- self.focus_col, True, self.editor, self.lst[self.focus]
- )
- self.editor.master.loop.widget.footer.update(FOOTER_EDITING)
- self._modified()
-
- def stop_edit(self):
- if self.editing:
- self.editor.master.loop.widget.footer.update(FOOTER)
- self.set_current_value(self.editing.get_edit_value(), False)
- self.editing = False
- self._modified()
-
- def left(self):
- self.focus_col = max(self.focus_col - 1, 0)
- self._modified()
-
- def right(self):
- self.focus_col = min(self.focus_col + 1, len(self.editor.columns) - 1)
- self._modified()
-
- def tab_next(self):
- self.stop_edit()
- if self.focus_col < len(self.editor.columns) - 1:
- self.focus_col += 1
- elif self.focus != len(self.lst) - 1:
- self.focus_col = 0
- self.focus += 1
- self._modified()
-
- def get_focus(self):
- if self.editing:
- return self.editing, self.focus
- elif self.lst:
- return GridRow(
- self.focus_col,
- False,
- self.editor,
- self.lst[self.focus]
- ), self.focus
- else:
- return None, None
-
- def set_focus(self, focus):
- self.stop_edit()
- self.focus = focus
- self._modified()
-
- def get_next(self, pos):
- if pos + 1 >= len(self.lst):
- return None, None
- return GridRow(None, False, self.editor, self.lst[pos + 1]), pos + 1
-
- def get_prev(self, pos):
- if pos - 1 < 0:
- return None, None
- return GridRow(None, False, self.editor, self.lst[pos - 1]), pos - 1
-
-
-class GridListBox(urwid.ListBox):
-
- def __init__(self, lw):
- urwid.ListBox.__init__(self, lw)
-
-
-FIRST_WIDTH_MAX = 40
-FIRST_WIDTH_MIN = 20
-
-
-class GridEditor(urwid.WidgetWrap):
- title = None
- columns = None
-
- def __init__(self, master, value, callback, *cb_args, **cb_kwargs):
- value = self.data_in(copy.deepcopy(value))
- self.master, self.value, self.callback = master, value, callback
- self.cb_args, self.cb_kwargs = cb_args, cb_kwargs
-
- first_width = 20
- if value:
- for r in value:
- assert len(r) == len(self.columns)
- first_width = max(len(r), first_width)
- self.first_width = min(first_width, FIRST_WIDTH_MAX)
-
- title = urwid.Text(self.title)
- title = urwid.Padding(title, align="left", width=("relative", 100))
- title = urwid.AttrWrap(title, "heading")
-
- headings = []
- for i, col in enumerate(self.columns):
- c = urwid.Text(col.heading)
- if i == 0 and len(self.columns) > 1:
- headings.append(("fixed", first_width + 2, c))
- else:
- headings.append(c)
- h = urwid.Columns(
- headings,
- dividechars = 2
- )
- h = urwid.AttrWrap(h, "heading")
-
- self.walker = GridWalker(self.value, self)
- self.lb = GridListBox(self.walker)
- self._w = urwid.Frame(
- self.lb,
- header = urwid.Pile([title, h])
- )
- self.master.loop.widget.footer.update("")
- self.show_empty_msg()
-
- def show_empty_msg(self):
- if self.walker.lst:
- self._w.set_footer(None)
- else:
- self._w.set_footer(
- urwid.Text(
- [
- ("highlight", "No values. Press "),
- ("key", "a"),
- ("highlight", " to add some."),
- ]
- )
- )
-
- def encode(self, s):
- if not self.encoding:
- return s
- try:
- return s.encode(self.encoding)
- except ValueError:
- return None
-
- def read_file(self, p, unescaped=False):
- if p:
- try:
- p = os.path.expanduser(p)
- d = open(p, "rb").read()
- self.walker.set_current_value(d, unescaped)
- self.walker._modified()
- except IOError as v:
- return str(v)
-
- def set_subeditor_value(self, val, focus, focus_col):
- self.walker.set_value(val, focus, focus_col)
-
- def keypress(self, size, key):
- if self.walker.editing:
- if key in ["esc"]:
- self.walker.stop_edit()
- elif key == "tab":
- pf, pfc = self.walker.focus, self.walker.focus_col
- self.walker.tab_next()
- if self.walker.focus == pf and self.walker.focus_col != pfc:
- self.walker.start_edit()
- else:
- self._w.keypress(size, key)
- return None
-
- key = common.shortcuts(key)
- column = self.columns[self.walker.focus_col]
- if key in ["q", "esc"]:
- res = []
- for i in self.walker.lst:
- if not i[1] and any([x for x in i[0]]):
- res.append(i[0])
- self.callback(self.data_out(res), *self.cb_args, **self.cb_kwargs)
- signals.pop_view_state.send(self)
- elif key == "g":
- self.walker.set_focus(0)
- elif key == "G":
- self.walker.set_focus(len(self.walker.lst) - 1)
- elif key in ["h", "left"]:
- self.walker.left()
- elif key in ["l", "right"]:
- self.walker.right()
- elif key == "tab":
- self.walker.tab_next()
- elif key == "a":
- self.walker.add()
- elif key == "A":
- self.walker.insert()
- elif key == "d":
- self.walker.delete_focus()
- elif column.keypress(key, self) and not self.handle_key(key):
- return self._w.keypress(size, key)
-
- def data_out(self, data):
- """
- Called on raw list data, before data is returned through the
- callback.
- """
- return data
-
- def data_in(self, data):
- """
- Called to prepare provided data.
- """
- return data
-
- def is_error(self, col, val):
- """
- Return False, or a string error message.
- """
- return False
-
- def handle_key(self, key):
- return False
-
- def make_help(self):
- text = []
- text.append(urwid.Text([("text", "Editor control:\n")]))
- keys = [
- ("A", "insert row before cursor"),
- ("a", "add row after cursor"),
- ("d", "delete row"),
- ("e", "spawn external editor on current field"),
- ("q", "save changes and exit editor"),
- ("r", "read value from file"),
- ("R", "read unescaped value from file"),
- ("esc", "save changes and exit editor"),
- ("tab", "next field"),
- ("enter", "edit field"),
- ]
- text.extend(
- common.format_keyvals(keys, key="key", val="text", indent=4)
- )
- text.append(
- urwid.Text(
- [
- "\n",
- ("text", "Values are escaped Python-style strings.\n"),
- ]
- )
- )
- return text
-
-
-class QueryEditor(GridEditor):
- title = "Editing query"
- columns = [
- TextColumn("Key"),
- TextColumn("Value")
- ]
-
-
-class HeaderEditor(GridEditor):
- title = "Editing headers"
- columns = [
- TextColumn("Key"),
- TextColumn("Value")
- ]
-
- def make_help(self):
- h = GridEditor.make_help(self)
- text = []
- text.append(urwid.Text([("text", "Special keys:\n")]))
- keys = [
- ("U", "add User-Agent header"),
- ]
- text.extend(
- common.format_keyvals(keys, key="key", val="text", indent=4)
- )
- text.append(urwid.Text([("text", "\n")]))
- text.extend(h)
- return text
-
- def set_user_agent(self, k):
- ua = user_agents.get_by_shortcut(k)
- if ua:
- self.walker.add_value(
- [
- "User-Agent",
- ua[2]
- ]
- )
-
- def handle_key(self, key):
- if key == "U":
- signals.status_prompt_onekey.send(
- prompt = "Add User-Agent header:",
- keys = [(i[0], i[1]) for i in user_agents.UASTRINGS],
- callback = self.set_user_agent,
- )
- return True
-
-
-class URLEncodedFormEditor(GridEditor):
- title = "Editing URL-encoded form"
- columns = [
- TextColumn("Key"),
- TextColumn("Value")
- ]
-
-
-class ReplaceEditor(GridEditor):
- title = "Editing replacement patterns"
- columns = [
- TextColumn("Filter"),
- TextColumn("Regex"),
- TextColumn("Replacement"),
- ]
-
- def is_error(self, col, val):
- if col == 0:
- if not filt.parse(val):
- return "Invalid filter specification."
- elif col == 1:
- try:
- re.compile(val)
- except re.error:
- return "Invalid regular expression."
- return False
-
-
-class SetHeadersEditor(GridEditor):
- title = "Editing header set patterns"
- columns = [
- TextColumn("Filter"),
- TextColumn("Header"),
- TextColumn("Value"),
- ]
-
- def is_error(self, col, val):
- if col == 0:
- if not filt.parse(val):
- return "Invalid filter specification"
- return False
-
- def make_help(self):
- h = GridEditor.make_help(self)
- text = []
- text.append(urwid.Text([("text", "Special keys:\n")]))
- keys = [
- ("U", "add User-Agent header"),
- ]
- text.extend(
- common.format_keyvals(keys, key="key", val="text", indent=4)
- )
- text.append(urwid.Text([("text", "\n")]))
- text.extend(h)
- return text
-
- def set_user_agent(self, k):
- ua = user_agents.get_by_shortcut(k)
- if ua:
- self.walker.add_value(
- [
- ".*",
- "User-Agent",
- ua[2]
- ]
- )
-
- def handle_key(self, key):
- if key == "U":
- signals.status_prompt_onekey.send(
- prompt = "Add User-Agent header:",
- keys = [(i[0], i[1]) for i in user_agents.UASTRINGS],
- callback = self.set_user_agent,
- )
- return True
-
-
-class PathEditor(GridEditor):
- title = "Editing URL path components"
- columns = [
- TextColumn("Component"),
- ]
-
- def data_in(self, data):
- return [[i] for i in data]
-
- def data_out(self, data):
- return [i[0] for i in data]
-
-
-class ScriptEditor(GridEditor):
- title = "Editing scripts"
- columns = [
- TextColumn("Command"),
- ]
-
- def is_error(self, col, val):
- try:
- script.parse_command(val)
- except exceptions.AddonError as e:
- return str(e)
-
-
-class HostPatternEditor(GridEditor):
- title = "Editing host patterns"
- columns = [
- TextColumn("Regex (matched on hostname:port / ip:port)")
- ]
-
- def is_error(self, col, val):
- try:
- re.compile(val, re.IGNORECASE)
- except re.error as e:
- return "Invalid regex: %s" % str(e)
-
- def data_in(self, data):
- return [[i] for i in data]
-
- def data_out(self, data):
- return [i[0] for i in data]
-
-
-class CookieEditor(GridEditor):
- title = "Editing request Cookie header"
- columns = [
- TextColumn("Name"),
- TextColumn("Value"),
- ]
-
-
-class CookieAttributeEditor(GridEditor):
- title = "Editing Set-Cookie attributes"
- columns = [
- TextColumn("Name"),
- TextColumn("Value"),
- ]
-
- def data_out(self, data):
- ret = []
- for i in data:
- if not i[1]:
- ret.append([i[0], None])
- else:
- ret.append(i)
- return ret
-
-
-class SetCookieEditor(GridEditor):
- title = "Editing response SetCookie header"
- columns = [
- TextColumn("Name"),
- TextColumn("Value"),
- SubgridColumn("Attributes", CookieAttributeEditor),
- ]
-
- def data_in(self, data):
- flattened = []
- for key, (value, attrs) in data:
- flattened.append([key, value, attrs.items(multi=True)])
- return flattened
-
- def data_out(self, data):
- vals = []
- for key, value, attrs in data:
- vals.append(
- [
- key,
- (value, attrs)
- ]
- )
- return vals
diff --git a/mitmproxy/console/grideditor/__init__.py b/mitmproxy/console/grideditor/__init__.py
new file mode 100644
index 00000000..894f3d22
--- /dev/null
+++ b/mitmproxy/console/grideditor/__init__.py
@@ -0,0 +1,2 @@
+from .editors import * # noqa
+from . import base # noqa
diff --git a/mitmproxy/console/grideditor/base.py b/mitmproxy/console/grideditor/base.py
new file mode 100644
index 00000000..8b80badb
--- /dev/null
+++ b/mitmproxy/console/grideditor/base.py
@@ -0,0 +1,425 @@
+from __future__ import absolute_import, print_function, division
+import abc
+import copy
+
+import six
+import urwid
+from mitmproxy.console import common
+from mitmproxy.console import signals
+
+from typing import Any # noqa
+from typing import Callable # noqa
+from typing import Container # noqa
+from typing import Iterable # noqa
+from typing import Optional # noqa
+from typing import Sequence # noqa
+from typing import Tuple # noqa
+
+FOOTER = [
+ ('heading_key', "enter"), ":edit ",
+ ('heading_key', "q"), ":back ",
+]
+FOOTER_EDITING = [
+ ('heading_key', "esc"), ":stop editing ",
+]
+
+
+@six.add_metaclass(abc.ABCMeta)
+class Column(object):
+ subeditor = None
+
+ def __init__(self, heading):
+ self.heading = heading
+
+ @abc.abstractmethod
+ def Display(self, data):
+ # type: () -> Cell
+ pass
+
+ @abc.abstractmethod
+ def Edit(self, data):
+ # type: () -> Cell
+ pass
+
+ @abc.abstractmethod
+ def blank(self):
+ # type: () -> Any
+ pass
+
+ def keypress(self, key, editor):
+ # type: (str, GridEditor) -> Optional[str]
+ return key
+
+
+class Cell(urwid.WidgetWrap):
+
+ def get_data(self):
+ """
+ Raises:
+ ValueError, if the current content is invalid.
+ """
+ raise NotImplementedError()
+
+ def selectable(self):
+ return True
+
+
+class GridRow(urwid.WidgetWrap):
+ def __init__(
+ self,
+ focused, # type: Optional[int]
+ editing, # type: bool
+ editor, # type: GridEditor
+ values # type: Tuple[Iterable[bytes], Container[int]
+ ):
+ self.focused = focused
+ self.editor = editor
+ self.edit_col = None # type: Optional[Cell]
+
+ errors = values[1]
+ self.fields = []
+ for i, v in enumerate(values[0]):
+ if focused == i and editing:
+ self.edit_col = self.editor.columns[i].Edit(v)
+ self.fields.append(self.edit_col)
+ else:
+ w = self.editor.columns[i].Display(v)
+ if focused == i:
+ if i in errors:
+ w = urwid.AttrWrap(w, "focusfield_error")
+ else:
+ w = urwid.AttrWrap(w, "focusfield")
+ elif i in errors:
+ w = urwid.AttrWrap(w, "field_error")
+ self.fields.append(w)
+
+ fspecs = self.fields[:]
+ if len(self.fields) > 1:
+ fspecs[0] = ("fixed", self.editor.first_width + 2, fspecs[0])
+ w = urwid.Columns(
+ fspecs,
+ dividechars=2
+ )
+ if focused is not None:
+ w.set_focus_column(focused)
+ super(GridRow, self).__init__(w)
+
+ def keypress(self, s, k):
+ if self.edit_col:
+ w = self._w.column_widths(s)[self.focused]
+ k = self.edit_col.keypress((w,), k)
+ return k
+
+ def selectable(self):
+ return True
+
+
+class GridWalker(urwid.ListWalker):
+ """
+ Stores rows as a list of (rows, errors) tuples, where rows is a list
+ and errors is a set with an entry of each offset in rows that is an
+ error.
+ """
+
+ def __init__(
+ self,
+ lst, # type: Iterable[list]
+ editor # type: GridEditor
+ ):
+ self.lst = [(i, set()) for i in lst]
+ self.editor = editor
+ self.focus = 0
+ self.focus_col = 0
+ self.edit_row = None # type: Optional[GridRow]
+
+ def _modified(self):
+ self.editor.show_empty_msg()
+ return super(GridWalker, self)._modified()
+
+ def add_value(self, lst):
+ self.lst.append(
+ (lst[:], set())
+ )
+ self._modified()
+
+ def get_current_value(self):
+ if self.lst:
+ return self.lst[self.focus][0][self.focus_col]
+
+ def set_current_value(self, val):
+ errors = self.lst[self.focus][1]
+ emsg = self.editor.is_error(self.focus_col, val)
+ if emsg:
+ signals.status_message.send(message=emsg, expire=5)
+ errors.add(self.focus_col)
+ else:
+ errors.discard(self.focus_col)
+ self.set_value(val, self.focus, self.focus_col, errors)
+
+ def set_value(self, val, focus, focus_col, errors=None):
+ if not errors:
+ errors = set([])
+ row = list(self.lst[focus][0])
+ row[focus_col] = val
+ self.lst[focus] = [tuple(row), errors]
+ self._modified()
+
+ def delete_focus(self):
+ if self.lst:
+ del self.lst[self.focus]
+ self.focus = min(len(self.lst) - 1, self.focus)
+ self._modified()
+
+ def _insert(self, pos):
+ self.focus = pos
+ self.lst.insert(
+ self.focus,
+ ([c.blank() for c in self.editor.columns], set([]))
+ )
+ self.focus_col = 0
+ self.start_edit()
+
+ def insert(self):
+ return self._insert(self.focus)
+
+ def add(self):
+ return self._insert(min(self.focus + 1, len(self.lst)))
+
+ def start_edit(self):
+ col = self.editor.columns[self.focus_col]
+ if self.lst and not col.subeditor:
+ self.edit_row = GridRow(
+ self.focus_col, True, self.editor, self.lst[self.focus]
+ )
+ self.editor.master.loop.widget.footer.update(FOOTER_EDITING)
+ self._modified()
+
+ def stop_edit(self):
+ if self.edit_row:
+ self.editor.master.loop.widget.footer.update(FOOTER)
+ try:
+ val = self.edit_row.edit_col.get_data()
+ except ValueError:
+ return
+ self.edit_row = None
+ self.set_current_value(val)
+
+ def left(self):
+ self.focus_col = max(self.focus_col - 1, 0)
+ self._modified()
+
+ def right(self):
+ self.focus_col = min(self.focus_col + 1, len(self.editor.columns) - 1)
+ self._modified()
+
+ def tab_next(self):
+ self.stop_edit()
+ if self.focus_col < len(self.editor.columns) - 1:
+ self.focus_col += 1
+ elif self.focus != len(self.lst) - 1:
+ self.focus_col = 0
+ self.focus += 1
+ self._modified()
+
+ def get_focus(self):
+ if self.edit_row:
+ return self.edit_row, self.focus
+ elif self.lst:
+ return GridRow(
+ self.focus_col,
+ False,
+ self.editor,
+ self.lst[self.focus]
+ ), self.focus
+ else:
+ return None, None
+
+ def set_focus(self, focus):
+ self.stop_edit()
+ self.focus = focus
+ self._modified()
+
+ def get_next(self, pos):
+ if pos + 1 >= len(self.lst):
+ return None, None
+ return GridRow(None, False, self.editor, self.lst[pos + 1]), pos + 1
+
+ def get_prev(self, pos):
+ if pos - 1 < 0:
+ return None, None
+ return GridRow(None, False, self.editor, self.lst[pos - 1]), pos - 1
+
+
+class GridListBox(urwid.ListBox):
+ def __init__(self, lw):
+ super(GridListBox, self).__init__(lw)
+
+
+FIRST_WIDTH_MAX = 40
+FIRST_WIDTH_MIN = 20
+
+
+class GridEditor(urwid.WidgetWrap):
+ title = None # type: str
+ columns = None # type: Sequence[Column]
+
+ def __init__(
+ self,
+ master, # type: "mitmproxy.console.master.ConsoleMaster"
+ value, # type: Any
+ callback, # type: Callable[..., None]
+ *cb_args,
+ **cb_kwargs
+ ):
+ value = self.data_in(copy.deepcopy(value))
+ self.master = master
+ self.value = value
+ self.callback = callback
+ self.cb_args = cb_args
+ self.cb_kwargs = cb_kwargs
+
+ first_width = 20
+ if value:
+ for r in value:
+ assert len(r) == len(self.columns)
+ first_width = max(len(r), first_width)
+ self.first_width = min(first_width, FIRST_WIDTH_MAX)
+
+ title = urwid.Text(self.title)
+ title = urwid.Padding(title, align="left", width=("relative", 100))
+ title = urwid.AttrWrap(title, "heading")
+
+ headings = []
+ for i, col in enumerate(self.columns):
+ c = urwid.Text(col.heading)
+ if i == 0 and len(self.columns) > 1:
+ headings.append(("fixed", first_width + 2, c))
+ else:
+ headings.append(c)
+ h = urwid.Columns(
+ headings,
+ dividechars=2
+ )
+ h = urwid.AttrWrap(h, "heading")
+
+ self.walker = GridWalker(self.value, self)
+ self.lb = GridListBox(self.walker)
+ w = urwid.Frame(
+ self.lb,
+ header=urwid.Pile([title, h])
+ )
+ super(GridEditor, self).__init__(w)
+ self.master.loop.widget.footer.update("")
+ self.show_empty_msg()
+
+ def show_empty_msg(self):
+ if self.walker.lst:
+ self._w.set_footer(None)
+ else:
+ self._w.set_footer(
+ urwid.Text(
+ [
+ ("highlight", "No values. Press "),
+ ("key", "a"),
+ ("highlight", " to add some."),
+ ]
+ )
+ )
+
+ def set_subeditor_value(self, val, focus, focus_col):
+ self.walker.set_value(val, focus, focus_col)
+
+ def keypress(self, size, key):
+ if self.walker.edit_row:
+ if key in ["esc"]:
+ self.walker.stop_edit()
+ elif key == "tab":
+ pf, pfc = self.walker.focus, self.walker.focus_col
+ self.walker.tab_next()
+ if self.walker.focus == pf and self.walker.focus_col != pfc:
+ self.walker.start_edit()
+ else:
+ self._w.keypress(size, key)
+ return None
+
+ key = common.shortcuts(key)
+ column = self.columns[self.walker.focus_col]
+ if key in ["q", "esc"]:
+ res = []
+ for i in self.walker.lst:
+ if not i[1] and any([x for x in i[0]]):
+ res.append(i[0])
+ self.callback(self.data_out(res), *self.cb_args, **self.cb_kwargs)
+ signals.pop_view_state.send(self)
+ elif key == "g":
+ self.walker.set_focus(0)
+ elif key == "G":
+ self.walker.set_focus(len(self.walker.lst) - 1)
+ elif key in ["h", "left"]:
+ self.walker.left()
+ elif key in ["l", "right"]:
+ self.walker.right()
+ elif key == "tab":
+ self.walker.tab_next()
+ elif key == "a":
+ self.walker.add()
+ elif key == "A":
+ self.walker.insert()
+ elif key == "d":
+ self.walker.delete_focus()
+ elif column.keypress(key, self) and not self.handle_key(key):
+ return self._w.keypress(size, key)
+
+ def data_out(self, data):
+ # type: (Sequence[list]) -> Any
+ """
+ Called on raw list data, before data is returned through the
+ callback.
+ """
+ return data
+
+ def data_in(self, data):
+ # type: (Any) -> Iterable[list]
+ """
+ Called to prepare provided data.
+ """
+ return data
+
+ def is_error(self, col, val):
+ # type: (int, Any) -> Optional[str]
+ """
+ Return None, or a string error message.
+ """
+ return False
+
+ def handle_key(self, key):
+ return False
+
+ def make_help(self):
+ text = [
+ urwid.Text([("text", "Editor control:\n")])
+ ]
+ keys = [
+ ("A", "insert row before cursor"),
+ ("a", "add row after cursor"),
+ ("d", "delete row"),
+ ("e", "spawn external editor on current field"),
+ ("q", "save changes and exit editor"),
+ ("r", "read value from file"),
+ ("R", "read unescaped value from file"),
+ ("esc", "save changes and exit editor"),
+ ("tab", "next field"),
+ ("enter", "edit field"),
+ ]
+ text.extend(
+ common.format_keyvals(keys, key="key", val="text", indent=4)
+ )
+ text.append(
+ urwid.Text(
+ [
+ "\n",
+ ("text", "Values are escaped Python-style strings.\n"),
+ ]
+ )
+ )
+ return text
diff --git a/mitmproxy/console/grideditor/col_bytes.py b/mitmproxy/console/grideditor/col_bytes.py
new file mode 100644
index 00000000..51bbb6cb
--- /dev/null
+++ b/mitmproxy/console/grideditor/col_bytes.py
@@ -0,0 +1,103 @@
+from __future__ import absolute_import, print_function, division
+
+import os
+
+import urwid
+from mitmproxy.console import signals
+from mitmproxy.console.grideditor import base
+from netlib import strutils
+
+
+def read_file(filename, callback, escaped):
+ # type: (str, Callable[...,None], bool) -> Optional[str]
+ if not filename:
+ return
+
+ filename = os.path.expanduser(filename)
+ try:
+ with open(filename, "r" if escaped else "rb") as f:
+ d = f.read()
+ except IOError as v:
+ return str(v)
+
+ if escaped:
+ try:
+ d = strutils.escaped_str_to_bytes(d)
+ except ValueError:
+ return "Invalid Python-style string encoding."
+ # TODO: Refactor the status_prompt_path signal so that we
+ # can raise exceptions here and return the content instead.
+ callback(d)
+
+
+class Column(base.Column):
+ def Display(self, data):
+ return Display(data)
+
+ def Edit(self, data):
+ return Edit(data)
+
+ def blank(self):
+ return b""
+
+ def keypress(self, key, editor):
+ if key == "r":
+ if editor.walker.get_current_value() is not None:
+ signals.status_prompt_path.send(
+ self,
+ prompt="Read file",
+ callback=read_file,
+ args=(editor.walker.set_current_value, True)
+ )
+ elif key == "R":
+ if editor.walker.get_current_value() is not None:
+ signals.status_prompt_path.send(
+ self,
+ prompt="Read unescaped file",
+ callback=read_file,
+ args=(editor.walker.set_current_value, False)
+ )
+ elif key == "e":
+ o = editor.walker.get_current_value()
+ if o is not None:
+ n = editor.master.spawn_editor(o)
+ n = strutils.clean_hanging_newline(n)
+ editor.walker.set_current_value(n)
+ elif key in ["enter"]:
+ editor.walker.start_edit()
+ else:
+ return key
+
+
+class Display(base.Cell):
+ def __init__(self, data):
+ # type: (bytes) -> Display
+ self.data = data
+ escaped = strutils.bytes_to_escaped_str(data)
+ w = urwid.Text(escaped, wrap="any")
+ super(Display, self).__init__(w)
+
+ def get_data(self):
+ return self.data
+
+
+class Edit(base.Cell):
+ def __init__(self, data):
+ # type: (bytes) -> Edit
+ data = strutils.bytes_to_escaped_str(data)
+ w = urwid.Edit(edit_text=data, wrap="any", multiline=True)
+ w = urwid.AttrWrap(w, "editfield")
+ super(Edit, self).__init__(w)
+
+ def get_data(self):
+ # type: () -> bytes
+ txt = self._w.get_text()[0].strip()
+ try:
+ return strutils.escaped_str_to_bytes(txt)
+ except ValueError:
+ signals.status_message.send(
+ self,
+ message="Invalid Python-style string encoding.",
+ expire=1000
+ )
+ raise
diff --git a/mitmproxy/console/grideditor/col_subgrid.py b/mitmproxy/console/grideditor/col_subgrid.py
new file mode 100644
index 00000000..1dec8032
--- /dev/null
+++ b/mitmproxy/console/grideditor/col_subgrid.py
@@ -0,0 +1,51 @@
+from __future__ import absolute_import, print_function, division
+import urwid
+from mitmproxy.console.grideditor import base
+from mitmproxy.console import signals
+from netlib.http import cookies
+
+
+class Column(base.Column):
+ def __init__(self, heading, subeditor):
+ super(Column, self).__init__(heading)
+ self.subeditor = subeditor
+
+ def Edit(self, data):
+ raise RuntimeError("SubgridColumn should handle edits itself")
+
+ def Display(self, data):
+ return Display(data)
+
+ def blank(self):
+ return []
+
+ def keypress(self, key, editor):
+ if key in "rRe":
+ signals.status_message.send(
+ self,
+ message="Press enter to edit this field.",
+ expire=1000
+ )
+ return
+ elif key in ["enter"]:
+ editor.master.view_grideditor(
+ self.subeditor(
+ editor.master,
+ editor.walker.get_current_value(),
+ editor.set_subeditor_value,
+ editor.walker.focus,
+ editor.walker.focus_col
+ )
+ )
+ else:
+ return key
+
+
+class Display(base.Cell):
+ def __init__(self, data):
+ p = cookies._format_pairs(data, sep="\n")
+ w = urwid.Text(p)
+ super(Display, self).__init__(w)
+
+ def get_data(self):
+ pass
diff --git a/mitmproxy/console/grideditor/col_text.py b/mitmproxy/console/grideditor/col_text.py
new file mode 100644
index 00000000..d60dc854
--- /dev/null
+++ b/mitmproxy/console/grideditor/col_text.py
@@ -0,0 +1,55 @@
+"""
+Welcome to the encoding dance!
+
+In a nutshell, text columns are actually a proxy class for byte columns,
+which just encode/decodes contents.
+"""
+from __future__ import absolute_import, print_function, division
+
+from mitmproxy.console import signals
+from mitmproxy.console.grideditor import col_bytes
+
+
+class Column(col_bytes.Column):
+ def __init__(self, heading, encoding="utf8", errors="surrogateescape"):
+ super(Column, self).__init__(heading)
+ self.encoding_args = encoding, errors
+
+ def Display(self, data):
+ return TDisplay(data, self.encoding_args)
+
+ def Edit(self, data):
+ return TEdit(data, self.encoding_args)
+
+ def blank(self):
+ return u""
+
+
+# This is the same for both edit and display.
+class EncodingMixin(object):
+ def __init__(self, data, encoding_args):
+ # type: (str) -> TDisplay
+ self.encoding_args = encoding_args
+ data = data.encode(*self.encoding_args)
+ super(EncodingMixin, self).__init__(data)
+
+ def get_data(self):
+ data = super(EncodingMixin, self).get_data()
+ try:
+ return data.decode(*self.encoding_args)
+ except ValueError:
+ signals.status_message.send(
+ self,
+ message="Invalid encoding.",
+ expire=1000
+ )
+ raise
+
+
+# urwid forces a different name for a subclass.
+class TDisplay(EncodingMixin, col_bytes.Display):
+ pass
+
+
+class TEdit(EncodingMixin, col_bytes.Edit):
+ pass
diff --git a/mitmproxy/console/grideditor/editors.py b/mitmproxy/console/grideditor/editors.py
new file mode 100644
index 00000000..80f0541b
--- /dev/null
+++ b/mitmproxy/console/grideditor/editors.py
@@ -0,0 +1,239 @@
+from __future__ import absolute_import, print_function, division
+import re
+import urwid
+from mitmproxy import filt
+from mitmproxy.builtins import script
+from mitmproxy import exceptions
+from mitmproxy.console import common
+from mitmproxy.console.grideditor import base
+from mitmproxy.console.grideditor import col_bytes
+from mitmproxy.console.grideditor import col_text
+from mitmproxy.console.grideditor import col_subgrid
+from mitmproxy.console import signals
+from netlib.http import user_agents
+
+
+class QueryEditor(base.GridEditor):
+ title = "Editing query"
+ columns = [
+ col_text.Column("Key"),
+ col_text.Column("Value")
+ ]
+
+
+class HeaderEditor(base.GridEditor):
+ title = "Editing headers"
+ columns = [
+ col_bytes.Column("Key"),
+ col_bytes.Column("Value")
+ ]
+
+ def make_help(self):
+ h = super(HeaderEditor, self).make_help()
+ text = [
+ urwid.Text([("text", "Special keys:\n")])
+ ]
+ keys = [
+ ("U", "add User-Agent header"),
+ ]
+ text.extend(
+ common.format_keyvals(keys, key="key", val="text", indent=4)
+ )
+ text.append(urwid.Text([("text", "\n")]))
+ text.extend(h)
+ return text
+
+ def set_user_agent(self, k):
+ ua = user_agents.get_by_shortcut(k)
+ if ua:
+ self.walker.add_value(
+ [
+ b"User-Agent",
+ ua[2].encode()
+ ]
+ )
+
+ def handle_key(self, key):
+ if key == "U":
+ signals.status_prompt_onekey.send(
+ prompt="Add User-Agent header:",
+ keys=[(i[0], i[1]) for i in user_agents.UASTRINGS],
+ callback=self.set_user_agent,
+ )
+ return True
+
+
+class URLEncodedFormEditor(base.GridEditor):
+ title = "Editing URL-encoded form"
+ columns = [
+ col_bytes.Column("Key"),
+ col_bytes.Column("Value")
+ ]
+
+
+class ReplaceEditor(base.GridEditor):
+ title = "Editing replacement patterns"
+ columns = [
+ col_text.Column("Filter"),
+ col_bytes.Column("Regex"),
+ col_bytes.Column("Replacement"),
+ ]
+
+ def is_error(self, col, val):
+ if col == 0:
+ if not filt.parse(val):
+ return "Invalid filter specification."
+ elif col == 1:
+ try:
+ re.compile(val)
+ except re.error:
+ return "Invalid regular expression."
+ return False
+
+
+class SetHeadersEditor(base.GridEditor):
+ title = "Editing header set patterns"
+ columns = [
+ col_text.Column("Filter"),
+ col_bytes.Column("Header"),
+ col_bytes.Column("Value"),
+ ]
+
+ def is_error(self, col, val):
+ if col == 0:
+ if not filt.parse(val):
+ return "Invalid filter specification"
+ return False
+
+ def make_help(self):
+ h = super(SetHeadersEditor, self).make_help()
+ text = [
+ urwid.Text([("text", "Special keys:\n")])
+ ]
+ keys = [
+ ("U", "add User-Agent header"),
+ ]
+ text.extend(
+ common.format_keyvals(keys, key="key", val="text", indent=4)
+ )
+ text.append(urwid.Text([("text", "\n")]))
+ text.extend(h)
+ return text
+
+ def set_user_agent(self, k):
+ ua = user_agents.get_by_shortcut(k)
+ if ua:
+ self.walker.add_value(
+ [
+ ".*",
+ b"User-Agent",
+ ua[2].encode()
+ ]
+ )
+
+ def handle_key(self, key):
+ if key == "U":
+ signals.status_prompt_onekey.send(
+ prompt="Add User-Agent header:",
+ keys=[(i[0], i[1]) for i in user_agents.UASTRINGS],
+ callback=self.set_user_agent,
+ )
+ return True
+
+
+class PathEditor(base.GridEditor):
+ # TODO: Next row on enter?
+
+ title = "Editing URL path components"
+ columns = [
+ col_text.Column("Component"),
+ ]
+
+ def data_in(self, data):
+ return [[i] for i in data]
+
+ def data_out(self, data):
+ return [i[0] for i in data]
+
+
+class ScriptEditor(base.GridEditor):
+ title = "Editing scripts"
+ columns = [
+ col_text.Column("Command"),
+ ]
+
+ def is_error(self, col, val):
+ try:
+ script.parse_command(val)
+ except exceptions.AddonError as e:
+ return str(e)
+
+
+class HostPatternEditor(base.GridEditor):
+ title = "Editing host patterns"
+ columns = [
+ col_text.Column("Regex (matched on hostname:port / ip:port)")
+ ]
+
+ def is_error(self, col, val):
+ try:
+ re.compile(val, re.IGNORECASE)
+ except re.error as e:
+ return "Invalid regex: %s" % str(e)
+
+ def data_in(self, data):
+ return [[i] for i in data]
+
+ def data_out(self, data):
+ return [i[0] for i in data]
+
+
+class CookieEditor(base.GridEditor):
+ title = "Editing request Cookie header"
+ columns = [
+ col_text.Column("Name"),
+ col_text.Column("Value"),
+ ]
+
+
+class CookieAttributeEditor(base.GridEditor):
+ title = "Editing Set-Cookie attributes"
+ columns = [
+ col_text.Column("Name"),
+ col_text.Column("Value"),
+ ]
+
+ def data_out(self, data):
+ ret = []
+ for i in data:
+ if not i[1]:
+ ret.append([i[0], None])
+ else:
+ ret.append(i)
+ return ret
+
+
+class SetCookieEditor(base.GridEditor):
+ title = "Editing response SetCookie header"
+ columns = [
+ col_text.Column("Name"),
+ col_text.Column("Value"),
+ col_subgrid.Column("Attributes", CookieAttributeEditor),
+ ]
+
+ def data_in(self, data):
+ flattened = []
+ for key, (value, attrs) in data:
+ flattened.append([key, value, attrs.items(multi=True)])
+ return flattened
+
+ def data_out(self, data):
+ vals = []
+ for key, value, attrs in data:
+ vals.append(
+ [
+ key,
+ (value, attrs)
+ ]
+ )
+ return vals
diff --git a/mitmproxy/console/help.py b/mitmproxy/console/help.py
index ff4a072f..8024dc31 100644
--- a/mitmproxy/console/help.py
+++ b/mitmproxy/console/help.py
@@ -49,12 +49,11 @@ class HelpView(urwid.ListBox):
text.append(urwid.Text([("head", "\n\nGlobal keys:\n")]))
keys = [
- ("c", "client replay of HTTP requests"),
("i", "set interception pattern"),
("o", "options"),
("q", "quit / return to previous page"),
("Q", "quit without confirm prompt"),
- ("S", "server replay of HTTP responses"),
+ ("R", "replay of HTTP requests/responses"),
]
text.extend(
common.format_keyvals(keys, key="key", val="text", indent=4)
diff --git a/mitmproxy/console/master.py b/mitmproxy/console/master.py
index f7c99ecb..03ec8b63 100644
--- a/mitmproxy/console/master.py
+++ b/mitmproxy/console/master.py
@@ -13,6 +13,7 @@ import tempfile
import traceback
import weakref
+import six
import urwid
from typing import Optional # noqa
@@ -182,7 +183,7 @@ class ConsoleState(flow.State):
self.mark_filter = False
def clear(self):
- marked_flows = [f for f in self.state.view if f.marked]
+ marked_flows = [f for f in self.view if f.marked]
super(ConsoleState, self).clear()
for f in marked_flows:
@@ -390,13 +391,17 @@ class ConsoleMaster(flow.FlowMaster):
)
def spawn_editor(self, data):
- fd, name = tempfile.mkstemp('', "mproxy")
- os.write(fd, data)
- os.close(fd)
- c = os.environ.get("EDITOR")
+ text = not isinstance(data, bytes)
+ fd, name = tempfile.mkstemp('', "mproxy", text=text)
+ if six.PY2:
+ os.close(fd)
+ with open(name, "w" if text else "wb") as f:
+ f.write(data)
+ else:
+ with open(fd, "w" if text else "wb") as f:
+ f.write(data)
# if no EDITOR is set, assume 'vi'
- if not c:
- c = "vi"
+ c = os.environ.get("EDITOR") or "vi"
cmd = shlex.split(c)
cmd.append(name)
self.ui.stop()
@@ -404,10 +409,11 @@ class ConsoleMaster(flow.FlowMaster):
subprocess.call(cmd)
except:
signals.status_message.send(
- message = "Can't start editor: %s" % " ".join(c)
+ message="Can't start editor: %s" % " ".join(c)
)
else:
- data = open(name, "rb").read()
+ with open(name, "r" if text else "rb") as f:
+ data = f.read()
self.ui.start()
os.unlink(name)
return data
@@ -570,7 +576,7 @@ class ConsoleMaster(flow.FlowMaster):
self,
ge,
None,
- statusbar.StatusBar(self, grideditor.FOOTER),
+ statusbar.StatusBar(self, grideditor.base.FOOTER),
ge.make_help()
)
)
diff --git a/mitmproxy/console/options.py b/mitmproxy/console/options.py
index 62564a60..f9fc3764 100644
--- a/mitmproxy/console/options.py
+++ b/mitmproxy/console/options.py
@@ -140,7 +140,7 @@ class Options(urwid.WidgetWrap):
)
self.master.loop.widget.footer.update("")
signals.update_settings.connect(self.sig_update_settings)
- master.options.changed.connect(self.sig_update_settings)
+ master.options.changed.connect(lambda sender, updated: self.sig_update_settings(sender))
def sig_update_settings(self, sender):
self.lb.walker._modified()
diff --git a/mitmproxy/console/searchable.py b/mitmproxy/console/searchable.py
index c60d1cd9..d58d3d13 100644
--- a/mitmproxy/console/searchable.py
+++ b/mitmproxy/console/searchable.py
@@ -78,9 +78,9 @@ class Searchable(urwid.ListBox):
return
# Start search at focus + 1
if backwards:
- rng = xrange(len(self.body) - 1, -1, -1)
+ rng = range(len(self.body) - 1, -1, -1)
else:
- rng = xrange(1, len(self.body) + 1)
+ rng = range(1, len(self.body) + 1)
for i in rng:
off = (self.focus_position + i) % len(self.body)
w = self.body[off]
diff --git a/mitmproxy/console/statusbar.py b/mitmproxy/console/statusbar.py
index 44be2b3e..156d1176 100644
--- a/mitmproxy/console/statusbar.py
+++ b/mitmproxy/console/statusbar.py
@@ -124,7 +124,7 @@ class StatusBar(urwid.WidgetWrap):
super(StatusBar, self).__init__(urwid.Pile([self.ib, self.master.ab]))
signals.update_settings.connect(self.sig_update_settings)
signals.flowlist_change.connect(self.sig_update_settings)
- master.options.changed.connect(self.sig_update_settings)
+ master.options.changed.connect(lambda sender, updated: self.sig_update_settings(sender))
self.redraw()
def sig_update_settings(self, sender):
diff --git a/mitmproxy/console/window.py b/mitmproxy/console/window.py
index 25780daf..b24718be 100644
--- a/mitmproxy/console/window.py
+++ b/mitmproxy/console/window.py
@@ -38,15 +38,12 @@ class Window(urwid.Frame):
return False
return True
- def keypress(self, size, k):
- k = super(self.__class__, self).keypress(size, k)
- if k == "?":
- self.master.view_help(self.helpctx)
- elif k == "c":
+ def handle_replay(self, k):
+ if k == "c":
if not self.master.client_playback:
signals.status_prompt_path.send(
self,
- prompt = "Client replay",
+ prompt = "Client replay path",
callback = self.master.client_playback_path
)
else:
@@ -59,20 +56,7 @@ class Window(urwid.Frame):
),
callback = self.master.stop_client_playback_prompt,
)
- elif k == "i":
- signals.status_prompt.send(
- self,
- prompt = "Intercept filter",
- text = self.master.state.intercept_txt,
- callback = self.master.set_intercept
- )
- elif k == "o":
- self.master.view_options()
- elif k == "Q":
- raise urwid.ExitMainLoop
- elif k == "q":
- signals.pop_view_state.send(self)
- elif k == "S":
+ elif k == "s":
if not self.master.server_playback:
signals.status_prompt_path.send(
self,
@@ -89,5 +73,33 @@ class Window(urwid.Frame):
),
callback = self.master.stop_server_playback_prompt,
)
+
+ def keypress(self, size, k):
+ k = super(self.__class__, self).keypress(size, k)
+ if k == "?":
+ self.master.view_help(self.helpctx)
+ elif k == "i":
+ signals.status_prompt.send(
+ self,
+ prompt = "Intercept filter",
+ text = self.master.state.intercept_txt,
+ callback = self.master.set_intercept
+ )
+ elif k == "o":
+ self.master.view_options()
+ elif k == "Q":
+ raise urwid.ExitMainLoop
+ elif k == "q":
+ signals.pop_view_state.send(self)
+ elif k == "R":
+ signals.status_prompt_onekey.send(
+ self,
+ prompt = "Replay",
+ keys = (
+ ("client", "c"),
+ ("server", "s"),
+ ),
+ callback = self.handle_replay,
+ )
else:
return k
diff --git a/mitmproxy/web/app.py b/mitmproxy/web/app.py
index e55df1f6..f8f85f3d 100644
--- a/mitmproxy/web/app.py
+++ b/mitmproxy/web/app.py
@@ -5,6 +5,8 @@ import json
import logging
import os.path
import re
+import hashlib
+
import six
import tornado.websocket
@@ -45,7 +47,8 @@ def convert_flow_to_json_dict(flow):
"path": flow.request.path,
"http_version": flow.request.http_version,
"headers": tuple(flow.request.headers.items(True)),
- "contentLength": len(flow.request.content) if flow.request.content is not None else None,
+ "contentLength": len(flow.request.raw_content) if flow.request.raw_content is not None else None,
+ "contentHash": hashlib.sha256(flow.request.raw_content).hexdigest() if flow.request.raw_content is not None else None,
"timestamp_start": flow.request.timestamp_start,
"timestamp_end": flow.request.timestamp_end,
"is_replay": flow.request.is_replay,
@@ -56,7 +59,8 @@ def convert_flow_to_json_dict(flow):
"status_code": flow.response.status_code,
"reason": flow.response.reason,
"headers": tuple(flow.response.headers.items(True)),
- "contentLength": len(flow.response.content) if flow.response.content is not None else None,
+ "contentLength": len(flow.response.raw_content) if flow.response.raw_content is not None else None,
+ "contentHash": hashlib.sha256(flow.response.raw_content).hexdigest() if flow.response.raw_content is not None else None,
"timestamp_start": flow.response.timestamp_start,
"timestamp_end": flow.response.timestamp_end,
"is_replay": flow.response.is_replay,
@@ -248,11 +252,14 @@ class FlowHandler(RequestHandler):
request.port = int(v)
elif k == "headers":
request.headers.set_state(v)
+ elif k == "content":
+ request.text = v
else:
print("Warning: Unknown update {}.{}: {}".format(a, k, v))
elif a == "response":
response = flow.response
+
for k, v in six.iteritems(b):
if k == "msg":
response.msg = str(v)
@@ -262,6 +269,8 @@ class FlowHandler(RequestHandler):
response.http_version = str(v)
elif k == "headers":
response.headers.set_state(v)
+ elif k == "content":
+ response.text = v
else:
print("Warning: Unknown update {}.{}: {}".format(a, k, v))
else:
diff --git a/mitmproxy/web/static/images/favicon.ico b/mitmproxy/web/static/images/favicon.ico
new file mode 100644
index 00000000..bfd2fde7
--- /dev/null
+++ b/mitmproxy/web/static/images/favicon.ico
Binary files differ
diff --git a/mitmproxy/web/templates/index.html b/mitmproxy/web/templates/index.html
index 165d7d3d..db9d2ecb 100644
--- a/mitmproxy/web/templates/index.html
+++ b/mitmproxy/web/templates/index.html
@@ -6,10 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/vendor.css"/>
<link rel="stylesheet" href="/static/app.css"/>
+ <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon"/>
<script src="/static/vendor.js"></script>
<script src="/static/app.js"></script>
</head>
<body>
<div id="mitmproxy"></div>
</body>
-</html> \ No newline at end of file
+</html>
diff --git a/netlib/encoding.py b/netlib/encoding.py
index 29e2a420..da282194 100644
--- a/netlib/encoding.py
+++ b/netlib/encoding.py
@@ -33,6 +33,7 @@ def decode(encoded, encoding, errors='strict'):
"""
global _cache
cached = (
+ isinstance(encoded, bytes) and
_cache.encoded == encoded and
_cache.encoding == encoding and
_cache.errors == errors
@@ -68,6 +69,7 @@ def encode(decoded, encoding, errors='strict'):
"""
global _cache
cached = (
+ isinstance(decoded, bytes) and
_cache.decoded == decoded and
_cache.encoding == encoding and
_cache.errors == errors
diff --git a/netlib/http/request.py b/netlib/http/request.py
index ecaa9b79..061217a3 100644
--- a/netlib/http/request.py
+++ b/netlib/http/request.py
@@ -253,14 +253,13 @@ class Request(message.Message):
)
def _get_query(self):
- _, _, _, _, query, _ = urllib.parse.urlparse(self.url)
+ query = urllib.parse.urlparse(self.url).query
return tuple(netlib.http.url.decode(query))
- def _set_query(self, value):
- query = netlib.http.url.encode(value)
- scheme, netloc, path, params, _, fragment = urllib.parse.urlparse(self.url)
- _, _, _, self.path = netlib.http.url.parse(
- urllib.parse.urlunparse([scheme, netloc, path, params, query, fragment]))
+ def _set_query(self, query_data):
+ query = netlib.http.url.encode(query_data)
+ _, _, path, params, _, fragment = urllib.parse.urlparse(self.url)
+ self.path = urllib.parse.urlunparse(["", "", path, params, query, fragment])
@query.setter
def query(self, value):
@@ -296,19 +295,18 @@ class Request(message.Message):
The URL's path components as a tuple of strings.
Components are unquoted.
"""
- _, _, path, _, _, _ = urllib.parse.urlparse(self.url)
+ path = urllib.parse.urlparse(self.url).path
# This needs to be a tuple so that it's immutable.
# Otherwise, this would fail silently:
# request.path_components.append("foo")
- return tuple(urllib.parse.unquote(i) for i in path.split("/") if i)
+ return tuple(netlib.http.url.unquote(i) for i in path.split("/") if i)
@path_components.setter
def path_components(self, components):
- components = map(lambda x: urllib.parse.quote(x, safe=""), components)
+ components = map(lambda x: netlib.http.url.quote(x, safe=""), components)
path = "/" + "/".join(components)
- scheme, netloc, _, params, query, fragment = urllib.parse.urlparse(self.url)
- _, _, _, self.path = netlib.http.url.parse(
- urllib.parse.urlunparse([scheme, netloc, path, params, query, fragment]))
+ _, _, _, params, query, fragment = urllib.parse.urlparse(self.url)
+ self.path = urllib.parse.urlunparse(["", "", path, params, query, fragment])
def anticache(self):
"""
@@ -365,13 +363,13 @@ class Request(message.Message):
pass
return ()
- def _set_urlencoded_form(self, value):
+ def _set_urlencoded_form(self, form_data):
"""
Sets the body to the URL-encoded form data, and adds the appropriate content-type header.
This will overwrite the existing content if there is one.
"""
self.headers["content-type"] = "application/x-www-form-urlencoded"
- self.content = netlib.http.url.encode(value).encode()
+ self.content = netlib.http.url.encode(form_data).encode()
@urlencoded_form.setter
def urlencoded_form(self, value):
diff --git a/netlib/http/url.py b/netlib/http/url.py
index 1c8c007a..076854b9 100644
--- a/netlib/http/url.py
+++ b/netlib/http/url.py
@@ -82,19 +82,51 @@ def unparse(scheme, host, port, path=""):
def encode(s):
- # type: (six.text_type, bytes) -> str
+ # type: Sequence[Tuple[str,str]] -> str
"""
Takes a list of (key, value) tuples and returns a urlencoded string.
"""
- s = [tuple(i) for i in s]
- return urllib.parse.urlencode(s, False)
+ if six.PY2:
+ return urllib.parse.urlencode(s, False)
+ else:
+ return urllib.parse.urlencode(s, False, errors="surrogateescape")
def decode(s):
"""
- Takes a urlencoded string and returns a list of (key, value) tuples.
+ Takes a urlencoded string and returns a list of surrogate-escaped (key, value) tuples.
+ """
+ if six.PY2:
+ return urllib.parse.parse_qsl(s, keep_blank_values=True)
+ else:
+ return urllib.parse.parse_qsl(s, keep_blank_values=True, errors='surrogateescape')
+
+
+def quote(b, safe="/"):
+ """
+ Returns:
+ An ascii-encodable str.
+ """
+ # type: (str) -> str
+ if six.PY2:
+ return urllib.parse.quote(b, safe=safe)
+ else:
+ return urllib.parse.quote(b, safe=safe, errors="surrogateescape")
+
+
+def unquote(s):
"""
- return urllib.parse.parse_qsl(s, keep_blank_values=True)
+ Args:
+ s: A surrogate-escaped str
+ Returns:
+ A surrogate-escaped str
+ """
+ # type: (str) -> str
+
+ if six.PY2:
+ return urllib.parse.unquote(s)
+ else:
+ return urllib.parse.unquote(s, errors="surrogateescape")
def hostport(scheme, host, port):
diff --git a/netlib/strutils.py b/netlib/strutils.py
index 96c8b10f..8f27ebb7 100644
--- a/netlib/strutils.py
+++ b/netlib/strutils.py
@@ -98,6 +98,9 @@ def bytes_to_escaped_str(data, keep_spacing=False):
def escaped_str_to_bytes(data):
"""
Take an escaped string and return the unescaped bytes equivalent.
+
+ Raises:
+ ValueError, if the escape sequence is invalid.
"""
if not isinstance(data, six.string_types):
if six.PY2:
diff --git a/release/setup.py b/release/setup.py
index 601654e5..b8eb6eec 100644
--- a/release/setup.py
+++ b/release/setup.py
@@ -6,7 +6,7 @@ setup(
py_modules=["rtool"],
install_requires=[
"click>=6.2, <7.0",
- "twine>=1.6.5, <1.7",
+ "twine>=1.6.5, <1.8",
"virtualenv>=14.0.5, <15.1",
"wheel>=0.29.0, <0.30",
"six>=1.10.0, <1.11",
diff --git a/setup.py b/setup.py
index e0bd4545..23eb3b26 100644
--- a/setup.py
+++ b/setup.py
@@ -71,7 +71,7 @@ setup(
"h2>=2.4.0, <3",
"html2text>=2016.1.8, <=2016.5.29",
"hyperframe>=4.0.1, <5",
- "lxml>=3.5.0, <3.7",
+ "lxml>=3.5.0, <=3.6.0", # no wheels for 3.6.1 yet.
"Pillow>=3.2, <3.4",
"passlib>=1.6.5, <1.7",
"pyasn1>=0.1.9, <0.2",
@@ -80,7 +80,7 @@ setup(
"pyperclip>=1.5.22, <1.6",
"requests>=2.9.1, <2.11",
"six>=1.10, <1.11",
- "tornado>=4.3, <4.4",
+ "tornado>=4.3, <4.5",
"urwid>=1.3.1, <1.4",
"watchdog>=0.8.3, <0.9",
],
@@ -116,9 +116,9 @@ setup(
# "pyamf>=0.8.0, <0.9",
],
'examples': [
- "beautifulsoup4>=4.4.1, <4.5",
+ "beautifulsoup4>=4.4.1, <4.6",
"harparser>=0.2, <0.3",
- "pytz>=2015.07.0, <=2016.4",
+ "pytz>=2015.07.0, <=2016.6.1",
]
}
)
diff --git a/test/netlib/http/test_url.py b/test/netlib/http/test_url.py
index 26b37230..768e5130 100644
--- a/test/netlib/http/test_url.py
+++ b/test/netlib/http/test_url.py
@@ -1,3 +1,4 @@
+import six
from netlib import tutils
from netlib.http import url
@@ -57,10 +58,49 @@ def test_unparse():
assert url.unparse("https", "foo.com", 443, "") == "https://foo.com"
-def test_urlencode():
+if six.PY2:
+ surrogates = bytes(bytearray(range(256)))
+else:
+ surrogates = bytes(range(256)).decode("utf8", "surrogateescape")
+
+surrogates_quoted = (
+ '%00%01%02%03%04%05%06%07%08%09%0A%0B%0C%0D%0E%0F'
+ '%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F'
+ '%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-./'
+ '0123456789%3A%3B%3C%3D%3E%3F'
+ '%40ABCDEFGHIJKLMNO'
+ 'PQRSTUVWXYZ%5B%5C%5D%5E_'
+ '%60abcdefghijklmno'
+ 'pqrstuvwxyz%7B%7C%7D%7E%7F'
+ '%80%81%82%83%84%85%86%87%88%89%8A%8B%8C%8D%8E%8F'
+ '%90%91%92%93%94%95%96%97%98%99%9A%9B%9C%9D%9E%9F'
+ '%A0%A1%A2%A3%A4%A5%A6%A7%A8%A9%AA%AB%AC%AD%AE%AF'
+ '%B0%B1%B2%B3%B4%B5%B6%B7%B8%B9%BA%BB%BC%BD%BE%BF'
+ '%C0%C1%C2%C3%C4%C5%C6%C7%C8%C9%CA%CB%CC%CD%CE%CF'
+ '%D0%D1%D2%D3%D4%D5%D6%D7%D8%D9%DA%DB%DC%DD%DE%DF'
+ '%E0%E1%E2%E3%E4%E5%E6%E7%E8%E9%EA%EB%EC%ED%EE%EF'
+ '%F0%F1%F2%F3%F4%F5%F6%F7%F8%F9%FA%FB%FC%FD%FE%FF'
+)
+
+
+def test_encode():
assert url.encode([('foo', 'bar')])
+ assert url.encode([('foo', surrogates)])
-def test_urldecode():
+def test_decode():
s = "one=two&three=four"
assert len(url.decode(s)) == 2
+ assert url.decode(surrogates)
+
+
+def test_quote():
+ assert url.quote("foo") == "foo"
+ assert url.quote("foo bar") == "foo%20bar"
+ assert url.quote(surrogates) == surrogates_quoted
+
+
+def test_unquote():
+ assert url.unquote("foo") == "foo"
+ assert url.unquote("foo%20bar") == "foo bar"
+ assert url.unquote(surrogates_quoted) == surrogates
diff --git a/tox.ini b/tox.ini
index 9da23a2e..ff0e41c2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,5 +17,5 @@ changedir = docs
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
[testenv:lint]
-deps = flake8>=2.6.2, <3
+deps = flake8>=2.6.2, <3.1
commands = flake8 --jobs 8 --count mitmproxy netlib pathod examples test
diff --git a/web/package.json b/web/package.json
index 66a12501..fb2c8c30 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,15 +12,13 @@
"<rootDir>/src/js"
],
"unmockedModulePathPatterns": [
- "react",
- "jquery"
+ "react"
]
},
"dependencies": {
"bootstrap": "^3.3.6",
"classnames": "^2.2.5",
"flux": "^2.1.1",
- "jquery": "^2.2.3",
"lodash": "^4.11.2",
"react": "^15.1.0",
"react-dom": "^15.1.0",
@@ -30,7 +28,7 @@
"redux-logger": "^2.6.1",
"redux-thunk": "^2.1.0",
"shallowequal": "^0.2.2",
- "react-codemirror" : "^0.2.6"
+ "react-codemirror": "^0.2.6"
},
"devDependencies": {
"babel-core": "^6.7.7",
@@ -56,7 +54,9 @@
"gulp-sourcemaps": "^1.6.0",
"gulp-util": "^3.0.7",
"jest": "^12.1.1",
- "react-addons-test-utils": "^15.1.0",
+ "react": "^15.2.1",
+ "react-addons-test-utils": "^15.2.1",
+ "react-dom": "^15.2.1",
"uglifyify": "^3.0.1",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0",
diff --git a/web/src/css/flowdetail.less b/web/src/css/flowdetail.less
index 35857729..d450bca5 100644
--- a/web/src/css/flowdetail.less
+++ b/web/src/css/flowdetail.less
@@ -102,11 +102,23 @@
}
.header-name {
width: 33%;
- padding-right: 1em;
}
.header-value {
}
+
+ // This exists so that you can copy
+ // and paste headers out of mitmweb.
+ .header-colon {
+ position: absolute;
+ opacity: 0;
+ }
+
+ .inline-input {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ }
}
.connection-table, .timing-table {
diff --git a/web/src/images/favicon.ico b/web/src/images/favicon.ico
new file mode 100644
index 00000000..bfd2fde7
--- /dev/null
+++ b/web/src/images/favicon.ico
Binary files differ
diff --git a/web/src/js/components/ContentView.jsx b/web/src/js/components/ContentView.jsx
index f7eafc89..75662509 100644
--- a/web/src/js/components/ContentView.jsx
+++ b/web/src/js/components/ContentView.jsx
@@ -1,12 +1,12 @@
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
-import { MessageUtils } from '../flow/utils.js'
import * as ContentViews from './ContentView/ContentViews'
import * as MetaViews from './ContentView/MetaViews'
-import ContentLoader from './ContentView/ContentLoader'
import ViewSelector from './ContentView/ViewSelector'
+import UploadContentButton from './ContentView/UploadContentButton'
+import DownloadContentButton from './ContentView/DownloadContentButton'
+
import { setContentView, displayLarge, updateEdit } from '../ducks/ui/flow'
-import CodeEditor from './common/CodeEditor'
ContentView.propTypes = {
// It may seem a bit weird at the first glance:
@@ -19,61 +19,32 @@ ContentView.propTypes = {
ContentView.isContentTooLarge = msg => msg.contentLength > 1024 * 1024 * (ContentViews.ViewImage.matches(msg) ? 10 : 0.2)
function ContentView(props) {
- const { flow, message, contentView, selectView, displayLarge, setDisplayLarge, onContentChange, isFlowEditorOpen, setModifiedFlowContent } = props
+ const { flow, message, contentView, isDisplayLarge, displayLarge, uploadContent, onContentChange, readonly } = props
- if (message.contentLength === 0) {
+ if (message.contentLength === 0 && readonly) {
return <MetaViews.ContentEmpty {...props}/>
}
- if (message.contentLength === null) {
+ if (message.contentLength === null && readonly) {
return <MetaViews.ContentMissing {...props}/>
}
- if (!displayLarge && ContentView.isContentTooLarge(message)) {
+ if (!isDisplayLarge && ContentView.isContentTooLarge(message)) {
return <MetaViews.ContentTooLarge {...props} onClick={displayLarge}/>
}
const View = ContentViews[contentView]
-
return (
<div>
- {isFlowEditorOpen ? (
- <ContentLoader flow={flow} message={message}>
- <CodeEditor content="" onChange={content =>{setModifiedFlowContent(content)}}/>
- </ContentLoader>
- ): (
- <div>
- {View.textView ? (
- <ContentLoader flow={flow} message={message}>
- <View content="" />
- </ContentLoader>
- ) : (
- <View flow={flow} message={message} />
- )}
- <div className="view-options text-center">
- <ViewSelector onSelectView={selectView} active={View} message={message}/>
- &nbsp;
- <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>
- &nbsp;
- <a className="btn btn-default btn-xs"
- onClick={() => ContentView.fileInput.click()}
- title="Upload a file to replace the content."
- >
- <i className="fa fa-upload"/>
- </a>
- <input
- ref={ref => ContentView.fileInput = ref}
- className="hidden"
- type="file"
- onChange={e => {if(e.target.files.length > 0) onContentChange(e.target.files[0])}}
- />
- </div>
- </div>
- )}
+ <View flow={flow} message={message} readonly={readonly} onChange={onContentChange}/>
+
+ <div className="view-options text-center">
+ <ViewSelector message={message}/>
+ &nbsp;
+ <DownloadContentButton flow={flow} message={message}/>
+ &nbsp;
+ <UploadContentButton uploadContent={uploadContent}/>
+ </div>
</div>
)
}
@@ -81,12 +52,10 @@ function ContentView(props) {
export default connect(
state => ({
contentView: state.ui.flow.contentView,
- displayLarge: state.ui.flow.displayLarge,
- isFlowEditorOpen : !!state.ui.flow.modifiedFlow // FIXME
+ isDisplayLarge: state.ui.flow.displayLarge,
}),
{
- selectView: setContentView,
displayLarge,
- updateEdit,
+ updateEdit
}
)(ContentView)
diff --git a/web/src/js/components/ContentView/CodeEditor.jsx b/web/src/js/components/ContentView/CodeEditor.jsx
new file mode 100644
index 00000000..95f1b98b
--- /dev/null
+++ b/web/src/js/components/ContentView/CodeEditor.jsx
@@ -0,0 +1,21 @@
+import React, { Component, PropTypes } from 'react'
+import { render } from 'react-dom';
+import Codemirror from 'react-codemirror';
+
+
+CodeEditor.propTypes = {
+ content: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+}
+
+export default function CodeEditor ( { content, onChange} ){
+
+ let options = {
+ lineNumbers: true
+ };
+ return (
+ <div onKeyDown={e => e.stopPropagation()}>
+ <Codemirror value={content} onChange={onChange} options={options}/>
+ </div>
+ )
+}
diff --git a/web/src/js/components/ContentView/ContentLoader.jsx b/web/src/js/components/ContentView/ContentLoader.jsx
index 1a23325c..ba6702ca 100644
--- a/web/src/js/components/ContentView/ContentLoader.jsx
+++ b/web/src/js/components/ContentView/ContentLoader.jsx
@@ -1,53 +1,36 @@
import React, { Component, PropTypes } from 'react'
import { MessageUtils } from '../../flow/utils.js'
-// This is the only place where we use jQuery.
-// Remove when possible.
-import $ from "jquery"
-export default class ContentLoader extends Component {
+export default View => class extends React.Component {
+
+ static displayName = View.displayName || View.name
+ static matches = View.matches
static propTypes = {
+ ...View.propTypes,
+ content: PropTypes.string, // mark as non-required
flow: PropTypes.object.isRequired,
message: PropTypes.object.isRequired,
}
- constructor(props, context) {
- super(props, context)
- this.state = { content: null, request: null }
- }
-
- requestContent(nextProps) {
- if (this.state.request) {
- this.state.request.abort()
+ constructor(props) {
+ super(props)
+ this.state = {
+ content: undefined,
+ request: undefined,
}
-
- const requestUrl = MessageUtils.getContentURL(nextProps.flow, nextProps.message)
- const request = $.get(requestUrl)
-
- this.setState({ content: null, request })
-
- request
- .done(content => {
- this.setState({ content })
- })
- .fail((xhr, textStatus, errorThrown) => {
- if (textStatus === 'abort') {
- return
- }
- this.setState({ content: `AJAX Error: ${textStatus}\r\n${errorThrown}` })
- })
- .always(() => {
- this.setState({ request: null })
- })
}
componentWillMount() {
- this.requestContent(this.props)
+ this.updateContent(this.props)
}
componentWillReceiveProps(nextProps) {
- if (nextProps.message !== this.props.message) {
- this.requestContent(nextProps)
+ if (
+ nextProps.message.content !== this.props.message.content ||
+ nextProps.message.contentHash !== this.props.message.contentHash
+ ) {
+ this.updateContent(nextProps)
}
}
@@ -57,15 +40,58 @@ export default class ContentLoader extends Component {
}
}
+ 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 || props.message.contentLength === null){
+ return this.setState({request: undefined, content: ""})
+ }
+
+ let requestUrl = MessageUtils.getContentURL(props.flow, props.message)
+
+ // 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 ? (
- React.cloneElement(this.props.children, {
- content: this.state.content
- })
+ 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>
)
}
-}
+};
diff --git a/web/src/js/components/ContentView/ContentViews.jsx b/web/src/js/components/ContentView/ContentViews.jsx
index 82ee0adc..a1adebea 100644
--- a/web/src/js/components/ContentView/ContentViews.jsx
+++ b/web/src/js/components/ContentView/ContentViews.jsx
@@ -1,19 +1,16 @@
import React, { PropTypes } from 'react'
import ContentLoader from './ContentLoader'
-import { MessageUtils } from '../../flow/utils.js'
+import { MessageUtils } from '../../flow/utils'
+import CodeEditor from './CodeEditor'
-const views = [ViewAuto, ViewImage, ViewJSON, ViewRaw]
-
-ViewImage.regex = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i
-ViewImage.matches = msg => ViewImage.regex.test(MessageUtils.getContentType(msg))
-
+const isImage = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i
+ViewImage.matches = msg => isImage.test(MessageUtils.getContentType(msg))
ViewImage.propTypes = {
flow: PropTypes.object.isRequired,
message: PropTypes.object.isRequired,
}
-
-export function ViewImage({ flow, message }) {
+function ViewImage({ flow, message }) {
return (
<div className="flowview-image">
<img src={MessageUtils.getContentURL(flow, message)} alt="preview" className="img-thumbnail"/>
@@ -21,26 +18,23 @@ export function ViewImage({ flow, message }) {
)
}
-ViewRaw.textView = true
-ViewRaw.matches = () => true
+ViewRaw.matches = () => true
ViewRaw.propTypes = {
content: React.PropTypes.string.isRequired,
}
-
-export function ViewRaw({ content }) {
- return <pre>{content}</pre>
+function ViewRaw({ content, readonly, onChange }) {
+ return readonly ? <pre>{content}</pre> : <CodeEditor content={content} onChange={onChange}/>
}
+ViewRaw = ContentLoader(ViewRaw)
-ViewJSON.textView = true
-ViewJSON.regex = /^application\/json$/i
-ViewJSON.matches = msg => ViewJSON.regex.test(MessageUtils.getContentType(msg))
+const isJSON = /^application\/json$/i
+ViewJSON.matches = msg => isJSON.test(MessageUtils.getContentType(msg))
ViewJSON.propTypes = {
content: React.PropTypes.string.isRequired,
}
-
-export function ViewJSON({ content }) {
+function ViewJSON({ content }) {
let json = content
try {
json = JSON.stringify(JSON.parse(content), null, 2);
@@ -49,23 +43,18 @@ export function ViewJSON({ content }) {
}
return <pre>{json}</pre>
}
+ViewJSON = ContentLoader(ViewJSON)
ViewAuto.matches = () => false
-ViewAuto.findView = msg => views.find(v => v.matches(msg)) || views[views.length - 1]
-
+ViewAuto.findView = msg => [ViewImage, ViewJSON, ViewRaw].find(v => v.matches(msg)) || ViewRaw
ViewAuto.propTypes = {
message: React.PropTypes.object.isRequired,
flow: React.PropTypes.object.isRequired,
}
-
-export function ViewAuto({ message, flow }) {
+function ViewAuto({ message, flow, readonly, onChange }) {
const View = ViewAuto.findView(message)
- if (View.textView) {
- return <ContentLoader message={message} flow={flow}><View content="" /></ContentLoader>
- } else {
- return <View message={message} flow={flow} />
- }
+ return <View message={message} flow={flow} readonly={readonly} onChange={onChange}/>
}
-export default views
+export { ViewImage, ViewRaw, ViewAuto, ViewJSON }
diff --git a/web/src/js/components/ContentView/DownloadContentButton.jsx b/web/src/js/components/ContentView/DownloadContentButton.jsx
new file mode 100644
index 00000000..3f11f909
--- /dev/null
+++ b/web/src/js/components/ContentView/DownloadContentButton.jsx
@@ -0,0 +1,18 @@
+import { MessageUtils } from "../../flow/utils"
+import { PropTypes } from 'react'
+
+DownloadContentButton.propTypes = {
+ flow: PropTypes.object.isRequired,
+ message: PropTypes.object.isRequired,
+}
+
+export default function DownloadContentButton({ flow, message }) {
+
+ 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>
+ )
+}
diff --git a/web/src/js/components/ContentView/MetaViews.jsx b/web/src/js/components/ContentView/MetaViews.jsx
index 2d064b54..b926738e 100644
--- a/web/src/js/components/ContentView/MetaViews.jsx
+++ b/web/src/js/components/ContentView/MetaViews.jsx
@@ -1,5 +1,7 @@
import React from 'react'
import { formatSize } from '../../utils.js'
+import UploadContentButton from './UploadContentButton'
+import DownloadContentButton from './DownloadContentButton'
export function ContentEmpty({ flow, message }) {
return (
@@ -17,11 +19,19 @@ export function ContentMissing({ flow, message }) {
)
}
-export function ContentTooLarge({ message, onClick }) {
+export function ContentTooLarge({ message, onClick, uploadContent, flow }) {
return (
- <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="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>
)
}
diff --git a/web/src/js/components/ContentView/UploadContentButton.jsx b/web/src/js/components/ContentView/UploadContentButton.jsx
new file mode 100644
index 00000000..0652b584
--- /dev/null
+++ b/web/src/js/components/ContentView/UploadContentButton.jsx
@@ -0,0 +1,28 @@
+import { PropTypes } from 'react'
+
+UploadContentButton.propTypes = {
+ uploadContent: PropTypes.func.isRequired,
+}
+
+export default function UploadContentButton({ uploadContent }) {
+
+ let fileInput;
+
+ return (
+ <a className="btn btn-default btn-xs"
+ onClick={() => fileInput.click()}
+ title="Upload a file to replace the content.">
+ <i className="fa fa-upload"/>
+ <input
+ ref={ref => fileInput = ref}
+ className="hidden"
+ type="file"
+ onChange={e => {
+ if (e.target.files.length > 0) uploadContent(e.target.files[0])
+ }}
+ />
+ </a>
+
+ )
+}
+
diff --git a/web/src/js/components/ContentView/ViewSelector.jsx b/web/src/js/components/ContentView/ViewSelector.jsx
index 9b151a5b..89b36231 100644
--- a/web/src/js/components/ContentView/ViewSelector.jsx
+++ b/web/src/js/components/ContentView/ViewSelector.jsx
@@ -1,28 +1,47 @@
import React, { PropTypes } from 'react'
import classnames from 'classnames'
-import views, { ViewAuto } from './ContentViews'
+import { connect } from 'react-redux'
+import * as ContentViews from './ContentViews'
+import { setContentView } from "../../ducks/ui/flow";
+
+
+function ViewButton({ name, setContentView, children, activeView }) {
+ return (
+ <button
+ onClick={() => setContentView(name)}
+ className={classnames('btn btn-default', { active: name === activeView })}>
+ {children}
+ </button>
+ )
+}
+ViewButton = connect(state => ({
+ activeView: state.ui.flow.contentView
+}), {
+ setContentView
+})(ViewButton)
+
ViewSelector.propTypes = {
- active: PropTypes.func.isRequired,
message: PropTypes.object.isRequired,
- onSelectView: PropTypes.func.isRequired,
}
+export default function ViewSelector({ message }) {
+
+ let autoView = ContentViews.ViewAuto.findView(message)
+ let autoViewName = (autoView.displayName || autoView.name)
+ .toLowerCase()
+ .replace('view', '')
+ .replace(/ContentLoader\((.+)\)/,"$1")
-export default function ViewSelector({ active, message, onSelectView }) {
return (
<div className="view-selector btn-group btn-group-xs">
- {views.map(View => (
- <button
- key={View.name}
- onClick={() => onSelectView(View.name)}
- className={classnames('btn btn-default', { active: View === active })}>
- {View === ViewAuto ? (
- `auto: ${ViewAuto.findView(message).name.toLowerCase().replace('view', '')}`
- ) : (
- View.name.toLowerCase().replace('view', '')
- )}
- </button>
- ))}
+
+ <ViewButton name="ViewAuto">auto: {autoViewName}</ViewButton>
+
+ {Object.keys(ContentViews).map(name =>
+ name !== "ViewAuto" &&
+ <ViewButton key={name} name={name}>{name.toLowerCase().replace('view', '')}</ViewButton>
+ )}
+
</div>
)
}
diff --git a/web/src/js/components/FlowView/Headers.jsx b/web/src/js/components/FlowView/Headers.jsx
index 706dd404..2e181383 100644
--- a/web/src/js/components/FlowView/Headers.jsx
+++ b/web/src/js/components/FlowView/Headers.jsx
@@ -126,7 +126,8 @@ export default class Headers extends Component {
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
diff --git a/web/src/js/components/FlowView/Messages.jsx b/web/src/js/components/FlowView/Messages.jsx
index 133b2883..9de25b5b 100644
--- a/web/src/js/components/FlowView/Messages.jsx
+++ b/web/src/js/components/FlowView/Messages.jsx
@@ -10,6 +10,7 @@ import ValueEditor from '../ValueEditor/ValueEditor'
import Headers from './Headers'
import { startEdit, updateEdit } from '../../ducks/ui/flow'
+import * as FlowActions from '../../ducks/flows'
import ToggleEdit from './ToggleEdit'
function RequestLine({ flow, readonly, updateFlow }) {
@@ -73,12 +74,13 @@ const Message = connect(
}),
{
updateFlow: updateEdit,
+ uploadContent: FlowActions.uploadContent
}
)
export class Request extends Component {
render() {
- const { flow, isEdit, updateFlow } = this.props
+ const { flow, isEdit, updateFlow, uploadContent } = this.props
return (
<section className="request">
@@ -94,7 +96,12 @@ export class Request extends Component {
/>
<hr/>
- <ContentView flow={flow} message={flow.request}/>
+ <ContentView
+ readonly={!isEdit}
+ flow={flow}
+ onContentChange={content => updateFlow({ request: {content}})}
+ uploadContent={content => uploadContent(flow, content, "request")}
+ message={flow.request}/>
</section>
)
}
@@ -129,7 +136,7 @@ Request = Message(Request)
export class Response extends Component {
render() {
- const { flow, isEdit, updateFlow } = this.props
+ const { flow, isEdit, updateFlow, uploadContent } = this.props
return (
<section className="response">
@@ -144,7 +151,13 @@ export class Response extends Component {
onChange={headers => updateFlow({ response: { headers } })}
/>
<hr/>
- <ContentView flow={flow} message={flow.response}/>
+ <ContentView
+ readonly={!isEdit}
+ flow={flow}
+ onContentChange={content => updateFlow({ response: {content}})}
+ uploadContent={content => uploadContent(flow, content, "response")}
+ message={flow.response}
+ />
</section>
)
}
diff --git a/web/src/js/components/FlowView/ToggleEdit.jsx b/web/src/js/components/FlowView/ToggleEdit.jsx
index 0c8cbbd8..9016348e 100644
--- a/web/src/js/components/FlowView/ToggleEdit.jsx
+++ b/web/src/js/components/FlowView/ToggleEdit.jsx
@@ -10,11 +10,11 @@ ToggleEdit.propTypes = {
stopEdit: PropTypes.func.isRequired,
}
-function ToggleEdit({ isEdit, startEdit, stopEdit, flow }) {
+function ToggleEdit({ isEdit, startEdit, stopEdit, flow, modifiedFlow }) {
return (
<div className="edit-flow-container">
{isEdit ?
- <a className="edit-flow" onClick={() => stopEdit(flow)}>
+ <a className="edit-flow" onClick={() => stopEdit(flow, modifiedFlow)}>
<i className="fa fa-check"/>
</a>
:
@@ -29,7 +29,8 @@ function ToggleEdit({ isEdit, startEdit, stopEdit, flow }) {
export default connect(
state => ({
isEdit: !!state.ui.flow.modifiedFlow,
- flow: state.ui.flow.modifiedFlow || state.flows.byId[state.flows.selected[0]]
+ modifiedFlow: state.ui.flow.modifiedFlow || state.flows.byId[state.flows.selected[0]],
+ flow: state.flows.byId[state.flows.selected[0]]
}),
{
startEdit,
diff --git a/web/src/js/components/ValueEditor/ValueEditor.jsx b/web/src/js/components/ValueEditor/ValueEditor.jsx
index dd9c2cde..852f82c4 100644
--- a/web/src/js/components/ValueEditor/ValueEditor.jsx
+++ b/web/src/js/components/ValueEditor/ValueEditor.jsx
@@ -59,7 +59,7 @@ export default class ValueEditor extends Component {
return (
<div
ref={input => this.input = input}
- tabIndex={!this.props.readonly && "0"}
+ tabIndex={this.props.readonly ? undefined : 0}
className={className}
contentEditable={this.state.editable || undefined}
onFocus={this.onFocus}
diff --git a/web/src/js/components/common/CodeEditor.jsx b/web/src/js/components/common/CodeEditor.jsx
deleted file mode 100644
index 5b2305a8..00000000
--- a/web/src/js/components/common/CodeEditor.jsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React, { Component, PropTypes } from 'react'
-import { render } from 'react-dom';
-import Codemirror from 'react-codemirror';
-
-
-export default class CodeEditor extends Component{
- static propTypes = {
- content: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- }
-
- constructor(props){
- super(props)
- }
-
- componentWillMount(){
- this.props.onChange(this.props.content)
- }
-
- render() {
- let options = {
- lineNumbers: true
- };
- return (
- <div onKeyDown={e => e.stopPropagation()}>
- <Codemirror value={this.props.content} onChange={this.props.onChange} options={options}/>
- </div>
- )
- }
-}
diff --git a/web/src/js/ducks/flows.js b/web/src/js/ducks/flows.js
index f18e48e6..f96653a9 100644
--- a/web/src/js/ducks/flows.js
+++ b/web/src/js/ducks/flows.js
@@ -112,10 +112,9 @@ export function update(flow, data) {
return dispatch => fetchApi.put(`/flows/${flow.id}`, data)
}
-export function updateContent(flow, file, type) {
+export function uploadContent(flow, file, type) {
const body = new FormData()
- if (typeof file !== File)
- file = new Blob([file], {type: 'plain/text'})
+ file = new Blob([file], {type: 'plain/text'})
body.append('file', file)
return dispatch => fetchApi(`/flows/${flow.id}/${type}/content`, {method: 'post', body} )
}
diff --git a/web/src/js/ducks/ui/flow.js b/web/src/js/ducks/ui/flow.js
index b1fe535f..c9435676 100644
--- a/web/src/js/ducks/ui/flow.js
+++ b/web/src/js/ducks/ui/flow.js
@@ -1,4 +1,6 @@
import * as flowsActions from '../flows'
+import { getDiff } from "../../utils"
+
import _ from 'lodash'
export const SET_CONTENT_VIEW = 'UI_FLOWVIEW_SET_CONTENT_VIEW',
@@ -6,7 +8,7 @@ export const SET_CONTENT_VIEW = 'UI_FLOWVIEW_SET_CONTENT_VIEW',
SET_TAB = "UI_FLOWVIEW_SET_TAB",
START_EDIT = 'UI_FLOWVIEW_START_EDIT',
UPDATE_EDIT = 'UI_FLOWVIEW_UPDATE_EDIT',
- STOP_EDIT = 'UI_FLOWVIEW_STOP_EDIT'
+ UPLOAD_CONTENT = 'UI_FLOWVIEW_UPLOAD_CONTENT'
const defaultState = {
@@ -22,7 +24,7 @@ export default function reducer(state = defaultState, action) {
case START_EDIT:
return {
...state,
- modifiedFlow: action.flow
+ modifiedFlow: action.flow,
}
case UPDATE_EDIT:
@@ -31,12 +33,6 @@ export default function reducer(state = defaultState, action) {
modifiedFlow: _.merge({}, state.modifiedFlow, action.update)
}
- case STOP_EDIT:
- return {
- ...state,
- modifiedFlow: false
- }
-
case flowsActions.SELECT:
return {
...state,
@@ -44,6 +40,21 @@ export default function reducer(state = defaultState, action) {
displayLarge: false,
}
+ case flowsActions.UPDATE:
+ // There is no explicit "stop edit" event.
+ // We stop editing when we receive an update for
+ // the currently edited flow from the server
+ if (action.item.id === state.modifiedFlow.id) {
+ return {
+ ...state,
+ modifiedFlow: false,
+ displayLarge: false,
+ }
+ } else {
+ return state
+ }
+
+
case SET_TAB:
return {
...state,
@@ -87,11 +98,7 @@ export function updateEdit(update) {
return { type: UPDATE_EDIT, update }
}
-export function stopEdit(flow) {
- return (dispatch) => {
- dispatch(flowsActions.update(flow, flow)).then(() => {
- dispatch(flowsActions.updateFlow(flow))
- dispatch({ type: STOP_EDIT })
- })
- }
+export function stopEdit(flow, modifiedFlow) {
+ let diff = getDiff(flow, modifiedFlow)
+ return flowsActions.update(flow, diff)
}
diff --git a/web/src/js/utils.js b/web/src/js/utils.js
index eecacfbb..e44182d0 100644
--- a/web/src/js/utils.js
+++ b/web/src/js/utils.js
@@ -108,11 +108,22 @@ fetchApi.put = (url, json, options) => fetchApi(
}
)
+export function getDiff(obj1, obj2) {
+ let result = {...obj2};
+ for(let key in obj1) {
+ if(_.isEqual(obj2[key], obj1[key]))
+ result[key] = undefined
+ else if(!(Array.isArray(obj2[key]) && Array.isArray(obj1[key])) &&
+ typeof obj2[key] == 'object' && typeof obj1[key] == 'object')
+ result[key] = getDiff(obj1[key], obj2[key])
+ }
+ return result
+}
+
export const pure = renderFn => class extends React.Component {
static displayName = renderFn.name
shouldComponentUpdate(nextProps) {
- console.log(!shallowEqual(this.props, nextProps))
return !shallowEqual(this.props, nextProps)
}
diff --git a/web/src/templates/index.html b/web/src/templates/index.html
index 165d7d3d..db9d2ecb 100644
--- a/web/src/templates/index.html
+++ b/web/src/templates/index.html
@@ -6,10 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/vendor.css"/>
<link rel="stylesheet" href="/static/app.css"/>
+ <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon"/>
<script src="/static/vendor.js"></script>
<script src="/static/app.js"></script>
</head>
<body>
<div id="mitmproxy"></div>
</body>
-</html> \ No newline at end of file
+</html>