diff options
36 files changed, 1290 insertions, 955 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/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/master.py b/mitmproxy/console/master.py index f7c99ecb..ad46cbb4 100644 --- a/mitmproxy/console/master.py +++ b/mitmproxy/console/master.py @@ -182,7 +182,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 +390,12 @@ class ConsoleMaster(flow.FlowMaster): ) def spawn_editor(self, data): - fd, name = tempfile.mkstemp('', "mproxy") + text = not isinstance(data, bytes) + fd, name = tempfile.mkstemp('', "mproxy", text=text) os.write(fd, data) os.close(fd) - c = os.environ.get("EDITOR") # 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 +403,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 +570,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/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/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/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/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/web/package.json b/web/package.json index 81b96adc..302803f2 100644 --- a/web/package.json +++ b/web/package.json @@ -11,15 +11,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", @@ -29,7 +27,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", @@ -55,7 +53,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/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}/> - - <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> - - <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}/> + + <DownloadContentButton flow={flow} message={message}/> + + <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}/> + + <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) } |