diff options
author | Jim Shaver <dcypherd@gmail.com> | 2015-06-23 21:48:05 -0500 |
---|---|---|
committer | Jim Shaver <dcypherd@gmail.com> | 2015-06-23 21:48:05 -0500 |
commit | 080e4534253338c94e6d8c86cb3679ff15410f85 (patch) | |
tree | 6322fb822332b4135f0ff14de8c2d7137016f734 /web/src | |
parent | db5c0b210b0133d7cd58124c727dbc24480e2568 (diff) | |
parent | 074d8d7c7463cdb1f0a90e165a4b3ada3554b4c2 (diff) | |
download | mitmproxy-080e4534253338c94e6d8c86cb3679ff15410f85.tar.gz mitmproxy-080e4534253338c94e6d8c86cb3679ff15410f85.tar.bz2 mitmproxy-080e4534253338c94e6d8c86cb3679ff15410f85.zip |
Merge branch 'master' into hardfailvenv
Conflicts:
dev
Diffstat (limited to 'web/src')
34 files changed, 1904 insertions, 780 deletions
diff --git a/web/src/css/app.less b/web/src/css/app.less index 26f22572..046d378a 100644 --- a/web/src/css/app.less +++ b/web/src/css/app.less @@ -13,5 +13,7 @@ html { @import (less) "header.less"; @import (less) "flowtable.less"; @import (less) "flowdetail.less"; +@import (less) "flowview.less"; +@import (less) "prompt.less"; @import (less) "eventlog.less"; @import (less) "footer.less";
\ No newline at end of file diff --git a/web/src/css/eventlog.less b/web/src/css/eventlog.less index 8b0a7647..26dea3cc 100644 --- a/web/src/css/eventlog.less +++ b/web/src/css/eventlog.less @@ -6,7 +6,6 @@ display: flex; flex-direction: column; - > div { background-color: #F2F2F2; padding: 0 5px; @@ -23,7 +22,6 @@ background-color: #fcfcfc; } - .fa-close { cursor: pointer; float: right; diff --git a/web/src/css/flowdetail.less b/web/src/css/flowdetail.less index 7649057f..edf97566 100644 --- a/web/src/css/flowdetail.less +++ b/web/src/css/flowdetail.less @@ -1,13 +1,13 @@ //TODO: Move into some utils -.monospace(){ +.monospace() { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; } - .flow-detail { width: 100%; - overflow: auto; - + overflow-x: auto; + overflow-y: scroll; + nav { background-color: #F2F2F2; } @@ -27,18 +27,50 @@ max-height: 100px; overflow-y: auto; } + .request-line { + margin-bottom: 2px; + } + /*.request .response-line, + .response .request-line { + opacity: 0.7; + }*/ + + hr { + margin: 0 0 5px; + } + } +.inline-input { + margin: 0 -5px; + padding: 0 5px; + + &[contenteditable] { + + background-color: rgba(255, 255, 255, 0.2); + + &.has-warning { + color: rgb(255, 184, 184); + } + &.has-success { + //color: green; + } + } +} + +.view-options { + margin-top: 10px; +} .flow-detail table { .monospace(); width: 100%; table-layout: fixed; word-break: break-all; - + tr { - &:not(:first-child){ - border-top: 1px solid #f7f7f7; + &:not(:first-child) { + border-top: 1px solid #f7f7f7; } } @@ -59,12 +91,15 @@ } .header-table { + td { + line-height: 1.3em; + } .header-name { width: 33%; padding-right: 1em; } .header-value { - + } } diff --git a/web/src/css/flowtable.less b/web/src/css/flowtable.less index d94d9370..3533983c 100644 --- a/web/src/css/flowtable.less +++ b/web/src/css/flowtable.less @@ -1,127 +1,127 @@ //TODO: move into utils .user-select (@val) { - -webkit-touch-callout: @val; - -webkit-user-select: @val; - -khtml-user-select: @val; - -moz-user-select: @val; - -ms-user-select: @val; - user-select: @val; + -webkit-touch-callout: @val; + -webkit-user-select: @val; + -khtml-user-select: @val; + -moz-user-select: @val; + -ms-user-select: @val; + user-select: @val; } .flow-table { - width: 100%; - overflow: auto; - - table { width: 100%; - table-layout: fixed; - } - - thead { - background-color: #F2F2F2; - line-height: 23px; - } - - th { - font-weight: normal; - box-shadow: 0 1px 0 #a6a6a6; - position: relative !important; - padding-left: 1px; - .user-select(none); + overflow: auto; - &.sort-asc, &.sort-desc { - background-color: lighten(#F2F2F2, 3%); - } - &.sort-asc:after, &.sort-desc:after { - font: normal normal normal 14px/1 FontAwesome; - position: absolute; - right: 3px; - top: 3px; - padding: 2px; - background-color: fadeout(lighten(#F2F2F2, 3%), 20%); - } - &.sort-asc:after { - content: "\f0de"; + table { + width: 100%; + table-layout: fixed; } - &.sort-desc:after { - content: "\f0dd"; + + thead { + background-color: #F2F2F2; + line-height: 23px; } - } + th { + font-weight: normal; + box-shadow: 0 1px 0 #a6a6a6; + position: relative !important; + padding-left: 1px; + .user-select(none); - tr { - cursor: pointer; + &.sort-asc, &.sort-desc { + background-color: lighten(#F2F2F2, 3%); + } + &.sort-asc:after, &.sort-desc:after { + font: normal normal normal 14px/1 FontAwesome; + position: absolute; + right: 3px; + top: 3px; + padding: 2px; + background-color: fadeout(lighten(#F2F2F2, 3%), 20%); + } + &.sort-asc:after { + content: "\f0de"; + } + &.sort-desc:after { + content: "\f0dd"; + } - &:nth-child(even) { - background-color: rgba(0, 0, 0, 0.05); - } - &.selected { - background-color: hsla(209, 52%, 84%, 0.5) !important; - } - &.highlighted { - background-color: hsla(48, 100%, 50%, 0.4); } - &.highlighted:nth-child(even) { - background-color: hsla(48, 100%, 50%, 0.5); + + tr { + cursor: pointer; + + &:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); + } + &.selected { + background-color: hsla(209, 52%, 84%, 0.5) !important; + } + &.highlighted { + background-color: hsla(48, 100%, 50%, 0.4); + } + &.highlighted:nth-child(even) { + background-color: hsla(48, 100%, 50%, 0.5); + } } - } - td { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + td { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } - @interceptorange: hsl(30, 100%, 50%); + @interceptorange: hsl(30, 100%, 50%); - tr.intercepted:not(.has-response) { - .col-path, .col-method { - color: @interceptorange; + tr.intercepted:not(.has-response) { + .col-path, .col-method { + color: @interceptorange; + } } - } - tr.intercepted.has-response { - .col-status, .col-size, .col-time { - color: @interceptorange; + tr.intercepted.has-response { + .col-status, .col-size, .col-time { + color: @interceptorange; + } } - } - .fa { - line-height: inherit; - &.pull-right { - margin-left: 0; + .fa { + line-height: inherit; + &.pull-right { + margin-left: 0; + } } - } - .col-tls { - width: 10px; - } - .col-tls-https { - background-color: rgba(0, 185, 0, 0.5); - } - .col-icon { - width: 32px; - } - .col-path { - .fa-repeat { - color: green; - } - .fa-pause { - color: @interceptorange; - } - } - .col-method { - width: 60px; - } - .col-status { - width: 50px; - } - .col-size { - width: 70px; - } - .col-time { - width: 50px; - } - td.col-time, td.col-size { - text-align: right; - } + .col-tls { + width: 10px; + } + .col-tls-https { + background-color: rgba(0, 185, 0, 0.5); + } + .col-icon { + width: 32px; + } + .col-path { + .fa-repeat { + color: green; + } + .fa-pause { + color: @interceptorange; + } + } + .col-method { + width: 60px; + } + .col-status { + width: 50px; + } + .col-size { + width: 70px; + } + .col-time { + width: 50px; + } + td.col-time, td.col-size { + text-align: right; + } }
\ No newline at end of file diff --git a/web/src/css/flowview.less b/web/src/css/flowview.less new file mode 100644 index 00000000..aa8a2df2 --- /dev/null +++ b/web/src/css/flowview.less @@ -0,0 +1,9 @@ +.flowview-image { + + text-align: center; + + img { + max-width: 100%; + max-height: 100%; + } +}
\ No newline at end of file diff --git a/web/src/css/header.less b/web/src/css/header.less index 57f122e8..8fa5e37f 100644 --- a/web/src/css/header.less +++ b/web/src/css/header.less @@ -2,30 +2,32 @@ @import (reference) '../../node_modules/bootstrap/less/mixins/grid.less'; header { - padding-top: 0.5em; - background-color: white; - @separator-color: lighten(grey, 15%); - .menu { - padding: 10px; - border-bottom: solid @separator-color 1px; - } + padding-top: 0.5em; + background-color: white; + @separator-color: lighten(grey, 15%); + .menu { + padding: 10px; + border-bottom: solid @separator-color 1px; + } } @menu-row-gutter-width: 5px; .menu-row { - .make-row(@menu-row-gutter-width); + .make-row(@menu-row-gutter-width); } .filter-input { - .make-md-column(3, @menu-row-gutter-width); + .make-sm-column(3, @menu-row-gutter-width); } .filter-input .popover { - top: 27px; - display: block; - max-width: none; - .popover-content { - max-height: 500px; - overflow-y: auto; - } + top: 27px; + display: block; + max-width: none; + opacity: 0.9; + + .popover-content { + max-height: 500px; + overflow-y: auto; + } }
\ No newline at end of file diff --git a/web/src/css/layout.less b/web/src/css/layout.less index f6807f24..1075d6c9 100644 --- a/web/src/css/layout.less +++ b/web/src/css/layout.less @@ -8,6 +8,8 @@ html, body, #container { display: flex; flex-direction: column; + outline: none; // our root element is focused by default. + > header, > footer, > .eventlog { flex: 0 0 auto; } @@ -15,12 +17,15 @@ html, body, #container { .main-view { flex: 1 1 auto; - + + // All children of #container need an explicit height + // If we don't set this, the scrollbars disappear + // (https://github.com/mitmproxy/mitmproxy/issues/615) + height: 0; + display: flex; flex-direction: row; - outline: 0; - &.vertical { flex-direction: column; } diff --git a/web/src/css/prompt.less b/web/src/css/prompt.less new file mode 100644 index 00000000..eee0426d --- /dev/null +++ b/web/src/css/prompt.less @@ -0,0 +1,27 @@ +.prompt-dialog { + top: 0; + bottom: 0; + left: 0; + right: 0; + position: fixed; + z-index: 100; + background-color: rgba(0, 0, 0, 0.1); +} + +.prompt-content { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 25px; + padding: 2px 5px; + background-color: white; + box-shadow: 0 -1px 3px lightgray; + + .option { + cursor: pointer; + &:not(:last-child)::after { + content: ", "; + } + } +}
\ No newline at end of file diff --git a/web/src/css/sprites.less b/web/src/css/sprites.less index 49b3600c..74131c5e 100644 --- a/web/src/css/sprites.less +++ b/web/src/css/sprites.less @@ -5,34 +5,42 @@ // From Chrome Dev Tools .resource-icon-css { - background-image: url(images/chrome-devtools/resourceCSSIcon.png); + background-image: url(images/chrome-devtools/resourceCSSIcon.png); } + .resource-icon-document { - background-image: url(images/chrome-devtools/resourceDocumentIcon.png); + background-image: url(images/chrome-devtools/resourceDocumentIcon.png); } + .resource-icon-js { - background-image: url(images/chrome-devtools/resourceJSIcon.png); + background-image: url(images/chrome-devtools/resourceJSIcon.png); } + .resource-icon-plain { - background-image: url(images/chrome-devtools/resourcePlainIcon.png); + background-image: url(images/chrome-devtools/resourcePlainIcon.png); } // Own .resource-icon-executable { - background-image: url(images/resourceExecutableIcon.png); + background-image: url(images/resourceExecutableIcon.png); } + .resource-icon-flash { - background-image: url(images/resourceFlashIcon.png); + background-image: url(images/resourceFlashIcon.png); } + .resource-icon-image { - background-image: url(images/resourceImageIcon.png); + background-image: url(images/resourceImageIcon.png); } + .resource-icon-java { - background-image: url(images/resourceJavaIcon.png); + background-image: url(images/resourceJavaIcon.png); } + .resource-icon-not-modified { - background-image: url(images/resourceNotModifiedIcon.png); + background-image: url(images/resourceNotModifiedIcon.png); } + .resource-icon-redirect { - background-image: url(images/resourceRedirectIcon.png); + background-image: url(images/resourceRedirectIcon.png); }
\ No newline at end of file diff --git a/web/src/css/vendor-bootstrap-variables.less b/web/src/css/vendor-bootstrap-variables.less index b2818993..e2c37bf5 100644 --- a/web/src/css/vendor-bootstrap-variables.less +++ b/web/src/css/vendor-bootstrap-variables.less @@ -1,6 +1,5 @@ - -@navbar-height: 32px; -@navbar-default-link-color: #303030; -@navbar-default-color: #303030; -@navbar-default-bg: #ffffff; -@navbar-default-border: #e0e0e0; +@navbar-height: 32px; +@navbar-default-link-color: #303030; +@navbar-default-color: #303030; +@navbar-default-bg: #ffffff; +@navbar-default-border: #e0e0e0; diff --git a/web/src/css/vendor-bootstrap.less b/web/src/css/vendor-bootstrap.less index 0b3252fe..35fda379 100644 --- a/web/src/css/vendor-bootstrap.less +++ b/web/src/css/vendor-bootstrap.less @@ -2,12 +2,10 @@ @import "../../node_modules/bootstrap/less/variables.less"; @import "vendor-bootstrap-variables.less"; @import "../../node_modules/bootstrap/less/mixins.less"; - // Reset and dependencies @import "../../node_modules/bootstrap/less/normalize.less"; @import "../../node_modules/bootstrap/less/print.less"; @import "../../node_modules/bootstrap/less/glyphicons.less"; - // Core CSS @import "../../node_modules/bootstrap/less/scaffolding.less"; @import "../../node_modules/bootstrap/less/type.less"; @@ -16,7 +14,6 @@ @import "../../node_modules/bootstrap/less/tables.less"; @import "../../node_modules/bootstrap/less/forms.less"; @import "../../node_modules/bootstrap/less/buttons.less"; - // Components @import "../../node_modules/bootstrap/less/component-animations.less"; @import "../../node_modules/bootstrap/less/dropdowns.less"; @@ -39,13 +36,11 @@ @import "../../node_modules/bootstrap/less/responsive-embed.less"; @import "../../node_modules/bootstrap/less/wells.less"; @import "../../node_modules/bootstrap/less/close.less"; - // Components w/ JavaScript @import "../../node_modules/bootstrap/less/modals.less"; @import "../../node_modules/bootstrap/less/tooltip.less"; @import "../../node_modules/bootstrap/less/popovers.less"; @import "../../node_modules/bootstrap/less/carousel.less"; - // Utility classes @import "../../node_modules/bootstrap/less/utilities.less"; @import "../../node_modules/bootstrap/less/responsive-utilities.less"; diff --git a/web/src/js/actions.js b/web/src/js/actions.js index 64cd68a7..2455a52e 100644 --- a/web/src/js/actions.js +++ b/web/src/js/actions.js @@ -1,4 +1,6 @@ var $ = require("jquery"); +var _ = require("lodash"); +var AppDispatcher = require("./dispatcher.js").AppDispatcher; var ActionTypes = { // Connection @@ -43,7 +45,8 @@ var SettingsActions = { $.ajax({ type: "PUT", url: "/settings", - data: settings + contentType: 'application/json', + data: JSON.stringify(settings) }); /* @@ -94,11 +97,22 @@ var FlowActions = { revert: function(flow){ $.post("/flows/" + flow.id + "/revert"); }, - update: function (flow) { + update: function (flow, nextProps) { + /* + //Facebook Flux: We do an optimistic update on the client already. + var nextFlow = _.cloneDeep(flow); + _.merge(nextFlow, nextProps); AppDispatcher.dispatchViewAction({ type: ActionTypes.FLOW_STORE, cmd: StoreCmds.UPDATE, - data: flow + data: nextFlow + }); + */ + $.ajax({ + type: "PUT", + url: "/flows/" + flow.id, + contentType: 'application/json', + data: JSON.stringify(nextProps) }); }, clear: function(){ @@ -107,7 +121,7 @@ var FlowActions = { }; var Query = { - FILTER: "f", + SEARCH: "s", HIGHLIGHT: "h", SHOW_EVENTLOG: "e" }; @@ -117,5 +131,7 @@ module.exports = { ConnectionActions: ConnectionActions, FlowActions: FlowActions, StoreCmds: StoreCmds, + SettingsActions: SettingsActions, + EventLogActions: EventLogActions, Query: Query };
\ No newline at end of file diff --git a/web/src/js/app.js b/web/src/js/app.js index 63d782d4..9fb868b8 100644 --- a/web/src/js/app.js +++ b/web/src/js/app.js @@ -1,13 +1,17 @@ - var React = require("react"); var ReactRouter = require("react-router"); var $ = require("jquery"); var Connection = require("./connection"); var proxyapp = require("./components/proxyapp.js"); +var EventLogActions = require("./actions.js").EventLogActions; $(function () { window.ws = new Connection("/updates"); + window.onerror = function (msg) { + EventLogActions.add_event(msg); + }; + ReactRouter.run(proxyapp.routes, function (Handler, state) { React.render(<Handler/>, document.body); }); diff --git a/web/src/js/components/common.js b/web/src/js/components/common.js index ffaa717f..965ae9a7 100644 --- a/web/src/js/components/common.js +++ b/web/src/js/components/common.js @@ -7,8 +7,8 @@ var AutoScrollMixin = { componentWillUpdate: function () { var node = this.getDOMNode(); this._shouldScrollBottom = ( - node.scrollTop !== 0 && - node.scrollTop + node.clientHeight === node.scrollHeight + node.scrollTop !== 0 && + node.scrollTop + node.clientHeight === node.scrollHeight ); }, componentDidUpdate: function () { @@ -29,34 +29,79 @@ var StickyHeadMixin = { } }; +var SettingsState = { + contextTypes: { + settingsStore: React.PropTypes.object.isRequired + }, + getInitialState: function () { + return { + settings: this.context.settingsStore.dict + }; + }, + componentDidMount: function () { + this.context.settingsStore.addListener("recalculate", this.onSettingsChange); + }, + componentWillUnmount: function () { + this.context.settingsStore.removeListener("recalculate", this.onSettingsChange); + }, + onSettingsChange: function () { + this.setState({ + settings: this.context.settingsStore.dict + }); + }, +}; + + +var ChildFocus = { + contextTypes: { + returnFocus: React.PropTypes.func + }, + returnFocus: function(){ + React.findDOMNode(this).blur(); + window.getSelection().removeAllRanges(); + this.context.returnFocus(); + } +}; + var Navigation = _.extend({}, ReactRouter.Navigation, { setQuery: function (dict) { - var q = this.context.getCurrentQuery(); - for(var i in dict){ - if(dict.hasOwnProperty(i)){ + var q = this.context.router.getCurrentQuery(); + for (var i in dict) { + if (dict.hasOwnProperty(i)) { q[i] = dict[i] || undefined; //falsey values shall be removed. } } - q._ = "_"; // workaround for https://github.com/rackt/react-router/pull/957 - this.replaceWith(this.context.getCurrentPath(), this.context.getCurrentParams(), q); + this.replaceWith(this.context.router.getCurrentPath(), this.context.router.getCurrentParams(), q); }, - replaceWith: function(routeNameOrPath, params, query) { - if(routeNameOrPath === undefined){ - routeNameOrPath = this.context.getCurrentPath(); + replaceWith: function (routeNameOrPath, params, query) { + if (routeNameOrPath === undefined) { + routeNameOrPath = this.context.router.getCurrentPath(); } - if(params === undefined){ - params = this.context.getCurrentParams(); + if (params === undefined) { + params = this.context.router.getCurrentParams(); } - if(query === undefined) { - query = this.context.getCurrentQuery(); + if (query === undefined) { + query = this.context.router.getCurrentQuery(); } - // FIXME: react-router is just broken. - ReactRouter.Navigation.replaceWith.call(this, routeNameOrPath, params, query); + this.context.router.replaceWith(routeNameOrPath, params, query); + } +}); + +// react-router is fairly good at changing its API regularly. +// We keep the old method for now - if it should turn out that their changes are permanent, +// we may remove this mixin and access react-router directly again. +var RouterState = _.extend({}, ReactRouter.State, { + getQuery: function () { + // For whatever reason, react-router always returns the same object, which makes comparing + // the current props with nextProps impossible. As a workaround, we just clone the query object. + return _.clone(this.context.router.getCurrentQuery()); + }, + getParams: function () { + return _.clone(this.context.router.getCurrentParams()); } }); -_.extend(Navigation.contextTypes, ReactRouter.State.contextTypes); var Splitter = React.createClass({ getDefaultProps: function () { @@ -164,9 +209,11 @@ var Splitter = React.createClass({ }); module.exports = { - State: ReactRouter.State, // keep here - react-router is pretty buggy, we may need workarounds in the future. + ChildFocus: ChildFocus, + RouterState: RouterState, Navigation: Navigation, StickyHeadMixin: StickyHeadMixin, AutoScrollMixin: AutoScrollMixin, - Splitter: Splitter + Splitter: Splitter, + SettingsState: SettingsState };
\ No newline at end of file diff --git a/web/src/js/components/editor.js b/web/src/js/components/editor.js new file mode 100644 index 00000000..f2d44566 --- /dev/null +++ b/web/src/js/components/editor.js @@ -0,0 +1,240 @@ +var React = require("react"); +var common = require("./common.js"); +var utils = require("../utils.js"); + +var contentToHtml = function (content) { + return _.escape(content); +}; +var nodeToContent = function (node) { + return node.textContent; +}; + +/* + Basic Editor Functionality + */ +var EditorBase = React.createClass({ + propTypes: { + content: React.PropTypes.string.isRequired, + onDone: React.PropTypes.func.isRequired, + contentToHtml: React.PropTypes.func, + nodeToContent: React.PropTypes.func, // content === nodeToContent( Node<innerHTML=contentToHtml(content)> ) + onStop: React.PropTypes.func, + submitOnEnter: React.PropTypes.bool, + className: React.PropTypes.string, + tag: React.PropTypes.string + }, + getDefaultProps: function () { + return { + contentToHtml: contentToHtml, + nodeToContent: nodeToContent, + submitOnEnter: true, + className: "", + tag: "div" + }; + }, + getInitialState: function () { + return { + editable: false + }; + }, + render: function () { + var className = "inline-input " + this.props.className; + var html = {__html: this.props.contentToHtml(this.props.content)}; + var Tag = this.props.tag; + return <Tag + {...this.props} + tabIndex="0" + className={className} + contentEditable={this.state.editable || undefined } // workaround: use undef instead of false to remove attr + onFocus={this.onFocus} + onMouseDown={this.onMouseDown} + onClick={this.onClick} + onBlur={this._stop} + onKeyDown={this.onKeyDown} + onInput={this.onInput} + onPaste={this.onPaste} + dangerouslySetInnerHTML={html} + />; + }, + onPaste: function (e) { + e.preventDefault(); + var content = e.clipboardData.getData("text/plain"); + document.execCommand("insertHTML", false, content); + }, + onMouseDown: function (e) { + this._mouseDown = true; + window.addEventListener("mouseup", this.onMouseUp); + this.props.onMouseDown && this.props.onMouseDown(e); + }, + onMouseUp: function () { + if (this._mouseDown) { + this._mouseDown = false; + window.removeEventListener("mouseup", this.onMouseUp) + } + }, + onClick: function (e) { + this.onMouseUp(); + this.onFocus(e); + }, + onFocus: function (e) { + console.log("onFocus", this._mouseDown, this._ignore_events, this.state.editable); + if (this._mouseDown || this._ignore_events || this.state.editable) { + return; + } + + //contenteditable in FireFox is more or less broken. + // - we need to blur() and then focus(), otherwise the caret is not shown. + // - blur() + focus() == we need to save the caret position before + // Firefox sometimes just doesn't set a caret position => use caretPositionFromPoint + var sel = window.getSelection(); + var range; + if (sel.rangeCount > 0) { + range = sel.getRangeAt(0); + } else if (document.caretPositionFromPoint && e.clientX && e.clientY) { + var pos = document.caretPositionFromPoint(e.clientX, e.clientY); + range = document.createRange(); + range.setStart(pos.offsetNode, pos.offset); + } else if (document.caretRangeFromPoint && e.clientX && e.clientY) { + range = document.caretRangeFromPoint(e.clientX, e.clientY); + } else { + range = document.createRange(); + range.selectNodeContents(React.findDOMNode(this)); + } + + this._ignore_events = true; + this.setState({editable: true}, function () { + var node = React.findDOMNode(this); + node.blur(); + node.focus(); + this._ignore_events = false; + //sel.removeAllRanges(); + //sel.addRange(range); + + + }); + }, + stop: function () { + // a stop would cause a blur as a side-effect. + // but a blur event must trigger a stop as well. + // to fix this, make stop = blur and do the actual stop in the onBlur handler. + React.findDOMNode(this).blur(); + this.props.onStop && this.props.onStop(); + }, + _stop: function (e) { + if (this._ignore_events) { + return; + } + console.log("_stop", _.extend({}, e)); + window.getSelection().removeAllRanges(); //make sure that selection is cleared on blur + var node = React.findDOMNode(this); + var content = this.props.nodeToContent(node); + this.setState({editable: false}); + this.props.onDone(content); + this.props.onBlur && this.props.onBlur(e); + }, + reset: function () { + React.findDOMNode(this).innerHTML = this.props.contentToHtml(this.props.content); + }, + onKeyDown: function (e) { + e.stopPropagation(); + switch (e.keyCode) { + case utils.Key.ESC: + e.preventDefault(); + this.reset(); + this.stop(); + break; + case utils.Key.ENTER: + if (this.props.submitOnEnter && !e.shiftKey) { + e.preventDefault(); + this.stop(); + } + break; + default: + break; + } + }, + onInput: function () { + var node = React.findDOMNode(this); + var content = this.props.nodeToContent(node); + this.props.onInput && this.props.onInput(content); + } +}); + +/* + Add Validation to EditorBase + */ +var ValidateEditor = React.createClass({ + propTypes: { + content: React.PropTypes.string.isRequired, + onDone: React.PropTypes.func.isRequired, + onInput: React.PropTypes.func, + isValid: React.PropTypes.func, + className: React.PropTypes.string, + }, + getInitialState: function () { + return { + currentContent: this.props.content + }; + }, + componentWillReceiveProps: function () { + this.setState({currentContent: this.props.content}); + }, + onInput: function (content) { + this.setState({currentContent: content}); + this.props.onInput && this.props.onInput(content); + }, + render: function () { + var className = this.props.className || ""; + if (this.props.isValid) { + if (this.props.isValid(this.state.currentContent)) { + className += " has-success"; + } else { + className += " has-warning" + } + } + return <EditorBase + {...this.props} + ref="editor" + className={className} + onDone={this.onDone} + onInput={this.onInput} + />; + }, + onDone: function (content) { + if (this.props.isValid && !this.props.isValid(content)) { + this.refs.editor.reset(); + content = this.props.content; + } + this.props.onDone(content); + } +}); + +/* + Text Editor with mitmweb-specific convenience features + */ +var ValueEditor = React.createClass({ + mixins: [common.ChildFocus], + propTypes: { + content: React.PropTypes.string.isRequired, + onDone: React.PropTypes.func.isRequired, + inline: React.PropTypes.bool, + }, + render: function () { + var tag = this.props.inline ? "span" : "div"; + return <ValidateEditor + {...this.props} + onStop={this.onStop} + tag={tag} + />; + }, + focus: function () { + React.findDOMNode(this).focus(); + }, + onStop: function () { + this.returnFocus(); + } +}); + +module.exports = { + ValueEditor: ValueEditor +};
\ No newline at end of file diff --git a/web/src/js/components/eventlog.js b/web/src/js/components/eventlog.js index 23508275..4c6dafb1 100644 --- a/web/src/js/components/eventlog.js +++ b/web/src/js/components/eventlog.js @@ -3,6 +3,7 @@ var common = require("./common.js"); var Query = require("../actions.js").Query; var VirtualScrollMixin = require("./virtualscroll.js"); var views = require("../store/view.js"); +var _ = require("lodash"); var LogMessage = React.createClass({ render: function () { @@ -30,46 +31,36 @@ var LogMessage = React.createClass({ }); var EventLogContents = React.createClass({ + contextTypes: { + eventStore: React.PropTypes.object.isRequired + }, mixins: [common.AutoScrollMixin, VirtualScrollMixin], getInitialState: function () { - return { - log: [] - }; - }, - componentWillMount: function () { - this.openView(this.props.eventStore); - }, - componentWillUnmount: function () { - this.closeView(); - }, - openView: function (store) { - var view = new views.StoreView(store, function (entry) { + var filterFn = function (entry) { return this.props.filter[entry.level]; - }.bind(this)); - this.setState({ - view: view - }); - + }; + var view = new views.StoreView(this.context.eventStore, filterFn.bind(this)); view.addListener("add", this.onEventLogChange); view.addListener("recalculate", this.onEventLogChange); + + return { + view: view + }; }, - closeView: function () { + componentWillUnmount: function () { this.state.view.close(); }, + filter: function (entry) { + return this.props.filter[entry.level]; + }, onEventLogChange: function () { - this.setState({ - log: this.state.view.list - }); + this.forceUpdate(); }, componentWillReceiveProps: function (nextProps) { if (nextProps.filter !== this.props.filter) { this.props.filter = nextProps.filter; // Dirty: Make sure that view filter sees the update. this.state.view.recalculate(); } - if (nextProps.eventStore !== this.props.eventStore) { - this.closeView(); - this.openView(nextProps.eventStore); - } }, getDefaultProps: function () { return { @@ -82,12 +73,13 @@ var EventLogContents = React.createClass({ return <LogMessage key={elem.id} entry={elem}/>; }, render: function () { - var rows = this.renderRows(this.state.log); + var entries = this.state.view.list; + var rows = this.renderRows(entries); return <pre onScroll={this.onScroll}> - { this.getPlaceholderTop(this.state.log.length) } + { this.getPlaceholderTop(entries.length) } {rows} - { this.getPlaceholderBottom(this.state.log.length) } + { this.getPlaceholderBottom(entries.length) } </pre>; } }); @@ -148,7 +140,7 @@ var EventLog = React.createClass({ </div> </div> - <EventLogContents filter={this.state.filter} eventStore={this.props.eventStore}/> + <EventLogContents filter={this.state.filter}/> </div> ); } diff --git a/web/src/js/components/flowdetail.js b/web/src/js/components/flowdetail.js deleted file mode 100644 index 1d019ffb..00000000 --- a/web/src/js/components/flowdetail.js +++ /dev/null @@ -1,399 +0,0 @@ -var React = require("react"); -var _ = require("lodash"); - -var common = require("./common.js"); -var actions = require("../actions.js"); -var flowutils = require("../flow/utils.js"); -var toputils = require("../utils.js"); - -var NavAction = React.createClass({ - onClick: function (e) { - e.preventDefault(); - this.props.onClick(); - }, - render: function () { - return ( - <a title={this.props.title} - href="#" - className="nav-action" - onClick={this.onClick}> - <i className={"fa fa-fw " + this.props.icon}></i> - </a> - ); - } -}); - -var FlowDetailNav = React.createClass({ - render: function () { - var flow = this.props.flow; - - var tabs = this.props.tabs.map(function (e) { - var str = e.charAt(0).toUpperCase() + e.slice(1); - var className = this.props.active === e ? "active" : ""; - var onClick = function (event) { - this.props.selectTab(e); - event.preventDefault(); - }.bind(this); - return <a key={e} - href="#" - className={className} - onClick={onClick}>{str}</a>; - }.bind(this)); - - var acceptButton = null; - if(flow.intercepted){ - acceptButton = <NavAction title="[a]ccept intercepted flow" icon="fa-play" onClick={actions.FlowActions.accept.bind(null, flow)} />; - } - var revertButton = null; - if(flow.modified){ - revertButton = <NavAction title="revert changes to flow [V]" icon="fa-history" onClick={actions.FlowActions.revert.bind(null, flow)} />; - } - - return ( - <nav ref="head" className="nav-tabs nav-tabs-sm"> - {tabs} - <NavAction title="[d]elete flow" icon="fa-trash" onClick={actions.FlowActions.delete.bind(null, flow)} /> - <NavAction title="[D]uplicate flow" icon="fa-copy" onClick={actions.FlowActions.duplicate.bind(null, flow)} /> - <NavAction disabled title="[r]eplay flow" icon="fa-repeat" onClick={actions.FlowActions.replay.bind(null, flow)} /> - {acceptButton} - {revertButton} - </nav> - ); - } -}); - -var Headers = React.createClass({ - render: function () { - var rows = this.props.message.headers.map(function (header, i) { - return ( - <tr key={i}> - <td className="header-name">{header[0] + ":"}</td> - <td className="header-value">{header[1]}</td> - </tr> - ); - }); - return ( - <table className="header-table"> - <tbody> - {rows} - </tbody> - </table> - ); - } -}); - -var FlowDetailRequest = React.createClass({ - render: function () { - var flow = this.props.flow; - var first_line = [ - flow.request.method, - flowutils.RequestUtils.pretty_url(flow.request), - "HTTP/" + flow.request.httpversion.join(".") - ].join(" "); - var content = null; - if (flow.request.contentLength > 0) { - content = "Request Content Size: " + toputils.formatSize(flow.request.contentLength); - } else { - content = <div className="alert alert-info">No Content</div>; - } - - //TODO: Styling - - return ( - <section> - <div className="first-line">{ first_line }</div> - <Headers message={flow.request}/> - <hr/> - {content} - </section> - ); - } -}); - -var FlowDetailResponse = React.createClass({ - render: function () { - var flow = this.props.flow; - var first_line = [ - "HTTP/" + flow.response.httpversion.join("."), - flow.response.code, - flow.response.msg - ].join(" "); - var content = null; - if (flow.response.contentLength > 0) { - content = "Response Content Size: " + toputils.formatSize(flow.response.contentLength); - } else { - content = <div className="alert alert-info">No Content</div>; - } - - //TODO: Styling - - return ( - <section> - <div className="first-line">{ first_line }</div> - <Headers message={flow.response}/> - <hr/> - {content} - </section> - ); - } -}); - -var FlowDetailError = React.createClass({ - render: function () { - var flow = this.props.flow; - return ( - <section> - <div className="alert alert-warning"> - {flow.error.msg} - <div> - <small>{ toputils.formatTimeStamp(flow.error.timestamp) }</small> - </div> - </div> - </section> - ); - } -}); - -var TimeStamp = React.createClass({ - render: function () { - - if (!this.props.t) { - //should be return null, but that triggers a React bug. - return <tr></tr>; - } - - var ts = toputils.formatTimeStamp(this.props.t); - - var delta; - if (this.props.deltaTo) { - delta = toputils.formatTimeDelta(1000 * (this.props.t - this.props.deltaTo)); - delta = <span className="text-muted">{"(" + delta + ")"}</span>; - } else { - delta = null; - } - - return <tr> - <td>{this.props.title + ":"}</td> - <td>{ts} {delta}</td> - </tr>; - } -}); - -var ConnectionInfo = React.createClass({ - - render: function () { - var conn = this.props.conn; - var address = conn.address.address.join(":"); - - var sni = <tr key="sni"></tr>; //should be null, but that triggers a React bug. - if (conn.sni) { - sni = <tr key="sni"> - <td> - <abbr title="TLS Server Name Indication">TLS SNI:</abbr> - </td> - <td>{conn.sni}</td> - </tr>; - } - return ( - <table className="connection-table"> - <tbody> - <tr key="address"> - <td>Address:</td> - <td>{address}</td> - </tr> - {sni} - </tbody> - </table> - ); - } -}); - -var CertificateInfo = React.createClass({ - render: function () { - //TODO: We should fetch human-readable certificate representation - // from the server - var flow = this.props.flow; - var client_conn = flow.client_conn; - var server_conn = flow.server_conn; - - var preStyle = {maxHeight: 100}; - return ( - <div> - {client_conn.cert ? <h4>Client Certificate</h4> : null} - {client_conn.cert ? <pre style={preStyle}>{client_conn.cert}</pre> : null} - - {server_conn.cert ? <h4>Server Certificate</h4> : null} - {server_conn.cert ? <pre style={preStyle}>{server_conn.cert}</pre> : null} - </div> - ); - } -}); - -var Timing = React.createClass({ - render: function () { - var flow = this.props.flow; - var sc = flow.server_conn; - var cc = flow.client_conn; - var req = flow.request; - var resp = flow.response; - - var timestamps = [ - { - title: "Server conn. initiated", - t: sc.timestamp_start, - deltaTo: req.timestamp_start - }, { - title: "Server conn. TCP handshake", - t: sc.timestamp_tcp_setup, - deltaTo: req.timestamp_start - }, { - title: "Server conn. SSL handshake", - t: sc.timestamp_ssl_setup, - deltaTo: req.timestamp_start - }, { - title: "Client conn. established", - t: cc.timestamp_start, - deltaTo: req.timestamp_start - }, { - title: "Client conn. SSL handshake", - t: cc.timestamp_ssl_setup, - deltaTo: req.timestamp_start - }, { - title: "First request byte", - t: req.timestamp_start, - }, { - title: "Request complete", - t: req.timestamp_end, - deltaTo: req.timestamp_start - } - ]; - - if (flow.response) { - timestamps.push( - { - title: "First response byte", - t: resp.timestamp_start, - deltaTo: req.timestamp_start - }, { - title: "Response complete", - t: resp.timestamp_end, - deltaTo: req.timestamp_start - } - ); - } - - //Add unique key for each row. - timestamps.forEach(function (e) { - e.key = e.title; - }); - - timestamps = _.sortBy(timestamps, 't'); - - var rows = timestamps.map(function (e) { - return <TimeStamp {...e}/>; - }); - - return ( - <div> - <h4>Timing</h4> - <table className="timing-table"> - <tbody> - {rows} - </tbody> - </table> - </div> - ); - } -}); - -var FlowDetailConnectionInfo = React.createClass({ - render: function () { - var flow = this.props.flow; - var client_conn = flow.client_conn; - var server_conn = flow.server_conn; - return ( - <section> - - <h4>Client Connection</h4> - <ConnectionInfo conn={client_conn}/> - - <h4>Server Connection</h4> - <ConnectionInfo conn={server_conn}/> - - <CertificateInfo flow={flow}/> - - <Timing flow={flow}/> - - </section> - ); - } -}); - -var allTabs = { - request: FlowDetailRequest, - response: FlowDetailResponse, - error: FlowDetailError, - details: FlowDetailConnectionInfo -}; - -var FlowDetail = React.createClass({ - mixins: [common.StickyHeadMixin, common.Navigation, common.State], - getTabs: function (flow) { - var tabs = []; - ["request", "response", "error"].forEach(function (e) { - if (flow[e]) { - tabs.push(e); - } - }); - tabs.push("details"); - return tabs; - }, - nextTab: function (i) { - var tabs = this.getTabs(this.props.flow); - var currentIndex = tabs.indexOf(this.getParams().detailTab); - // JS modulo operator doesn't correct negative numbers, make sure that we are positive. - var nextIndex = (currentIndex + i + tabs.length) % tabs.length; - this.selectTab(tabs[nextIndex]); - }, - selectTab: function (panel) { - this.replaceWith( - "flow", - { - flowId: this.getParams().flowId, - detailTab: panel - } - ); - }, - render: function () { - var flow = this.props.flow; - var tabs = this.getTabs(flow); - var active = this.getParams().detailTab; - - if (!_.contains(tabs, active)) { - if (active === "response" && flow.error) { - active = "error"; - } else if (active === "error" && flow.response) { - active = "response"; - } else { - active = tabs[0]; - } - this.selectTab(active); - } - - var Tab = allTabs[active]; - return ( - <div className="flow-detail" onScroll={this.adjustHead}> - <FlowDetailNav ref="head" - flow={flow} - tabs={tabs} - active={active} - selectTab={this.selectTab}/> - <Tab flow={flow}/> - </div> - ); - } -}); - -module.exports = { - FlowDetail: FlowDetail -};
\ No newline at end of file diff --git a/web/src/js/components/flowtable-columns.js b/web/src/js/components/flowtable-columns.js index a82c607a..b3b47910 100644 --- a/web/src/js/components/flowtable-columns.js +++ b/web/src/js/components/flowtable-columns.js @@ -16,7 +16,7 @@ var TLSColumn = React.createClass({ }, render: function () { var flow = this.props.flow; - var ssl = (flow.request.scheme == "https"); + var ssl = (flow.request.scheme === "https"); var classes; if (ssl) { classes = "col-tls col-tls-https"; @@ -44,7 +44,7 @@ var IconColumn = React.createClass({ var contentType = ResponseUtils.getContentType(flow.response); //TODO: We should assign a type to the flow somewhere else. - if (flow.response.code == 304) { + if (flow.response.code === 304) { icon = "resource-icon-not-modified"; } else if (300 <= flow.response.code && flow.response.code < 400) { icon = "resource-icon-redirect"; diff --git a/web/src/js/components/flowtable.js b/web/src/js/components/flowtable.js index 4217786a..609034f6 100644 --- a/web/src/js/components/flowtable.js +++ b/web/src/js/components/flowtable.js @@ -108,33 +108,25 @@ var ROW_HEIGHT = 32; var FlowTable = React.createClass({ mixins: [common.StickyHeadMixin, common.AutoScrollMixin, VirtualScrollMixin], + contextTypes: { + view: React.PropTypes.object.isRequired + }, getInitialState: function () { return { columns: flowtable_columns }; }, - _listen: function(view){ - if(!view){ - return; - } - view.addListener("add", this.onChange); - view.addListener("update", this.onChange); - view.addListener("remove", this.onChange); - view.addListener("recalculate", this.onChange); - }, componentWillMount: function () { - this._listen(this.props.view); + this.context.view.addListener("add", this.onChange); + this.context.view.addListener("update", this.onChange); + this.context.view.addListener("remove", this.onChange); + this.context.view.addListener("recalculate", this.onChange); }, - componentWillReceiveProps: function (nextProps) { - if (nextProps.view !== this.props.view) { - if (this.props.view) { - this.props.view.removeListener("add"); - this.props.view.removeListener("update"); - this.props.view.removeListener("remove"); - this.props.view.removeListener("recalculate"); - } - this._listen(nextProps.view); - } + componentWillUnmount: function(){ + this.context.view.removeListener("add", this.onChange); + this.context.view.removeListener("update", this.onChange); + this.context.view.removeListener("remove", this.onChange); + this.context.view.removeListener("recalculate", this.onChange); }, getDefaultProps: function () { return { @@ -150,7 +142,7 @@ var FlowTable = React.createClass({ }, scrollIntoView: function (flow) { this.scrollRowIntoView( - this.props.view.index(flow), + this.context.view.index(flow), this.refs.body.getDOMNode().offsetTop ); }, @@ -158,8 +150,8 @@ var FlowTable = React.createClass({ var selected = (flow === this.props.selected); var highlighted = ( - this.props.view._highlight && - this.props.view._highlight[flow.id] + this.context.view._highlight && + this.context.view._highlight[flow.id] ); return <FlowRow key={flow.id} @@ -172,9 +164,7 @@ var FlowTable = React.createClass({ />; }, render: function () { - //console.log("render flowtable", this.state.start, this.state.stop, this.props.selected); - var flows = this.props.view ? this.props.view.list : []; - + var flows = this.context.view.list; var rows = this.renderRows(flows); return ( diff --git a/web/src/js/components/flowview/contentview.js b/web/src/js/components/flowview/contentview.js new file mode 100644 index 00000000..63d22c1c --- /dev/null +++ b/web/src/js/components/flowview/contentview.js @@ -0,0 +1,237 @@ +var React = require("react"); +var _ = require("lodash"); + +var MessageUtils = require("../../flow/utils.js").MessageUtils; +var utils = require("../../utils.js"); + +var image_regex = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i; +var ViewImage = React.createClass({ + statics: { + matches: function (message) { + return image_regex.test(MessageUtils.getContentType(message)); + } + }, + render: function () { + var url = MessageUtils.getContentURL(this.props.flow, this.props.message); + return <div className="flowview-image"> + <img src={url} alt="preview" className="img-thumbnail"/> + </div>; + } +}); + +var RawMixin = { + getInitialState: function () { + return { + content: undefined, + request: undefined + } + }, + requestContent: function (nextProps) { + if (this.state.request) { + this.state.request.abort(); + } + var request = MessageUtils.getContent(nextProps.flow, nextProps.message); + this.setState({ + content: undefined, + request: request + }); + request.done(function (data) { + this.setState({content: data}); + }.bind(this)).fail(function (jqXHR, textStatus, errorThrown) { + if (textStatus === "abort") { + return; + } + this.setState({content: "AJAX Error: " + textStatus + "\r\n" + errorThrown}); + }.bind(this)).always(function () { + this.setState({request: undefined}); + }.bind(this)); + + }, + componentWillMount: function () { + this.requestContent(this.props); + }, + componentWillReceiveProps: function (nextProps) { + if (nextProps.message !== this.props.message) { + this.requestContent(nextProps); + } + }, + componentWillUnmount: function () { + if (this.state.request) { + this.state.request.abort(); + } + }, + render: function () { + if (!this.state.content) { + return <div className="text-center"> + <i className="fa fa-spinner fa-spin"></i> + </div>; + } + return this.renderContent(); + } +}; + +var ViewRaw = React.createClass({ + mixins: [RawMixin], + statics: { + matches: function (message) { + return true; + } + }, + renderContent: function () { + return <pre>{this.state.content}</pre>; + } +}); + +var json_regex = /^application\/json$/i; +var ViewJSON = React.createClass({ + mixins: [RawMixin], + statics: { + matches: function (message) { + return json_regex.test(MessageUtils.getContentType(message)); + } + }, + renderContent: function () { + var json = this.state.content; + try { + json = JSON.stringify(JSON.parse(json), null, 2); + } catch (e) { + } + return <pre>{json}</pre>; + } +}); + +var ViewAuto = React.createClass({ + statics: { + matches: function () { + return false; // don't match itself + }, + findView: function (message) { + for (var i = 0; i < all.length; i++) { + if (all[i].matches(message)) { + return all[i]; + } + } + return all[all.length - 1]; + } + }, + render: function () { + var View = ViewAuto.findView(this.props.message); + return <View {...this.props}/>; + } +}); + +var all = [ViewAuto, ViewImage, ViewJSON, ViewRaw]; + + +var ContentEmpty = React.createClass({ + render: function () { + var message_name = this.props.flow.request === this.props.message ? "request" : "response"; + return <div className="alert alert-info">No {message_name} content.</div>; + } +}); + +var ContentMissing = React.createClass({ + render: function () { + var message_name = this.props.flow.request === this.props.message ? "Request" : "Response"; + return <div className="alert alert-info">{message_name} content missing.</div>; + } +}); + +var TooLarge = React.createClass({ + statics: { + isTooLarge: function (message) { + var max_mb = ViewImage.matches(message) ? 10 : 0.2; + return message.contentLength > 1024 * 1024 * max_mb; + } + }, + render: function () { + var size = utils.formatSize(this.props.message.contentLength); + return <div className="alert alert-warning"> + <button onClick={this.props.onClick} className="btn btn-xs btn-warning pull-right">Display anyway</button> + {size} content size. + </div>; + } +}); + +var ViewSelector = React.createClass({ + render: function () { + var views = []; + for (var i = 0; i < all.length; i++) { + var view = all[i]; + var className = "btn btn-default"; + if (view === this.props.active) { + className += " active"; + } + var text; + if (view === ViewAuto) { + text = "auto: " + ViewAuto.findView(this.props.message).displayName.toLowerCase().replace("view", ""); + } else { + text = view.displayName.toLowerCase().replace("view", ""); + } + views.push( + <button + key={view.displayName} + onClick={this.props.selectView.bind(null, view)} + className={className}> + {text} + </button> + ); + } + + return <div className="view-selector btn-group btn-group-xs">{views}</div>; + } +}); + +var ContentView = React.createClass({ + getInitialState: function () { + return { + displayLarge: false, + View: ViewAuto + }; + }, + propTypes: { + // It may seem a bit weird at the first glance: + // Every view takes the flow and the message as props, e.g. + // <Auto flow={flow} message={flow.request}/> + flow: React.PropTypes.object.isRequired, + message: React.PropTypes.object.isRequired, + }, + selectView: function (view) { + this.setState({ + View: view + }); + }, + displayLarge: function () { + this.setState({displayLarge: true}); + }, + componentWillReceiveProps: function (nextProps) { + if (nextProps.message !== this.props.message) { + this.setState(this.getInitialState()); + } + }, + render: function () { + var message = this.props.message; + if (message.contentLength === 0) { + return <ContentEmpty {...this.props}/>; + } else if (message.contentLength === null) { + return <ContentMissing {...this.props}/>; + } else if (!this.state.displayLarge && TooLarge.isTooLarge(message)) { + return <TooLarge {...this.props} onClick={this.displayLarge}/>; + } + + var downloadUrl = MessageUtils.getContentURL(this.props.flow, message); + + return <div> + <this.state.View {...this.props} /> + <div className="view-options text-center"> + <ViewSelector selectView={this.selectView} active={this.state.View} message={message}/> + + <a className="btn btn-default btn-xs" href={downloadUrl}> + <i className="fa fa-download"/> + </a> + </div> + </div>; + } +}); + +module.exports = ContentView;
\ No newline at end of file diff --git a/web/src/js/components/flowview/details.js b/web/src/js/components/flowview/details.js new file mode 100644 index 00000000..00e0116c --- /dev/null +++ b/web/src/js/components/flowview/details.js @@ -0,0 +1,181 @@ +var React = require("react"); +var _ = require("lodash"); + +var utils = require("../../utils.js"); + +var TimeStamp = React.createClass({ + render: function () { + + if (!this.props.t) { + //should be return null, but that triggers a React bug. + return <tr></tr>; + } + + var ts = utils.formatTimeStamp(this.props.t); + + var delta; + if (this.props.deltaTo) { + delta = utils.formatTimeDelta(1000 * (this.props.t - this.props.deltaTo)); + delta = <span className="text-muted">{"(" + delta + ")"}</span>; + } else { + delta = null; + } + + return <tr> + <td>{this.props.title + ":"}</td> + <td>{ts} {delta}</td> + </tr>; + } +}); + +var ConnectionInfo = React.createClass({ + + render: function () { + var conn = this.props.conn; + var address = conn.address.address.join(":"); + + var sni = <tr key="sni"></tr>; //should be null, but that triggers a React bug. + if (conn.sni) { + sni = <tr key="sni"> + <td> + <abbr title="TLS Server Name Indication">TLS SNI:</abbr> + </td> + <td>{conn.sni}</td> + </tr>; + } + return ( + <table className="connection-table"> + <tbody> + <tr key="address"> + <td>Address:</td> + <td>{address}</td> + </tr> + {sni} + </tbody> + </table> + ); + } +}); + +var CertificateInfo = React.createClass({ + render: function () { + //TODO: We should fetch human-readable certificate representation + // from the server + var flow = this.props.flow; + var client_conn = flow.client_conn; + var server_conn = flow.server_conn; + + var preStyle = {maxHeight: 100}; + return ( + <div> + {client_conn.cert ? <h4>Client Certificate</h4> : null} + {client_conn.cert ? <pre style={preStyle}>{client_conn.cert}</pre> : null} + + {server_conn.cert ? <h4>Server Certificate</h4> : null} + {server_conn.cert ? <pre style={preStyle}>{server_conn.cert}</pre> : null} + </div> + ); + } +}); + +var Timing = React.createClass({ + render: function () { + var flow = this.props.flow; + var sc = flow.server_conn; + var cc = flow.client_conn; + var req = flow.request; + var resp = flow.response; + + var timestamps = [ + { + title: "Server conn. initiated", + t: sc.timestamp_start, + deltaTo: req.timestamp_start + }, { + title: "Server conn. TCP handshake", + t: sc.timestamp_tcp_setup, + deltaTo: req.timestamp_start + }, { + title: "Server conn. SSL handshake", + t: sc.timestamp_ssl_setup, + deltaTo: req.timestamp_start + }, { + title: "Client conn. established", + t: cc.timestamp_start, + deltaTo: req.timestamp_start + }, { + title: "Client conn. SSL handshake", + t: cc.timestamp_ssl_setup, + deltaTo: req.timestamp_start + }, { + title: "First request byte", + t: req.timestamp_start, + }, { + title: "Request complete", + t: req.timestamp_end, + deltaTo: req.timestamp_start + } + ]; + + if (flow.response) { + timestamps.push( + { + title: "First response byte", + t: resp.timestamp_start, + deltaTo: req.timestamp_start + }, { + title: "Response complete", + t: resp.timestamp_end, + deltaTo: req.timestamp_start + } + ); + } + + //Add unique key for each row. + timestamps.forEach(function (e) { + e.key = e.title; + }); + + timestamps = _.sortBy(timestamps, 't'); + + var rows = timestamps.map(function (e) { + return <TimeStamp {...e}/>; + }); + + return ( + <div> + <h4>Timing</h4> + <table className="timing-table"> + <tbody> + {rows} + </tbody> + </table> + </div> + ); + } +}); + +var Details = React.createClass({ + render: function () { + var flow = this.props.flow; + var client_conn = flow.client_conn; + var server_conn = flow.server_conn; + return ( + <section> + + <h4>Client Connection</h4> + <ConnectionInfo conn={client_conn}/> + + <h4>Server Connection</h4> + <ConnectionInfo conn={server_conn}/> + + <CertificateInfo flow={flow}/> + + <Timing flow={flow}/> + + </section> + ); + } +}); + +module.exports = Details;
\ No newline at end of file diff --git a/web/src/js/components/flowview/index.js b/web/src/js/components/flowview/index.js new file mode 100644 index 00000000..739a46dc --- /dev/null +++ b/web/src/js/components/flowview/index.js @@ -0,0 +1,127 @@ +var React = require("react"); +var _ = require("lodash"); + +var common = require("../common.js"); +var Nav = require("./nav.js"); +var Messages = require("./messages.js"); +var Details = require("./details.js"); +var Prompt = require("../prompt.js"); + + +var allTabs = { + request: Messages.Request, + response: Messages.Response, + error: Messages.Error, + details: Details +}; + +var FlowView = React.createClass({ + mixins: [common.StickyHeadMixin, common.Navigation, common.RouterState], + getInitialState: function () { + return { + prompt: false + }; + }, + getTabs: function (flow) { + var tabs = []; + ["request", "response", "error"].forEach(function (e) { + if (flow[e]) { + tabs.push(e); + } + }); + tabs.push("details"); + return tabs; + }, + nextTab: function (i) { + var tabs = this.getTabs(this.props.flow); + var currentIndex = tabs.indexOf(this.getActive()); + // JS modulo operator doesn't correct negative numbers, make sure that we are positive. + var nextIndex = (currentIndex + i + tabs.length) % tabs.length; + this.selectTab(tabs[nextIndex]); + }, + selectTab: function (panel) { + this.replaceWith( + "flow", + { + flowId: this.getParams().flowId, + detailTab: panel + } + ); + }, + getActive: function(){ + return this.getParams().detailTab; + }, + promptEdit: function () { + var options; + switch(this.getActive()){ + case "request": + options = [ + "method", + "url", + {text:"http version", key:"v"}, + "header" + /*, "content"*/]; + break; + case "response": + options = [ + {text:"http version", key:"v"}, + "code", + "message", + "header" + /*, "content"*/]; + break; + case "details": + return; + default: + throw "Unknown tab for edit: " + this.getActive(); + } + + this.setState({ + prompt: { + done: function (k) { + this.setState({prompt: false}); + if(k){ + this.refs.tab.edit(k); + } + }.bind(this), + options: options + } + }); + }, + render: function () { + var flow = this.props.flow; + var tabs = this.getTabs(flow); + var active = this.getActive(); + + if (!_.contains(tabs, active)) { + if (active === "response" && flow.error) { + active = "error"; + } else if (active === "error" && flow.response) { + active = "response"; + } else { + active = tabs[0]; + } + this.selectTab(active); + } + + var prompt = null; + if (this.state.prompt) { + prompt = <Prompt {...this.state.prompt}/>; + } + + var Tab = allTabs[active]; + return ( + <div className="flow-detail" onScroll={this.adjustHead}> + <Nav ref="head" + flow={flow} + tabs={tabs} + active={active} + selectTab={this.selectTab}/> + <Tab ref="tab" flow={flow}/> + {prompt} + </div> + ); + } +}); + +module.exports = FlowView;
\ No newline at end of file diff --git a/web/src/js/components/flowview/messages.js b/web/src/js/components/flowview/messages.js new file mode 100644 index 00000000..fa75efbe --- /dev/null +++ b/web/src/js/components/flowview/messages.js @@ -0,0 +1,326 @@ +var React = require("react"); +var _ = require("lodash"); + +var common = require("../common.js"); +var actions = require("../../actions.js"); +var flowutils = require("../../flow/utils.js"); +var utils = require("../../utils.js"); +var ContentView = require("./contentview.js"); +var ValueEditor = require("../editor.js").ValueEditor; + +var Headers = React.createClass({ + propTypes: { + onChange: React.PropTypes.func.isRequired, + message: React.PropTypes.object.isRequired + }, + onChange: function (row, col, val) { + var nextHeaders = _.cloneDeep(this.props.message.headers); + nextHeaders[row][col] = val; + if (!nextHeaders[row][0] && !nextHeaders[row][1]) { + // do not delete last row + if (nextHeaders.length === 1) { + nextHeaders[0][0] = "Name"; + nextHeaders[0][1] = "Value"; + } else { + nextHeaders.splice(row, 1); + // manually move selection target if this has been the last row. + if (row === nextHeaders.length) { + this._nextSel = (row - 1) + "-value"; + } + } + } + this.props.onChange(nextHeaders); + }, + edit: function () { + this.refs["0-key"].focus(); + }, + onTab: function (row, col, e) { + var headers = this.props.message.headers; + if (row === headers.length - 1 && col === 1) { + e.preventDefault(); + + var nextHeaders = _.cloneDeep(this.props.message.headers); + nextHeaders.push(["Name", "Value"]); + this.props.onChange(nextHeaders); + this._nextSel = (row + 1) + "-key"; + } + }, + componentDidUpdate: function () { + if (this._nextSel && this.refs[this._nextSel]) { + this.refs[this._nextSel].focus(); + this._nextSel = undefined; + } + }, + onRemove: function (row, col, e) { + if (col === 1) { + e.preventDefault(); + this.refs[row + "-key"].focus(); + } else if (row > 0) { + e.preventDefault(); + this.refs[(row - 1) + "-value"].focus(); + } + }, + render: function () { + + var rows = this.props.message.headers.map(function (header, i) { + + var kEdit = <HeaderEditor + ref={i + "-key"} + content={header[0]} + onDone={this.onChange.bind(null, i, 0)} + onRemove={this.onRemove.bind(null, i, 0)} + onTab={this.onTab.bind(null, i, 0)}/>; + var vEdit = <HeaderEditor + ref={i + "-value"} + content={header[1]} + onDone={this.onChange.bind(null, i, 1)} + onRemove={this.onRemove.bind(null, i, 1)} + onTab={this.onTab.bind(null, i, 1)}/>; + return ( + <tr key={i}> + <td className="header-name">{kEdit}:</td> + <td className="header-value">{vEdit}</td> + </tr> + ); + }.bind(this)); + return ( + <table className="header-table"> + <tbody> + {rows} + </tbody> + </table> + ); + } +}); + +var HeaderEditor = React.createClass({ + render: function () { + return <ValueEditor ref="input" {...this.props} onKeyDown={this.onKeyDown} inline/>; + }, + focus: function () { + this.getDOMNode().focus(); + }, + onKeyDown: function (e) { + switch (e.keyCode) { + case utils.Key.BACKSPACE: + var s = window.getSelection().getRangeAt(0); + if (s.startOffset === 0 && s.endOffset === 0) { + this.props.onRemove(e); + } + break; + case utils.Key.TAB: + if (!e.shiftKey) { + this.props.onTab(e); + } + break; + } + } +}); + +var RequestLine = React.createClass({ + render: function () { + var flow = this.props.flow; + var url = flowutils.RequestUtils.pretty_url(flow.request); + var httpver = "HTTP/" + flow.request.httpversion.join("."); + + return <div className="first-line request-line"> + <ValueEditor + ref="method" + content={flow.request.method} + onDone={this.onMethodChange} + inline/> + + <ValueEditor + ref="url" + content={url} + onDone={this.onUrlChange} + isValid={this.isValidUrl} + inline/> + + <ValueEditor + ref="httpVersion" + content={httpver} + onDone={this.onHttpVersionChange} + isValid={flowutils.isValidHttpVersion} + inline/> + </div> + }, + isValidUrl: function (url) { + var u = flowutils.parseUrl(url); + return !!u.host; + }, + onMethodChange: function (nextMethod) { + actions.FlowActions.update( + this.props.flow, + {request: {method: nextMethod}} + ); + }, + onUrlChange: function (nextUrl) { + var props = flowutils.parseUrl(nextUrl); + props.path = props.path || ""; + actions.FlowActions.update( + this.props.flow, + {request: props} + ); + }, + onHttpVersionChange: function (nextVer) { + var ver = flowutils.parseHttpVersion(nextVer); + actions.FlowActions.update( + this.props.flow, + {request: {httpversion: ver}} + ); + } +}); + +var ResponseLine = React.createClass({ + render: function () { + var flow = this.props.flow; + var httpver = "HTTP/" + flow.response.httpversion.join("."); + return <div className="first-line response-line"> + <ValueEditor + ref="httpVersion" + content={httpver} + onDone={this.onHttpVersionChange} + isValid={flowutils.isValidHttpVersion} + inline/> + + <ValueEditor + ref="code" + content={flow.response.code + ""} + onDone={this.onCodeChange} + isValid={this.isValidCode} + inline/> + + <ValueEditor + ref="msg" + content={flow.response.msg} + onDone={this.onMsgChange} + inline/> + </div>; + }, + isValidCode: function (code) { + return /^\d+$/.test(code); + }, + onHttpVersionChange: function (nextVer) { + var ver = flowutils.parseHttpVersion(nextVer); + actions.FlowActions.update( + this.props.flow, + {response: {httpversion: ver}} + ); + }, + onMsgChange: function (nextMsg) { + actions.FlowActions.update( + this.props.flow, + {response: {msg: nextMsg}} + ); + }, + onCodeChange: function (nextCode) { + nextCode = parseInt(nextCode); + actions.FlowActions.update( + this.props.flow, + {response: {code: nextCode}} + ); + } +}); + +var Request = React.createClass({ + render: function () { + var flow = this.props.flow; + return ( + <section className="request"> + <RequestLine ref="requestLine" flow={flow}/> + {/*<ResponseLine flow={flow}/>*/} + <Headers ref="headers" message={flow.request} onChange={this.onHeaderChange}/> + <hr/> + <ContentView flow={flow} message={flow.request}/> + </section> + ); + }, + edit: function (k) { + switch (k) { + case "m": + this.refs.requestLine.refs.method.focus(); + break; + case "u": + this.refs.requestLine.refs.url.focus(); + break; + case "v": + this.refs.requestLine.refs.httpVersion.focus(); + break; + case "h": + this.refs.headers.edit(); + break; + default: + throw "Unimplemented: " + k; + } + }, + onHeaderChange: function (nextHeaders) { + actions.FlowActions.update(this.props.flow, { + request: { + headers: nextHeaders + } + }); + } +}); + +var Response = React.createClass({ + render: function () { + var flow = this.props.flow; + return ( + <section className="response"> + {/*<RequestLine flow={flow}/>*/} + <ResponseLine ref="responseLine" flow={flow}/> + <Headers ref="headers" message={flow.response} onChange={this.onHeaderChange}/> + <hr/> + <ContentView flow={flow} message={flow.response}/> + </section> + ); + }, + edit: function (k) { + switch (k) { + case "c": + this.refs.responseLine.refs.code.focus(); + break; + case "m": + this.refs.responseLine.refs.msg.focus(); + break; + case "v": + this.refs.responseLine.refs.httpVersion.focus(); + break; + case "h": + this.refs.headers.edit(); + break; + default: + throw "Unimplemented: " + k; + } + }, + onHeaderChange: function (nextHeaders) { + actions.FlowActions.update(this.props.flow, { + response: { + headers: nextHeaders + } + }); + } +}); + +var Error = React.createClass({ + render: function () { + var flow = this.props.flow; + return ( + <section> + <div className="alert alert-warning"> + {flow.error.msg} + <div> + <small>{ utils.formatTimeStamp(flow.error.timestamp) }</small> + </div> + </div> + </section> + ); + } +}); + +module.exports = { + Request: Request, + Response: Response, + Error: Error +};
\ No newline at end of file diff --git a/web/src/js/components/flowview/nav.js b/web/src/js/components/flowview/nav.js new file mode 100644 index 00000000..46eda707 --- /dev/null +++ b/web/src/js/components/flowview/nav.js @@ -0,0 +1,61 @@ +var React = require("react"); + +var actions = require("../../actions.js"); + +var NavAction = React.createClass({ + onClick: function (e) { + e.preventDefault(); + this.props.onClick(); + }, + render: function () { + return ( + <a title={this.props.title} + href="#" + className="nav-action" + onClick={this.onClick}> + <i className={"fa fa-fw " + this.props.icon}></i> + </a> + ); + } +}); + +var Nav = React.createClass({ + render: function () { + var flow = this.props.flow; + + var tabs = this.props.tabs.map(function (e) { + var str = e.charAt(0).toUpperCase() + e.slice(1); + var className = this.props.active === e ? "active" : ""; + var onClick = function (event) { + this.props.selectTab(e); + event.preventDefault(); + }.bind(this); + return <a key={e} + href="#" + className={className} + onClick={onClick}>{str}</a>; + }.bind(this)); + + var acceptButton = null; + if(flow.intercepted){ + acceptButton = <NavAction title="[a]ccept intercepted flow" icon="fa-play" onClick={actions.FlowActions.accept.bind(null, flow)} />; + } + var revertButton = null; + if(flow.modified){ + revertButton = <NavAction title="revert changes to flow [V]" icon="fa-history" onClick={actions.FlowActions.revert.bind(null, flow)} />; + } + + return ( + <nav ref="head" className="nav-tabs nav-tabs-sm"> + {tabs} + <NavAction title="[d]elete flow" icon="fa-trash" onClick={actions.FlowActions.delete.bind(null, flow)} /> + <NavAction title="[D]uplicate flow" icon="fa-copy" onClick={actions.FlowActions.duplicate.bind(null, flow)} /> + <NavAction disabled title="[r]eplay flow" icon="fa-repeat" onClick={actions.FlowActions.replay.bind(null, flow)} /> + {acceptButton} + {revertButton} + </nav> + ); + } +}); + +module.exports = Nav;
\ No newline at end of file diff --git a/web/src/js/components/footer.js b/web/src/js/components/footer.js index d04fb615..229d691b 100644 --- a/web/src/js/components/footer.js +++ b/web/src/js/components/footer.js @@ -1,12 +1,14 @@ var React = require("react"); +var common = require("./common.js"); var Footer = React.createClass({ + mixins: [common.SettingsState], render: function () { - var mode = this.props.settings.mode; - var intercept = this.props.settings.intercept; + var mode = this.state.settings.mode; + var intercept = this.state.settings.intercept; return ( <footer> - {mode != "regular" ? <span className="label label-success">{mode} mode</span> : null} + {mode && mode != "regular" ? <span className="label label-success">{mode} mode</span> : null} {intercept ? <span className="label label-success">Intercept: {intercept}</span> : null} </footer> diff --git a/web/src/js/components/header.js b/web/src/js/components/header.js index d1684fb2..998a41df 100644 --- a/web/src/js/components/header.js +++ b/web/src/js/components/header.js @@ -50,6 +50,7 @@ var FilterDocs = React.createClass({ } }); var FilterInput = React.createClass({ + mixins: [common.ChildFocus], getInitialState: function () { // Consider both focus and mouseover for showing/hiding the tooltip, // because onBlur of the input is triggered before the click on the tooltip @@ -114,11 +115,13 @@ var FilterInput = React.createClass({ // If closed using ESC/ENTER, hide the tooltip. this.setState({mousefocus: false}); } + e.stopPropagation(); }, blur: function () { this.refs.input.getDOMNode().blur(); + this.returnFocus(); }, - focus: function () { + select: function () { this.refs.input.getDOMNode().select(); }, render: function () { @@ -157,14 +160,14 @@ var FilterInput = React.createClass({ }); var MainMenu = React.createClass({ - mixins: [common.Navigation, common.State], + mixins: [common.Navigation, common.RouterState, common.SettingsState], statics: { title: "Start", route: "flows" }, - onFilterChange: function (val) { + onSearchChange: function (val) { var d = {}; - d[Query.FILTER] = val; + d[Query.SEARCH] = val; this.setQuery(d); }, onHighlightChange: function (val) { @@ -173,29 +176,32 @@ var MainMenu = React.createClass({ this.setQuery(d); }, onInterceptChange: function (val) { - SettingsActions.update({intercept: val}); + actions.SettingsActions.update({intercept: val}); }, render: function () { - var filter = this.getQuery()[Query.FILTER] || ""; + var search = this.getQuery()[Query.SEARCH] || ""; var highlight = this.getQuery()[Query.HIGHLIGHT] || ""; - var intercept = this.props.settings.intercept || ""; + var intercept = this.state.settings.intercept || ""; return ( <div> <div className="menu-row"> <FilterInput - placeholder="Filter" - type="filter" + ref="search" + placeholder="Search" + type="search" color="black" - value={filter} - onChange={this.onFilterChange} /> + value={search} + onChange={this.onSearchChange} /> <FilterInput + ref="highlight" placeholder="Highlight" type="tag" color="hsl(48, 100%, 50%)" value={highlight} onChange={this.onHighlightChange}/> <FilterInput + ref="intercept" placeholder="Intercept" type="pause" color="hsl(208, 56%, 53%)" @@ -214,7 +220,7 @@ var ViewMenu = React.createClass({ title: "View", route: "flows" }, - mixins: [common.Navigation, common.State], + mixins: [common.Navigation, common.RouterState], toggleEventLog: function () { var d = {}; @@ -356,15 +362,17 @@ var Header = React.createClass({ }, render: function () { var header = header_entries.map(function (entry, i) { - var classes = React.addons.classSet({ - active: entry == this.state.active - }); + var className; + if (entry === this.state.active) { + className = "active"; + } else { + className = ""; + } return ( <a key={i} href="#" - className={classes} - onClick={this.handleClick.bind(this, entry)} - > + className={className} + onClick={this.handleClick.bind(this, entry)}> { entry.title} </a> ); @@ -377,7 +385,7 @@ var Header = React.createClass({ {header} </nav> <div className="menu"> - <this.state.active settings={this.props.settings}/> + <this.state.active ref="active"/> </div> </header> ); @@ -386,5 +394,6 @@ var Header = React.createClass({ module.exports = { - Header: Header -}
\ No newline at end of file + Header: Header, + MainMenu: MainMenu +};
\ No newline at end of file diff --git a/web/src/js/components/mainview.js b/web/src/js/components/mainview.js index 184ef49f..9ff51dfa 100644 --- a/web/src/js/components/mainview.js +++ b/web/src/js/components/mainview.js @@ -1,25 +1,48 @@ var React = require("react"); -var common = require("./common.js"); var actions = require("../actions.js"); var Query = require("../actions.js").Query; -var toputils = require("../utils.js"); +var utils = require("../utils.js"); var views = require("../store/view.js"); var Filt = require("../filt/filt.js"); -FlowTable = require("./flowtable.js"); -var flowdetail = require("./flowdetail.js"); + +var common = require("./common.js"); +var FlowTable = require("./flowtable.js"); +var FlowView = require("./flowview/index.js"); var MainView = React.createClass({ - mixins: [common.Navigation, common.State], + mixins: [common.Navigation, common.RouterState], + contextTypes: { + flowStore: React.PropTypes.object.isRequired, + }, + childContextTypes: { + view: React.PropTypes.object.isRequired, + }, + getChildContext: function () { + return { + view: this.state.view + }; + }, getInitialState: function () { + var sortKeyFun = false; + var view = new views.StoreView(this.context.flowStore, this.getViewFilt(), sortKeyFun); + view.addListener("recalculate", this.onRecalculate); + view.addListener("add", this.onUpdate); + view.addListener("update", this.onUpdate); + view.addListener("remove", this.onUpdate); + view.addListener("remove", this.onRemove); + return { - flows: [], - sortKeyFun: false + view: view, + sortKeyFun: sortKeyFun }; }, + componentWillUnmount: function () { + this.state.view.close(); + }, getViewFilt: function () { try { - var filt = Filt.parse(this.getQuery()[Query.FILTER] || ""); + var filt = Filt.parse(this.getQuery()[Query.SEARCH] || ""); var highlightStr = this.getQuery()[Query.HIGHLIGHT]; var highlight = highlightStr ? Filt.parse(highlightStr) : false; } catch (e) { @@ -35,29 +58,12 @@ var MainView = React.createClass({ }; }, componentWillReceiveProps: function (nextProps) { - if (nextProps.flowStore !== this.props.flowStore) { - this.closeView(); - this.openView(nextProps.flowStore); - } - - var filterChanged = (this.props.query[Query.FILTER] !== nextProps.query[Query.FILTER]); + var filterChanged = (this.props.query[Query.SEARCH] !== nextProps.query[Query.SEARCH]); var highlightChanged = (this.props.query[Query.HIGHLIGHT] !== nextProps.query[Query.HIGHLIGHT]); if (filterChanged || highlightChanged) { this.state.view.recalculate(this.getViewFilt(), this.state.sortKeyFun); } }, - openView: function (store) { - var view = new views.StoreView(store, this.getViewFilt(), this.state.sortKeyFun); - this.setState({ - view: view - }); - - view.addListener("recalculate", this.onRecalculate); - view.addListener("add", this.onUpdate); - view.addListener("update", this.onUpdate); - view.addListener("remove", this.onUpdate); - view.addListener("remove", this.onRemove); - }, onRecalculate: function () { this.forceUpdate(); var selected = this.getSelected(); @@ -76,16 +82,7 @@ var MainView = React.createClass({ this.selectFlow(flow_to_select); } }, - closeView: function () { - this.state.view.close(); - }, - componentWillMount: function () { - this.openView(this.props.flowStore); - }, - componentWillUnmount: function () { - this.closeView(); - }, - setSortKeyFun: function(sortKeyFun){ + setSortKeyFun: function (sortKeyFun) { this.setState({ sortKeyFun: sortKeyFun }); @@ -109,7 +106,7 @@ var MainView = React.createClass({ var flows = this.state.view.list; var index; if (!this.getParams().flowId) { - if (shift > 0) { + if (shift < 0) { index = flows.length - 1; } else { index = 0; @@ -129,55 +126,55 @@ var MainView = React.createClass({ } this.selectFlow(flows[index]); }, - onKeyDown: function (e) { + onMainKeyDown: function (e) { var flow = this.getSelected(); if (e.ctrlKey) { return; } switch (e.keyCode) { - case toputils.Key.K: - case toputils.Key.UP: + case utils.Key.K: + case utils.Key.UP: this.selectFlowRelative(-1); break; - case toputils.Key.J: - case toputils.Key.DOWN: + case utils.Key.J: + case utils.Key.DOWN: this.selectFlowRelative(+1); break; - case toputils.Key.SPACE: - case toputils.Key.PAGE_DOWN: + case utils.Key.SPACE: + case utils.Key.PAGE_DOWN: this.selectFlowRelative(+10); break; - case toputils.Key.PAGE_UP: + case utils.Key.PAGE_UP: this.selectFlowRelative(-10); break; - case toputils.Key.END: + case utils.Key.END: this.selectFlowRelative(+1e10); break; - case toputils.Key.HOME: + case utils.Key.HOME: this.selectFlowRelative(-1e10); break; - case toputils.Key.ESC: + case utils.Key.ESC: this.selectFlow(null); break; - case toputils.Key.H: - case toputils.Key.LEFT: + case utils.Key.H: + case utils.Key.LEFT: if (this.refs.flowDetails) { this.refs.flowDetails.nextTab(-1); } break; - case toputils.Key.L: - case toputils.Key.TAB: - case toputils.Key.RIGHT: + case utils.Key.L: + case utils.Key.TAB: + case utils.Key.RIGHT: if (this.refs.flowDetails) { this.refs.flowDetails.nextTab(+1); } break; - case toputils.Key.C: + case utils.Key.C: if (e.shiftKey) { actions.FlowActions.clear(); } break; - case toputils.Key.D: + case utils.Key.D: if (flow) { if (e.shiftKey) { actions.FlowActions.duplicate(flow); @@ -186,23 +183,30 @@ var MainView = React.createClass({ } } break; - case toputils.Key.A: + case utils.Key.A: if (e.shiftKey) { actions.FlowActions.accept_all(); } else if (flow && flow.intercepted) { actions.FlowActions.accept(flow); } break; - case toputils.Key.R: + case utils.Key.R: if (!e.shiftKey && flow) { actions.FlowActions.replay(flow); } break; - case toputils.Key.V: + case utils.Key.V: if (e.shiftKey && flow && flow.modified) { actions.FlowActions.revert(flow); } break; + case utils.Key.E: + if (this.refs.flowDetails) { + this.refs.flowDetails.promptEdit(); + } + break; + case utils.Key.SHIFT: + break; default: console.debug("keydown", e.keyCode); return; @@ -210,7 +214,7 @@ var MainView = React.createClass({ e.preventDefault(); }, getSelected: function () { - return this.props.flowStore.get(this.getParams().flowId); + return this.context.flowStore.get(this.getParams().flowId); }, render: function () { var selected = this.getSelected(); @@ -219,16 +223,15 @@ var MainView = React.createClass({ if (selected) { details = [ <common.Splitter key="splitter"/>, - <flowdetail.FlowDetail key="flowDetails" ref="flowDetails" flow={selected}/> + <FlowView key="flowDetails" ref="flowDetails" flow={selected}/> ]; } else { details = null; } return ( - <div className="main-view" onKeyDown={this.onKeyDown} tabIndex="0"> + <div className="main-view"> <FlowTable ref="flowTable" - view={this.state.view} selectFlow={this.selectFlow} setSortKeyFun={this.setSortKeyFun} selected={selected} /> diff --git a/web/src/js/components/prompt.js b/web/src/js/components/prompt.js new file mode 100644 index 00000000..121a1170 --- /dev/null +++ b/web/src/js/components/prompt.js @@ -0,0 +1,100 @@ +var React = require("react"); +var _ = require("lodash"); + +var utils = require("../utils.js"); +var common = require("./common.js"); + +var Prompt = React.createClass({ + mixins: [common.ChildFocus], + propTypes: { + options: React.PropTypes.array.isRequired, + done: React.PropTypes.func.isRequired, + prompt: React.PropTypes.string + }, + componentDidMount: function () { + React.findDOMNode(this).focus(); + }, + onKeyDown: function (e) { + e.stopPropagation(); + e.preventDefault(); + var opts = this.getOptions(); + for (var i = 0; i < opts.length; i++) { + var k = opts[i].key; + if (utils.Key[k.toUpperCase()] === e.keyCode) { + this.done(k); + return; + } + } + if (e.keyCode === utils.Key.ESC || e.keyCode === utils.Key.ENTER) { + this.done(false); + } + }, + onClick: function (e) { + this.done(false); + }, + done: function (ret) { + this.props.done(ret); + this.returnFocus(); + }, + getOptions: function () { + var opts = []; + + var keyTaken = function (k) { + return _.includes(_.pluck(opts, "key"), k); + }; + + for (var i = 0; i < this.props.options.length; i++) { + var opt = this.props.options[i]; + if (_.isString(opt)) { + var str = opt; + while (str.length > 0 && keyTaken(str[0])) { + str = str.substr(1); + } + opt = { + text: opt, + key: str[0] + }; + } + if (!opt.text || !opt.key || keyTaken(opt.key)) { + throw "invalid options"; + } else { + opts.push(opt); + } + } + return opts; + }, + render: function () { + var opts = this.getOptions(); + opts = _.map(opts, function (o) { + var prefix, suffix; + var idx = o.text.indexOf(o.key); + if (idx !== -1) { + prefix = o.text.substring(0, idx); + suffix = o.text.substring(idx + 1); + + } else { + prefix = o.text + " ("; + suffix = ")"; + } + var onClick = function (e) { + this.done(o.key); + e.stopPropagation(); + }.bind(this); + return <span + key={o.key} + className="option" + onClick={onClick}> + {prefix} + <strong className="text-primary">{o.key}</strong>{suffix} + </span>; + }.bind(this)); + return <div tabIndex="0" onKeyDown={this.onKeyDown} onClick={this.onClick} className="prompt-dialog"> + <div className="prompt-content"> + {this.props.prompt || <strong>Select: </strong> } + {opts} + </div> + </div>; + } +}); + +module.exports = Prompt;
\ No newline at end of file diff --git a/web/src/js/components/proxyapp.js b/web/src/js/components/proxyapp.js index 863a9f53..e766d6e6 100644 --- a/web/src/js/components/proxyapp.js +++ b/web/src/js/components/proxyapp.js @@ -9,6 +9,7 @@ var header = require("./header.js"); var EventLog = require("./eventlog.js"); var store = require("../store/store.js"); var Query = require("../actions.js").Query; +var Key = require("../utils.js").Key; //TODO: Move out of here, just a stub. @@ -20,52 +21,87 @@ var Reports = React.createClass({ var ProxyAppMain = React.createClass({ - mixins: [common.State], + mixins: [common.RouterState], + childContextTypes: { + settingsStore: React.PropTypes.object.isRequired, + flowStore: React.PropTypes.object.isRequired, + eventStore: React.PropTypes.object.isRequired, + returnFocus: React.PropTypes.func.isRequired, + }, + componentDidMount: function () { + this.focus(); + }, + getChildContext: function () { + return { + settingsStore: this.state.settingsStore, + flowStore: this.state.flowStore, + eventStore: this.state.eventStore, + returnFocus: this.focus, + }; + }, getInitialState: function () { var eventStore = new store.EventLogStore(); var flowStore = new store.FlowStore(); - var settings = new store.SettingsStore(); + var settingsStore = new store.SettingsStore(); // Default Settings before fetch - _.extend(settings.dict,{ - }); + _.extend(settingsStore.dict, {}); return { - settings: settings, + settingsStore: settingsStore, flowStore: flowStore, eventStore: eventStore }; }, - componentDidMount: function () { - this.state.settings.addListener("recalculate", this.onSettingsChange); - window.app = this; + focus: function () { + React.findDOMNode(this).focus(); }, - componentWillUnmount: function () { - this.state.settings.removeListener("recalculate", this.onSettingsChange); + getMainComponent: function () { + return this.refs.view.refs.__routeHandler__; }, - onSettingsChange: function(){ - this.setState({ - settings: this.state.settings - }); + onKeydown: function (e) { + + var selectFilterInput = function (name) { + var headerComponent = this.refs.header; + headerComponent.setState({active: header.MainMenu}, function () { + headerComponent.refs.active.refs[name].select(); + }); + }.bind(this); + + switch (e.keyCode) { + case Key.I: + selectFilterInput("intercept"); + break; + case Key.L: + selectFilterInput("search"); + break; + case Key.H: + selectFilterInput("highlight"); + break; + default: + var main = this.getMainComponent(); + if (main.onMainKeyDown) { + main.onMainKeyDown(e); + } + return; // don't prevent default then + } + e.preventDefault(); }, render: function () { var eventlog; if (this.getQuery()[Query.SHOW_EVENTLOG]) { eventlog = [ <common.Splitter key="splitter" axis="y"/>, - <EventLog key="eventlog" eventStore={this.state.eventStore}/> + <EventLog key="eventlog"/> ]; } else { eventlog = null; } return ( - <div id="container"> - <header.Header settings={this.state.settings.dict}/> - <RouteHandler - settings={this.state.settings.dict} - flowStore={this.state.flowStore} - query={this.getQuery()}/> + <div id="container" tabIndex="0" onKeyDown={this.onKeydown}> + <header.Header ref="header"/> + <RouteHandler ref="view" query={this.getQuery()}/> {eventlog} - <Footer settings={this.state.settings.dict}/> + <Footer/> </div> ); } diff --git a/web/src/js/connection.js b/web/src/js/connection.js index 85514c3c..5e229b6e 100644 --- a/web/src/js/connection.js +++ b/web/src/js/connection.js @@ -1,5 +1,6 @@ var actions = require("./actions.js"); +var AppDispatcher = require("./dispatcher.js").AppDispatcher; function Connection(url) { if (url[0] === "/") { @@ -16,11 +17,11 @@ function Connection(url) { }; ws.onerror = function () { actions.ConnectionActions.error(); - EventLogActions.add_event("WebSocket connection error."); + actions.EventLogActions.add_event("WebSocket connection error."); }; ws.onclose = function () { actions.ConnectionActions.close(); - EventLogActions.add_event("WebSocket connection closed."); + actions.EventLogActions.add_event("WebSocket connection closed."); }; return ws; } diff --git a/web/src/js/dispatcher.js b/web/src/js/dispatcher.js index 040c34db..0c2aa202 100644 --- a/web/src/js/dispatcher.js +++ b/web/src/js/dispatcher.js @@ -7,7 +7,7 @@ const PayloadSources = { }; -AppDispatcher = new flux.Dispatcher(); +var AppDispatcher = new flux.Dispatcher(); AppDispatcher.dispatchViewAction = function (action) { action.source = PayloadSources.VIEW; this.dispatch(action); diff --git a/web/src/js/flow/utils.js b/web/src/js/flow/utils.js index a95d4ffe..d72febaa 100644 --- a/web/src/js/flow/utils.js +++ b/web/src/js/flow/utils.js @@ -1,8 +1,17 @@ var _ = require("lodash"); +var $ = require("jquery"); -var _MessageUtils = { +var defaultPorts = { + "http": 80, + "https": 443 +}; + +var MessageUtils = { getContentType: function (message) { - return this.get_first_header(message, /^Content-Type$/i); + var ct = this.get_first_header(message, /^Content-Type$/i); + if(ct){ + return ct.split(";")[0].trim(); + } }, get_first_header: function (message, regex) { //FIXME: Cache Invalidation. @@ -34,15 +43,22 @@ var _MessageUtils = { } } return false; + }, + getContentURL: function (flow, message) { + if (message === flow.request) { + message = "request"; + } else if (message === flow.response) { + message = "response"; + } + return "/flows/" + flow.id + "/" + message + "/content"; + }, + getContent: function (flow, message) { + var url = MessageUtils.getContentURL(flow, message); + return $.get(url); } }; -var defaultPorts = { - "http": 80, - "https": 443 -}; - -var RequestUtils = _.extend(_MessageUtils, { +var RequestUtils = _.extend(MessageUtils, { pretty_host: function (request) { //FIXME: Add hostheader return request.host; @@ -56,11 +72,59 @@ var RequestUtils = _.extend(_MessageUtils, { } }); -var ResponseUtils = _.extend(_MessageUtils, {}); +var ResponseUtils = _.extend(MessageUtils, {}); + +var parseUrl_regex = /^(?:(https?):\/\/)?([^\/:]+)?(?::(\d+))?(\/.*)?$/i; +var parseUrl = function (url) { + //there are many correct ways to parse a URL, + //however, a mitmproxy user may also wish to generate a not-so-correct URL. ;-) + var parts = parseUrl_regex.exec(url); + if(!parts){ + return false; + } + + var scheme = parts[1], + host = parts[2], + port = parseInt(parts[3]), + path = parts[4]; + if (scheme) { + port = port || defaultPorts[scheme]; + } + var ret = {}; + if (scheme) { + ret.scheme = scheme; + } + if (host) { + ret.host = host; + } + if (port) { + ret.port = port; + } + if (path) { + ret.path = path; + } + return ret; +}; + + +var isValidHttpVersion_regex = /^HTTP\/\d+(\.\d+)*$/i; +var isValidHttpVersion = function (httpVersion) { + return isValidHttpVersion_regex.test(httpVersion); +}; + +var parseHttpVersion = function (httpVersion) { + httpVersion = httpVersion.replace("HTTP/", "").split("."); + return _.map(httpVersion, function (x) { + return parseInt(x); + }); +}; module.exports = { ResponseUtils: ResponseUtils, - RequestUtils: RequestUtils - -}
\ No newline at end of file + RequestUtils: RequestUtils, + MessageUtils: MessageUtils, + parseUrl: parseUrl, + parseHttpVersion: parseHttpVersion, + isValidHttpVersion: isValidHttpVersion +};
\ No newline at end of file diff --git a/web/src/js/store/view.js b/web/src/js/store/view.js index 204d22da..d628d46b 100644 --- a/web/src/js/store/view.js +++ b/web/src/js/store/view.js @@ -14,8 +14,6 @@ var default_filt = function (elem) { function StoreView(store, filt, sortfun) { EventEmitter.call(this); - filt = filt || default_filt; - sortfun = sortfun || default_sort; this.store = store; @@ -37,12 +35,13 @@ _.extend(StoreView.prototype, EventEmitter.prototype, { this.store.removeListener("update", this.update); this.store.removeListener("remove", this.remove); this.store.removeListener("recalculate", this.recalculate); + this.removeAllListeners(); }, recalculate: function (filt, sortfun) { - filt = filt || default_filt; - sortfun = sortfun || default_sort; + filt = filt || this.filt || default_filt; + sortfun = sortfun || this.sortfun || default_sort; filt = filt.bind(this); - sortfun = sortfun.bind(this) + sortfun = sortfun.bind(this); this.filt = filt; this.sortfun = sortfun; diff --git a/web/src/js/utils.js b/web/src/js/utils.js index bcd3958d..40575692 100644 --- a/web/src/js/utils.js +++ b/web/src/js/utils.js @@ -1,5 +1,10 @@ var $ = require("jquery"); var _ = require("lodash"); +var actions = require("./actions.js"); + +window.$ = $; +window._ = _; +window.React = require("react"); var Key = { UP: 38, @@ -15,6 +20,7 @@ var Key = { TAB: 9, SPACE: 32, BACKSPACE: 8, + SHIFT: 16 }; // Add A-Z for (var i = 65; i <= 90; i++) { @@ -26,17 +32,17 @@ var formatSize = function (bytes) { if (bytes === 0) return "0"; var prefix = ["b", "kb", "mb", "gb", "tb"]; - for (var i = 0; i < prefix.length; i++){ - if (Math.pow(1024, i + 1) > bytes){ + for (var i = 0; i < prefix.length; i++) { + if (Math.pow(1024, i + 1) > bytes) { break; } } var precision; - if (bytes%Math.pow(1024, i) === 0) + if (bytes % Math.pow(1024, i) === 0) precision = 0; else precision = 1; - return (bytes/Math.pow(1024, i)).toFixed(precision) + prefix[i]; + return (bytes / Math.pow(1024, i)).toFixed(precision) + prefix[i]; }; @@ -58,21 +64,20 @@ var formatTimeStamp = function (seconds) { return ts.replace("T", " ").replace("Z", ""); }; - // At some places, we need to sort strings alphabetically descending, // but we can only provide a key function. // This beauty "reverses" a JS string. var end = String.fromCharCode(0xffff); -function reverseString(s){ +function reverseString(s) { return String.fromCharCode.apply(String, - _.map(s.split(""), function (c) { - return 0xffff - c.charCodeAt(); - }) - ) + end; + _.map(s.split(""), function (c) { + return 0xffff - c.charCodeAt(0); + }) + ) + end; } function getCookie(name) { - var r = document.cookie.match("\\b" + name + "=([^;]*)\\b"); + var r = document.cookie.match(new RegExp("\\b" + name + "=([^;]*)\\b")); return r ? r[1] : undefined; } var xsrf = $.param({_xsrf: getCookie("_xsrf")}); @@ -80,19 +85,22 @@ var xsrf = $.param({_xsrf: getCookie("_xsrf")}); //Tornado XSRF Protection. $.ajaxPrefilter(function (options) { if (["post", "put", "delete"].indexOf(options.type.toLowerCase()) >= 0 && options.url[0] === "/") { - if (options.data) { - options.data += ("&" + xsrf); + if(options.url.indexOf("?") === -1){ + options.url += "?" + xsrf; } else { - options.data = xsrf; + options.url += "&" + xsrf; } } }); // Log AJAX Errors $(document).ajaxError(function (event, jqXHR, ajaxSettings, thrownError) { + if (thrownError === "abort") { + return; + } var message = jqXHR.responseText; - console.error(message, arguments); - EventLogActions.add_event(thrownError + ": " + message); - window.alert(message); + console.error(thrownError, message, arguments); + actions.EventLogActions.add_event(thrownError + ": " + message); + alert(message); }); module.exports = { @@ -100,5 +108,5 @@ module.exports = { formatTimeDelta: formatTimeDelta, formatTimeStamp: formatTimeStamp, reverseString: reverseString, - Key: Key + Key: Key, };
\ No newline at end of file |