diff options
25 files changed, 357 insertions, 212 deletions
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/master.py b/mitmproxy/console/master.py index f7c99ecb..db414147 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: 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/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/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..697085a9 100644 --- a/web/src/js/components/ContentView/ContentLoader.jsx +++ b/web/src/js/components/ContentView/ContentLoader.jsx @@ -1,53 +1,33 @@ 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.startRequest(this.props) } componentWillReceiveProps(nextProps) { - if (nextProps.message !== this.props.message) { - this.requestContent(nextProps) + if (nextProps.message.contentHash !== this.props.message.contentHash) { + this.startRequest(nextProps) } } @@ -57,15 +37,54 @@ export default class ContentLoader extends Component { } } + startRequest(props) { + if (this.state.request) { + this.state.request.abort() + } + 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/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/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/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..100bc771 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,8 @@ 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' + STOP_EDIT = 'UI_FLOWVIEW_STOP_EDIT', + UPLOAD_CONTENT = 'UI_FLOWVIEW_UPLOAD_CONTENT' const defaultState = { @@ -22,7 +25,7 @@ export default function reducer(state = defaultState, action) { case START_EDIT: return { ...state, - modifiedFlow: action.flow + modifiedFlow: action.flow, } case UPDATE_EDIT: @@ -87,10 +90,11 @@ export function updateEdit(update) { return { type: UPDATE_EDIT, update } } -export function stopEdit(flow) { +export function stopEdit(flow, modified_flow) { + let diff = getDiff(flow, modified_flow) return (dispatch) => { - dispatch(flowsActions.update(flow, flow)).then(() => { - dispatch(flowsActions.updateFlow(flow)) + dispatch(flowsActions.update(flow, diff)).then(() => { + dispatch(flowsActions.updateFlow(modified_flow)) dispatch({ type: STOP_EDIT }) }) } 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) } |