├── .dockerignore ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── _config.yml ├── commands.go ├── config └── config.go ├── docker-entrypoint.sh ├── docs └── images │ └── screencast.gif ├── formatter ├── binary.go ├── formatter.go ├── formatter_test.go ├── html.go ├── json.go └── text.go ├── go.mod ├── go.sum ├── keys.go ├── request-headers.go ├── sample-config.toml ├── status-line.go └── wuzz.go /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | 3 | !config 4 | !formatter 5 | !*.go 6 | !go.mod 7 | !sample-config.toml 8 | !docker-entrypoint.sh -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | wuzz 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.12.x #Update go version 4 | - tip 5 | os: 6 | - linux 7 | # remove osx, getting vm from travis is extremely slow 8 | # - osx 9 | 10 | arch: 11 | - amd64 12 | - ppc64le 13 | env: 14 | - "PATH=/home/travis/gopath/bin:$PATH" 15 | script: 16 | - test -z "$(go get -a)" 17 | - test -z "$(gofmt -l ./)" 18 | - test -z "$(go vet -v ./...)" 19 | - go test ./... 20 | - go build 21 | # after_success: 22 | # # Publish as pre-release if it isn't a pull request 23 | # - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then go get github.com/mitchellh/gox && go get github.com/tcnksm/ghr; fi' 24 | # - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then gox -output "dist/{{.Dir}}_{{.OS}}_{{.Arch}}" && ghr --username $GITHUB_USERNAME --token $GITHUB_TOKEN --replace --prerelease --debug pre-release dist/; fi' 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.5.0 2020.01.19 2 | 3 | - Added context specific search for HTML (goquery) 4 | - Request data displayed in request history 5 | - Fixed JSON indentation 6 | - Redirect toggle command Added 7 | - Multiple minor bug fixes 8 | 9 | ## 0.4.0 2017.08.21 10 | 11 | - Save/load requests (`-f`/`--file` flags for loading) 12 | - Multipart form handling (`-F`/`--form` flags) 13 | - Edit window content in external editor 14 | - Colorized html output 15 | - Context specific search (github.com/tidwall/gjson for JSON) 16 | - More consistency with cURL API (`--data-urlencode` flag added) 17 | - Update to the latest `gocui` ui library 18 | 19 | 20 | ## 0.3.0 2017.03.07 21 | 22 | - Request header autocompletion 23 | - Configurable statusline 24 | - JSON requests with `-j`/`--json` flags 25 | - Allow insecure HTTPS requests (`-k`/`--insecure` flags) 26 | - Socks proxy support (`-x`/`--proxy` flags) 27 | - Disable following redirects (`-R`/`--disable-redirects` flags) 28 | - Enhanced TLS support (`-T`/`--tls`, `-1`/`--tlsv1`, `--tlsv1.0`, `--tlsv1.1`, `--tlsv1.2` flags) 29 | - Commands for line and word deletion 30 | - Home/end navigation fix 31 | 32 | ## 0.2.0 2017.02.18 33 | 34 | - Config file support with configurable keybindings 35 | - Help popup (F1 key) 36 | - Ignore invalid SSL certs with the --insecure flag 37 | - PATCH request support 38 | - Allow JSON request body (--data-binary flag) 39 | - Colorized JSON response 40 | - Parameter encoding bugfix 41 | - Multiple UI bugfixes 42 | 43 | ## 0.1.0 2017.02.11 44 | 45 | Initial release 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.12 AS permissions-giver 2 | 3 | # Make sure docker-entrypoint.sh is executable, regardless of the build host. 4 | WORKDIR /out 5 | COPY docker-entrypoint.sh . 6 | RUN chmod +x docker-entrypoint.sh 7 | 8 | FROM golang:1.14-alpine3.12 AS builder 9 | 10 | # Build wuzz 11 | WORKDIR /out 12 | COPY . . 13 | RUN go build . 14 | 15 | FROM alpine:3.12 AS organizer 16 | 17 | # Prepare executables 18 | WORKDIR /out 19 | COPY --from=builder /out/wuzz . 20 | COPY --from=permissions-giver /out/docker-entrypoint.sh . 21 | 22 | FROM alpine:3.12 AS runner 23 | WORKDIR /wuzz 24 | COPY --from=organizer /out /usr/local/bin 25 | ENTRYPOINT [ "docker-entrypoint.sh" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wuzz 2 | 3 | Interactive cli tool for HTTP inspection. 4 | 5 | Wuzz command line arguments are similar to cURL's arguments, 6 | so it can be used to inspect/modify requests copied from the 7 | browser's network inspector with the "copy as cURL" feature. 8 | 9 | ![wuzz screencast](docs/images/screencast.gif) 10 | 11 | 12 | ## Installation and usage 13 | 14 | ### GO 15 | 16 | ``` 17 | $ go get github.com/asciimoo/wuzz 18 | $ "$GOPATH/bin/wuzz" --help 19 | ``` 20 | 21 | Note: golang >= 1.10 required. 22 | 23 | [Binary releases](https://github.com/asciimoo/wuzz/releases) are also available. 24 | 25 | ### Apt 26 | 27 | ``` 28 | $ apt install wuzz 29 | ``` 30 | 31 | ### Apk 32 | 33 | ``` 34 | $ apk add wuzz 35 | ``` 36 | 37 | ### Scoop 38 | 39 | ``` 40 | $ scoop bucket add main 41 | $ scoop install main/wuzz 42 | ``` 43 | 44 | ### X-CMD 45 | 46 | ``` 47 | $ x env use wuzz 48 | ``` 49 | 50 | ### Nix 51 | 52 | ``` 53 | $ nix-shell -p wuzz 54 | ``` 55 | 56 | 57 | ### Configuration 58 | 59 | It is possible to override default settings in a configuration file. 60 | The default location is `"$XDG_CONFIG_HOME/wuzz/config.toml"`on linux 61 | and `~/.wuzz/config.toml` on other platforms. 62 | `-c`/`--config` switches can be used to load config file from custom location. 63 | 64 | See [example configuration](sample-config.toml) for more details. 65 | 66 | 67 | ### Commands 68 | 69 | Keybinding | Description 70 | ----------------------------------------|--------------------------------------- 71 | F1 | Display help 72 | Ctrl+R | Send request 73 | Ret | Send request (only from URL view) 74 | Ctrl+S | Save response 75 | Ctrl+E | Save request 76 | Ctrl+F | Load request 77 | Ctrl+C | Quit 78 | Ctrl+K, Shift+Tab | Previous view 79 | Ctlr+J, Tab | Next view 80 | Ctlr+T | Toggle context specific search 81 | Alt+H | Toggle history 82 | Down | Move down one view line 83 | Up | Move up one view line 84 | Page down | Move down one view page 85 | Page up | Move up one view page 86 | F2 | Jump to URL 87 | F3 | Jump to query parameters 88 | F4 | Jump to HTTP method 89 | F5 | Jump to request body 90 | F6 | Jump to headers 91 | F7 | Jump to search 92 | F8 | Jump to response headers 93 | F9 | Jump to response body 94 | F11 | Redirects Restriction Mode 95 | 96 | 97 | ### Context specific search 98 | 99 | Wuzz accepts regular expressions by default to filter response body. 100 | Custom query syntax can be toggled by pressing Ctrl+T. 101 | The following formats have context specific search syntax: 102 | 103 | Response format | Query syntax 104 | -----------------|---------------------------------------- 105 | HTML | https://github.com/PuerkitoBio/goquery 106 | JSON | https://github.com/tidwall/gjson 107 | 108 | 109 | ## TODO 110 | 111 | * Better navigation 112 | * Autocompletion 113 | * Tests 114 | 115 | 116 | ## Bugs / Suggestions 117 | 118 | Bugs or suggestions? Visit the [issue tracker](https://github.com/asciimoo/wuzz/issues) 119 | or join `#wuzz` on freenode 120 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "unicode" 10 | 11 | "github.com/jroimartin/gocui" 12 | "github.com/nsf/termbox-go" 13 | ) 14 | 15 | type CommandFunc func(*gocui.Gui, *gocui.View) error 16 | 17 | var COMMANDS map[string]func(string, *App) CommandFunc = map[string]func(string, *App) CommandFunc{ 18 | "submit": func(_ string, a *App) CommandFunc { 19 | return a.SubmitRequest 20 | }, 21 | "saveResponse": func(_ string, a *App) CommandFunc { 22 | return func(g *gocui.Gui, _ *gocui.View) error { 23 | return a.OpenSaveDialog(VIEW_TITLES[SAVE_RESPONSE_DIALOG_VIEW], g, 24 | func(g *gocui.Gui, _ *gocui.View) error { 25 | saveLocation := getViewValue(g, SAVE_DIALOG_VIEW) 26 | 27 | if len(a.history) == 0 { 28 | return nil 29 | } 30 | req := a.history[a.historyIndex] 31 | if req.RawResponseBody == nil { 32 | return nil 33 | } 34 | 35 | err := ioutil.WriteFile(saveLocation, req.RawResponseBody, 0644) 36 | 37 | var saveResult string 38 | if err == nil { 39 | saveResult = "Response saved successfully." 40 | } else { 41 | saveResult = "Error saving response: " + err.Error() 42 | } 43 | viewErr := a.OpenSaveResultView(saveResult, g) 44 | return viewErr 45 | }) 46 | } 47 | }, 48 | "loadRequest": func(_ string, a *App) CommandFunc { 49 | return func(g *gocui.Gui, _ *gocui.View) error { 50 | return a.OpenSaveDialog(VIEW_TITLES[LOAD_REQUEST_DIALOG_VIEW], g, 51 | func(g *gocui.Gui, _ *gocui.View) error { 52 | defer a.closePopup(g, SAVE_DIALOG_VIEW) 53 | loadLocation := getViewValue(g, SAVE_DIALOG_VIEW) 54 | return a.LoadRequest(g, loadLocation) 55 | }) 56 | } 57 | }, 58 | "saveRequest": func(_ string, a *App) CommandFunc { 59 | return a.SaveRequest 60 | }, 61 | "history": func(_ string, a *App) CommandFunc { 62 | return a.ToggleHistory 63 | }, 64 | "quit": func(_ string, _ *App) CommandFunc { 65 | return quit 66 | }, 67 | "focus": func(args string, a *App) CommandFunc { 68 | return func(g *gocui.Gui, _ *gocui.View) error { 69 | return a.setViewByName(g, args) 70 | } 71 | }, 72 | "nextView": func(_ string, a *App) CommandFunc { 73 | return a.NextView 74 | }, 75 | "prevView": func(_ string, a *App) CommandFunc { 76 | return a.PrevView 77 | }, 78 | "scrollDown": func(_ string, _ *App) CommandFunc { 79 | return scrollViewDown 80 | }, 81 | "scrollUp": func(_ string, _ *App) CommandFunc { 82 | return scrollViewUp 83 | }, 84 | "pageDown": func(_ string, _ *App) CommandFunc { 85 | return pageDown 86 | }, 87 | "pageUp": func(_ string, _ *App) CommandFunc { 88 | return pageUp 89 | }, 90 | "deleteLine": func(_ string, _ *App) CommandFunc { 91 | return deleteLine 92 | }, 93 | "deleteWord": func(_ string, _ *App) CommandFunc { 94 | return deleteWord 95 | }, 96 | "openEditor": func(_ string, a *App) CommandFunc { 97 | return func(g *gocui.Gui, v *gocui.View) error { 98 | return openEditor(g, v, a.config.General.Editor) 99 | } 100 | }, 101 | "toggleContextSpecificSearch": func(_ string, a *App) CommandFunc { 102 | return func(g *gocui.Gui, _ *gocui.View) error { 103 | a.config.General.ContextSpecificSearch = !a.config.General.ContextSpecificSearch 104 | a.PrintBody(g) 105 | return nil 106 | } 107 | }, 108 | "clearHistory": func(_ string, a *App) CommandFunc { 109 | return func(g *gocui.Gui, _ *gocui.View) error { 110 | a.history = make([]*Request, 0, 31) 111 | a.historyIndex = 0 112 | a.Layout(g) 113 | return nil 114 | } 115 | }, 116 | "redirectRestriction": func(_ string, a *App) CommandFunc { 117 | return func(g *gocui.Gui, _ *gocui.View) error { 118 | a.config.General.FollowRedirects = !a.config.General.FollowRedirects 119 | return nil 120 | } 121 | }, 122 | } 123 | 124 | func scrollView(v *gocui.View, dy int) error { 125 | v.Autoscroll = false 126 | ox, oy := v.Origin() 127 | if oy+dy < 0 { 128 | dy = -oy 129 | } 130 | if _, err := v.Line(dy); dy > 0 && err != nil { 131 | dy = 0 132 | } 133 | v.SetOrigin(ox, oy+dy) 134 | return nil 135 | } 136 | 137 | func scrollViewUp(_ *gocui.Gui, v *gocui.View) error { 138 | return scrollView(v, -1) 139 | } 140 | 141 | func scrollViewDown(_ *gocui.Gui, v *gocui.View) error { 142 | return scrollView(v, 1) 143 | } 144 | 145 | func pageUp(_ *gocui.Gui, v *gocui.View) error { 146 | _, height := v.Size() 147 | scrollView(v, -height*2/3) 148 | return nil 149 | } 150 | 151 | func pageDown(_ *gocui.Gui, v *gocui.View) error { 152 | _, height := v.Size() 153 | scrollView(v, height*2/3) 154 | return nil 155 | } 156 | 157 | func deleteLine(_ *gocui.Gui, v *gocui.View) error { 158 | if !v.Editable { 159 | return nil 160 | } 161 | _, curY := v.Cursor() 162 | _, oY := v.Origin() 163 | currentLine := curY + oY 164 | viewLines := strings.Split(strings.TrimSpace(v.Buffer()), "\n") 165 | if currentLine >= len(viewLines) { 166 | return nil 167 | } 168 | v.Clear() 169 | if currentLine > 0 { 170 | fmt.Fprintln(v, strings.Join(viewLines[:currentLine], "\n")) 171 | } 172 | fmt.Fprint(v, strings.Join(viewLines[currentLine+1:], "\n")) 173 | v.SetCursor(0, currentLine) 174 | v.SetOrigin(0, oY) 175 | return nil 176 | } 177 | 178 | func deleteWord(_ *gocui.Gui, v *gocui.View) error { 179 | cX, cY := v.Cursor() 180 | oX, _ := v.Origin() 181 | cX = cX - 1 + oX 182 | line, err := v.Line(cY) 183 | if err != nil || line == "" || cX < 0 { 184 | return nil 185 | } 186 | if cX >= len(line) { 187 | cX = len(line) - 1 188 | } 189 | origCharCateg := getCharCategory(rune(line[cX])) 190 | v.EditDelete(true) 191 | cX -= 1 192 | for cX >= 0 { 193 | c := rune(line[cX]) 194 | if origCharCateg != getCharCategory(c) { 195 | break 196 | } 197 | v.EditDelete(true) 198 | cX -= 1 199 | } 200 | return nil 201 | } 202 | 203 | func getCharCategory(chr rune) int { 204 | switch { 205 | case unicode.IsDigit(chr): 206 | return 0 207 | case unicode.IsLetter(chr): 208 | return 1 209 | case unicode.IsSpace(chr): 210 | return 2 211 | case unicode.IsPunct(chr): 212 | return 3 213 | } 214 | return int(chr) 215 | } 216 | 217 | func quit(g *gocui.Gui, v *gocui.View) error { 218 | return gocui.ErrQuit 219 | } 220 | 221 | func openEditor(g *gocui.Gui, v *gocui.View, editor string) error { 222 | file, err := ioutil.TempFile(os.TempDir(), "wuzz-") 223 | if err != nil { 224 | return nil 225 | } 226 | defer os.Remove(file.Name()) 227 | 228 | val := getViewValue(g, v.Name()) 229 | if val != "" { 230 | fmt.Fprint(file, val) 231 | } 232 | file.Close() 233 | 234 | info, err := os.Stat(file.Name()) 235 | if err != nil { 236 | return nil 237 | } 238 | 239 | cmd := exec.Command(editor, file.Name()) 240 | cmd.Stdout = os.Stdout 241 | cmd.Stdin = os.Stdin 242 | cmd.Stderr = os.Stderr 243 | err = cmd.Run() 244 | // sync termbox to reset console settings 245 | // this is required because the external editor can modify the console 246 | defer g.Update(func(_ *gocui.Gui) error { 247 | termbox.Sync() 248 | return nil 249 | }) 250 | if err != nil { 251 | rv, _ := g.View(RESPONSE_BODY_VIEW) 252 | rv.Clear() 253 | fmt.Fprintf(rv, "Editor open error: %v", err) 254 | return nil 255 | } 256 | 257 | newInfo, err := os.Stat(file.Name()) 258 | if err != nil || newInfo.ModTime().Before(info.ModTime()) { 259 | return nil 260 | } 261 | 262 | newVal, err := ioutil.ReadFile(file.Name()) 263 | if err != nil { 264 | return nil 265 | } 266 | 267 | v.SetCursor(0, 0) 268 | v.SetOrigin(0, 0) 269 | v.Clear() 270 | fmt.Fprint(v, strings.TrimSpace(string(newVal))) 271 | 272 | return nil 273 | } 274 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "time" 9 | 10 | "github.com/BurntSushi/toml" 11 | "github.com/mitchellh/go-homedir" 12 | ) 13 | 14 | var ContentTypes = map[string]string{ 15 | "json": "application/json", 16 | "form": "application/x-www-form-urlencoded", 17 | "multipart": "multipart/form-data", 18 | } 19 | 20 | // Duration is used to automatically unmarshal timeout strings to 21 | // time.Duration values 22 | type Duration struct { 23 | time.Duration 24 | } 25 | 26 | func (d *Duration) UnmarshalText(text []byte) error { 27 | var err error 28 | d.Duration, err = time.ParseDuration(string(text)) 29 | return err 30 | } 31 | 32 | type Config struct { 33 | General GeneralOptions 34 | Keys map[string]map[string]string 35 | } 36 | 37 | type GeneralOptions struct { 38 | ContextSpecificSearch bool 39 | DefaultURLScheme string 40 | Editor string 41 | FollowRedirects bool 42 | FormatJSON bool 43 | Insecure bool 44 | PreserveScrollPosition bool 45 | StatusLine string 46 | TLSVersionMax uint16 47 | TLSVersionMin uint16 48 | Timeout Duration 49 | } 50 | 51 | var defaultTimeoutDuration, _ = time.ParseDuration("1m") 52 | 53 | var DefaultKeys = map[string]map[string]string{ 54 | "global": { 55 | "CtrlR": "submit", 56 | "CtrlC": "quit", 57 | "CtrlS": "saveResponse", 58 | "CtrlF": "loadRequest", 59 | "CtrlE": "saveRequest", 60 | "CtrlD": "deleteLine", 61 | "CtrlW": "deleteWord", 62 | "CtrlO": "openEditor", 63 | "CtrlT": "toggleContextSpecificSearch", 64 | "CtrlX": "clearHistory", 65 | "Tab": "nextView", 66 | "CtrlJ": "nextView", 67 | "CtrlK": "prevView", 68 | "AltH": "history", 69 | "F2": "focus url", 70 | "F3": "focus get", 71 | "F4": "focus method", 72 | "F5": "focus data", 73 | "F6": "focus headers", 74 | "F7": "focus search", 75 | "F8": "focus response-headers", 76 | "F9": "focus response-body", 77 | "F11": "redirectRestriction", 78 | }, 79 | "url": { 80 | "Enter": "submit", 81 | }, 82 | "response-headers": { 83 | "ArrowUp": "scrollUp", 84 | "ArrowDown": "scrollDown", 85 | "PageUp": "pageUp", 86 | "PageDown": "pageDown", 87 | }, 88 | "response-body": { 89 | "ArrowUp": "scrollUp", 90 | "ArrowDown": "scrollDown", 91 | "PageUp": "pageUp", 92 | "PageDown": "pageDown", 93 | }, 94 | "help": { 95 | "ArrowUp": "scrollUp", 96 | "ArrowDown": "scrollDown", 97 | "PageUp": "pageUp", 98 | "PageDown": "pageDown", 99 | }, 100 | } 101 | 102 | var DefaultConfig = Config{ 103 | General: GeneralOptions{ 104 | DefaultURLScheme: "https", 105 | Editor: "vim", 106 | FollowRedirects: true, 107 | FormatJSON: true, 108 | Insecure: false, 109 | PreserveScrollPosition: true, 110 | StatusLine: "[wuzz {{.Version}}]{{if .Duration}} [Response time: {{.Duration}}]{{end}} [Request no.: {{.RequestNumber}}/{{.HistorySize}}] [Search type: {{.SearchType}}]{{if .DisableRedirect}} [Redirects Restricted Mode {{.DisableRedirect}}]{{end}}", 111 | Timeout: Duration{ 112 | defaultTimeoutDuration, 113 | }, 114 | }, 115 | } 116 | 117 | func init() { 118 | if os.Getenv("EDITOR") != "" { 119 | DefaultConfig.General.Editor = os.Getenv("EDITOR") 120 | } 121 | } 122 | 123 | func LoadConfig(configFile string) (*Config, error) { 124 | if _, err := os.Stat(configFile); os.IsNotExist(err) { 125 | return nil, errors.New("Config file does not exist.") 126 | } else if err != nil { 127 | return nil, err 128 | } 129 | 130 | conf := DefaultConfig 131 | if _, err := toml.DecodeFile(configFile, &conf); err != nil { 132 | return nil, err 133 | } 134 | 135 | if conf.Keys == nil { 136 | conf.Keys = DefaultKeys 137 | } else { 138 | // copy default keys 139 | for keyCategory, keys := range DefaultKeys { 140 | confKeys, found := conf.Keys[keyCategory] 141 | if found { 142 | for key, action := range keys { 143 | if _, found := confKeys[key]; !found { 144 | conf.Keys[keyCategory][key] = action 145 | } 146 | } 147 | } else { 148 | conf.Keys[keyCategory] = keys 149 | } 150 | } 151 | } 152 | 153 | return &conf, nil 154 | } 155 | 156 | func GetDefaultConfigLocation() string { 157 | var configFolderLocation string 158 | switch runtime.GOOS { 159 | case "linux": 160 | // Use the XDG_CONFIG_HOME variable if it is set, otherwise 161 | // $HOME/.config/wuzz/config.toml 162 | xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") 163 | if xdgConfigHome != "" { 164 | configFolderLocation = xdgConfigHome 165 | } else { 166 | configFolderLocation, _ = homedir.Expand("~/.config/wuzz/") 167 | } 168 | 169 | default: 170 | // On other platforms we just use $HOME/.wuzz 171 | configFolderLocation, _ = homedir.Expand("~/.wuzz/") 172 | } 173 | 174 | return filepath.Join(configFolderLocation, "config.toml") 175 | } 176 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ "${1#-}" != "${1}" ] || [ -z "$(command -v "${1}")" ]; then 5 | sleep 0.01 6 | set -- wuzz "$@" 7 | fi 8 | 9 | exec "$@" -------------------------------------------------------------------------------- /docs/images/screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asciimoo/wuzz/9ae9b524f225f085a152195aac0c6322f289d966/docs/images/screencast.gif -------------------------------------------------------------------------------- /formatter/binary.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "encoding/hex" 5 | "errors" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | type binaryFormatter struct { 11 | } 12 | 13 | func (f *binaryFormatter) Format(writer io.Writer, data []byte) error { 14 | fmt.Fprint(writer, hex.Dump(data)) 15 | return nil 16 | } 17 | 18 | func (f *binaryFormatter) Title() string { 19 | return "[binary]" 20 | } 21 | 22 | func (f *binaryFormatter) Searchable() bool { 23 | return false 24 | } 25 | 26 | func (f *binaryFormatter) Search(q string, body []byte) ([]string, error) { 27 | return nil, errors.New("Cannot perform search on binary content type") 28 | } 29 | -------------------------------------------------------------------------------- /formatter/formatter.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "io" 5 | "mime" 6 | "strings" 7 | 8 | "github.com/asciimoo/wuzz/config" 9 | ) 10 | 11 | type ResponseFormatter interface { 12 | Format(writer io.Writer, data []byte) error 13 | Title() string 14 | Searchable() bool 15 | Search(string, []byte) ([]string, error) 16 | } 17 | 18 | func New(appConfig *config.Config, contentType string) ResponseFormatter { 19 | ctype, _, err := mime.ParseMediaType(contentType) 20 | if err == nil && appConfig.General.FormatJSON && (ctype == config.ContentTypes["json"] || strings.HasSuffix(ctype, "+json")) { 21 | return &jsonFormatter{} 22 | } else if strings.Contains(contentType, "text/html") { 23 | return &htmlFormatter{} 24 | } else if strings.Index(contentType, "text") == -1 && strings.Index(contentType, "application") == -1 { 25 | return &binaryFormatter{} 26 | } else { 27 | return &TextFormatter{} 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /formatter/formatter_test.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/asciimoo/wuzz/config" 8 | "github.com/nwidger/jsoncolor" 9 | "github.com/x86kernel/htmlcolor" 10 | ) 11 | 12 | func TestFormat(t *testing.T) { 13 | var binBuffer bytes.Buffer 14 | New(configFixture(true), "octet-stream").Format(&binBuffer, []byte("some binary data")) 15 | if binBuffer.String() != "00000000 73 6f 6d 65 20 62 69 6e 61 72 79 20 64 61 74 61 |some binary data|\n" { 16 | t.Error("Expected binary to eq " + binBuffer.String()) 17 | } 18 | 19 | var htmlBuffer bytes.Buffer 20 | New(configFixture(true), "text/html; charset=utf-8").Format(&htmlBuffer, []byte("unfomatted")) 21 | var htmltargetBuffer bytes.Buffer 22 | htmlcolor.NewFormatter().Format(&htmltargetBuffer, []byte("unfomatted")) 23 | htmltarget := htmltargetBuffer.String() 24 | 25 | if htmlBuffer.String() != htmltarget { 26 | t.Error("Expected html to eq " + htmlBuffer.String()) 27 | } 28 | 29 | var jsonEnabledBuffer bytes.Buffer 30 | New(configFixture(true), "application/json; charset=utf-8").Format(&jsonEnabledBuffer, []byte("{\"json\": \"some value\"}")) 31 | var jsontargetBuffer bytes.Buffer 32 | f := jsoncolor.NewFormatter() 33 | f.Indent = " " 34 | f.Format(&jsontargetBuffer, []byte("{\"json\": \"some value\"}")) 35 | jsontarget := jsontargetBuffer.String() 36 | 37 | if jsonEnabledBuffer.String() != jsontarget { 38 | t.Error("Expected json to eq \n" + jsonEnabledBuffer.String() + "\nbut not\n" + jsontarget) 39 | } 40 | 41 | var jsonDisabledBuffer bytes.Buffer 42 | New(configFixture(false), "application/json; charset=utf-8").Format(&jsonDisabledBuffer, []byte("{\"json\": \"some value\"}")) 43 | if jsonDisabledBuffer.String() != "{\"json\": \"some value\"}" { 44 | t.Error("Expected json to eq " + jsonDisabledBuffer.String()) 45 | } 46 | 47 | var textBuffer bytes.Buffer 48 | New(configFixture(true), "text/html; charset=utf-8").Format(&textBuffer, []byte("some text")) 49 | if textBuffer.String() != "some text" { 50 | t.Error("Expected text to eq " + textBuffer.String()) 51 | } 52 | } 53 | 54 | func TestTitle(t *testing.T) { 55 | //binary 56 | title := New(configFixture(true), "octet-stream").Title() 57 | if title != "[binary]" { 58 | t.Error("for octet-stream content type expected title ", title, "to be [binary]") 59 | } 60 | 61 | //html 62 | title = New(configFixture(true), "text/html; charset=utf-8").Title() 63 | if title != "[html]" { 64 | t.Error("For text/html content type expected title ", title, " to be [html]") 65 | } 66 | 67 | //json 68 | title = New(configFixture(true), "application/json; charset=utf-8").Title() 69 | if title != "[json]" { 70 | t.Error("For text/html content type expected title ", title, " to be [json]") 71 | } 72 | 73 | //text 74 | title = New(configFixture(true), "text/plain; charset=utf-8").Title() 75 | if title != "[text]" { 76 | t.Error("For text/html content type expected title ", title, " to be [text]") 77 | } 78 | } 79 | 80 | func TestSearchable(t *testing.T) { 81 | if New(configFixture(true), "octet-stream").Searchable() { 82 | t.Error("binary file can't be searchable") 83 | } 84 | 85 | if !New(configFixture(true), "text/html").Searchable() { 86 | t.Error("text/html should be searchable") 87 | } 88 | 89 | if !New(configFixture(true), "application/json").Searchable() { 90 | t.Error("application/json should be searchable") 91 | } 92 | if !New(configFixture(true), "text/plain").Searchable() { 93 | t.Error("text/plain should be searchable") 94 | } 95 | 96 | } 97 | 98 | func configFixture(jsonEnabled bool) *config.Config { 99 | return &config.Config{ 100 | General: config.GeneralOptions{ 101 | FormatJSON: jsonEnabled, 102 | }, 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /formatter/html.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | 8 | "github.com/PuerkitoBio/goquery" 9 | "github.com/x86kernel/htmlcolor" 10 | ) 11 | 12 | type htmlFormatter struct { 13 | parsedBody goquery.Document 14 | TextFormatter 15 | } 16 | 17 | func (f *htmlFormatter) Format(writer io.Writer, data []byte) error { 18 | htmlFormatter := htmlcolor.NewFormatter() 19 | buf := bytes.NewBuffer(make([]byte, 0, len(data))) 20 | err := htmlFormatter.Format(buf, data) 21 | 22 | if err == io.EOF { 23 | writer.Write(buf.Bytes()) 24 | return nil 25 | } 26 | 27 | return errors.New("html formatter error") 28 | } 29 | 30 | func (f *htmlFormatter) Title() string { 31 | return "[html]" 32 | } 33 | 34 | func (f *htmlFormatter) Search(q string, body []byte) ([]string, error) { 35 | if q == "" { 36 | buf := bytes.NewBuffer(make([]byte, 0, len(body))) 37 | err := f.Format(buf, body) 38 | return []string{buf.String()}, err 39 | } 40 | doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(body)) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | results := make([]string, 0, 8) 46 | doc.Find(q).Each(func(_ int, s *goquery.Selection) { 47 | htmlResult, err := goquery.OuterHtml(s) 48 | if err == nil { 49 | results = append(results, htmlResult) 50 | } 51 | }) 52 | 53 | return results, nil 54 | } 55 | -------------------------------------------------------------------------------- /formatter/json.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | 8 | "github.com/nwidger/jsoncolor" 9 | "github.com/tidwall/gjson" 10 | ) 11 | 12 | type jsonFormatter struct { 13 | parsedBody gjson.Result 14 | TextFormatter 15 | } 16 | 17 | func (f *jsonFormatter) Format(writer io.Writer, data []byte) error { 18 | jsonFormatter := jsoncolor.NewFormatter() 19 | jsonFormatter.Indent = " " 20 | buf := bytes.NewBuffer(make([]byte, 0, len(data))) 21 | err := jsonFormatter.Format(buf, data) 22 | if err == nil { 23 | writer.Write(buf.Bytes()) 24 | return nil 25 | } 26 | return errors.New("json formatter error") 27 | } 28 | 29 | func (f *jsonFormatter) Title() string { 30 | return "[json]" 31 | } 32 | 33 | func (f *jsonFormatter) Search(q string, body []byte) ([]string, error) { 34 | if q != "" { 35 | if f.parsedBody.Type != gjson.JSON { 36 | f.parsedBody = gjson.ParseBytes(body) 37 | } 38 | searchResult := f.parsedBody.Get(q) 39 | if searchResult.Type == gjson.Null { 40 | return nil, errors.New("Invalid gjson query or no results found") 41 | } 42 | if searchResult.Type != gjson.JSON { 43 | return []string{searchResult.String()}, nil 44 | } 45 | body = []byte(searchResult.String()) 46 | } 47 | jsonFormatter := jsoncolor.NewFormatter() 48 | jsonFormatter.Indent = " " 49 | buf := bytes.NewBuffer(make([]byte, 0, len(body))) 50 | err := jsonFormatter.Format(buf, body) 51 | if err != nil { 52 | return nil, errors.New("Invalid results") 53 | } 54 | return []string{string(buf.Bytes())}, nil 55 | } 56 | -------------------------------------------------------------------------------- /formatter/text.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "io" 5 | "regexp" 6 | ) 7 | 8 | type TextFormatter struct { 9 | } 10 | 11 | func (f *TextFormatter) Format(writer io.Writer, data []byte) error { 12 | _, err := writer.Write(data) 13 | return err 14 | } 15 | 16 | func (f *TextFormatter) Title() string { 17 | return "[text]" 18 | } 19 | 20 | func (f *TextFormatter) Searchable() bool { 21 | return true 22 | } 23 | 24 | func (f *TextFormatter) Search(q string, body []byte) ([]string, error) { 25 | search_re, err := regexp.Compile(q) 26 | if err != nil { 27 | return nil, err 28 | } 29 | ret := make([]string, 0, 16) 30 | for _, match := range search_re.FindAll(body, 1000) { 31 | ret = append(ret, string(match)) 32 | } 33 | return ret, nil 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/asciimoo/wuzz 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/PuerkitoBio/goquery v1.5.1 8 | github.com/alessio/shellescape v1.2.2 9 | github.com/andybalholm/cascadia v1.2.0 // indirect 10 | github.com/jroimartin/gocui v0.4.0 11 | github.com/mattn/go-colorable v0.1.6 // indirect 12 | github.com/mattn/go-runewidth v0.0.9 13 | github.com/mitchellh/go-homedir v1.1.0 14 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 15 | github.com/nwidger/jsoncolor v0.3.0 16 | github.com/stretchr/testify v1.10.0 // indirect 17 | github.com/tidwall/gjson v1.6.0 18 | github.com/tidwall/pretty v1.0.1 // indirect 19 | github.com/x86kernel/htmlcolor v0.0.0-20190529101448-c589f58466d0 20 | golang.org/x/net v0.0.0-20200602114024-627f9648deb9 21 | golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= 4 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 5 | github.com/alessio/shellescape v1.2.2 h1:8LnL+ncxhWT2TR00dfJRT25JWWrhkMZXneHVWnetDZg= 6 | github.com/alessio/shellescape v1.2.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 7 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 8 | github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE= 9 | github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 14 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 15 | github.com/jroimartin/gocui v0.4.0 h1:52jnalstgmc25FmtGcWqa0tcbMEWS6RpFLsOIO+I+E8= 16 | github.com/jroimartin/gocui v0.4.0/go.mod h1:7i7bbj99OgFHzo7kB2zPb8pXLqMBSQegY7azfqXMkyY= 17 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 18 | github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= 19 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 20 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 21 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 22 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 23 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 24 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 25 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 26 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 27 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 28 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 h1:lh3PyZvY+B9nFliSGTn5uFuqQQJGuNrD0MLCokv09ag= 29 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 30 | github.com/nwidger/jsoncolor v0.3.0 h1:VdTH8Dc0SJoq4pJ8pRxxFZW0/5Ng5akbN4YToCBJDSU= 31 | github.com/nwidger/jsoncolor v0.3.0/go.mod h1:Cs34umxLbJvgBMnVNVqhji9BhoT/N/KinHqZptQ7cf4= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 35 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 36 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 37 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 38 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 39 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 40 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 41 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 42 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 43 | github.com/tidwall/gjson v1.6.0 h1:9VEQWz6LLMUsUl6PueE49ir4Ka6CzLymOAZDxpFsTDc= 44 | github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= 45 | github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= 46 | github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= 47 | github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 48 | github.com/tidwall/pretty v1.0.1 h1:WE4RBSZ1x6McVVC8S/Md+Qse8YUv6HRObAx6ke00NY8= 49 | github.com/tidwall/pretty v1.0.1/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 50 | github.com/x86kernel/htmlcolor v0.0.0-20190529101448-c589f58466d0 h1:eViiK7U+LXJuAEcnOdp+5jIDp7j9iE2FE8YfWoLExTE= 51 | github.com/x86kernel/htmlcolor v0.0.0-20190529101448-c589f58466d0/go.mod h1:pUZuomyrQzbA0SQPSwAnDB3TgChnUMfZnSSfcAzpVh8= 52 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 53 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 54 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 55 | golang.org/x/net v0.0.0-20200602114024-627f9648deb9 h1:pNX+40auqi2JqRfOP1akLGtYcn15TUbkhwuCO3foqqM= 56 | golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 57 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 58 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 59 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 60 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 61 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 62 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y= 64 | golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 69 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 70 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 71 | -------------------------------------------------------------------------------- /keys.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jroimartin/gocui" 5 | ) 6 | 7 | var KEYS = map[string]gocui.Key{ 8 | "F1": gocui.KeyF1, 9 | "F2": gocui.KeyF2, 10 | "F3": gocui.KeyF3, 11 | "F4": gocui.KeyF4, 12 | "F5": gocui.KeyF5, 13 | "F6": gocui.KeyF6, 14 | "F7": gocui.KeyF7, 15 | "F8": gocui.KeyF8, 16 | "F9": gocui.KeyF9, 17 | "F10": gocui.KeyF10, 18 | "F11": gocui.KeyF11, 19 | "F12": gocui.KeyF12, 20 | "Insert": gocui.KeyInsert, 21 | "Delete": gocui.KeyDelete, 22 | "Home": gocui.KeyHome, 23 | "End": gocui.KeyEnd, 24 | "PageUp": gocui.KeyPgup, 25 | "PageDown": gocui.KeyPgdn, 26 | "ArrowUp": gocui.KeyArrowUp, 27 | "ArrowDown": gocui.KeyArrowDown, 28 | "ArrowLeft": gocui.KeyArrowLeft, 29 | "ArrowRight": gocui.KeyArrowRight, 30 | "CtrlTilde": gocui.KeyCtrlTilde, 31 | "Ctrl2": gocui.KeyCtrl2, 32 | "CtrlSpace": gocui.KeyCtrlSpace, 33 | "CtrlA": gocui.KeyCtrlA, 34 | "CtrlB": gocui.KeyCtrlB, 35 | "CtrlC": gocui.KeyCtrlC, 36 | "CtrlD": gocui.KeyCtrlD, 37 | "CtrlE": gocui.KeyCtrlE, 38 | "CtrlF": gocui.KeyCtrlF, 39 | "CtrlG": gocui.KeyCtrlG, 40 | "Backspace": gocui.KeyBackspace, 41 | "CtrlH": gocui.KeyCtrlH, 42 | "Tab": gocui.KeyTab, 43 | "CtrlI": gocui.KeyCtrlI, 44 | "CtrlJ": gocui.KeyCtrlJ, 45 | "CtrlK": gocui.KeyCtrlK, 46 | "CtrlL": gocui.KeyCtrlL, 47 | "Enter": gocui.KeyEnter, 48 | "CtrlM": gocui.KeyCtrlM, 49 | "CtrlN": gocui.KeyCtrlN, 50 | "CtrlO": gocui.KeyCtrlO, 51 | "CtrlP": gocui.KeyCtrlP, 52 | "CtrlQ": gocui.KeyCtrlQ, 53 | "CtrlR": gocui.KeyCtrlR, 54 | "CtrlS": gocui.KeyCtrlS, 55 | "CtrlT": gocui.KeyCtrlT, 56 | "CtrlU": gocui.KeyCtrlU, 57 | "CtrlV": gocui.KeyCtrlV, 58 | "CtrlW": gocui.KeyCtrlW, 59 | "CtrlX": gocui.KeyCtrlX, 60 | "CtrlY": gocui.KeyCtrlY, 61 | "CtrlZ": gocui.KeyCtrlZ, 62 | "Esc": gocui.KeyEsc, 63 | "CtrlLsqBracket": gocui.KeyCtrlLsqBracket, 64 | "Ctrl3": gocui.KeyCtrl3, 65 | "Ctrl4": gocui.KeyCtrl4, 66 | "CtrlBackslash": gocui.KeyCtrlBackslash, 67 | "Ctrl5": gocui.KeyCtrl5, 68 | "CtrlRsqBracket": gocui.KeyCtrlRsqBracket, 69 | "Ctrl6": gocui.KeyCtrl6, 70 | "Ctrl7": gocui.KeyCtrl7, 71 | "CtrlSlash": gocui.KeyCtrlSlash, 72 | "CtrlUnderscore": gocui.KeyCtrlUnderscore, 73 | "Space": gocui.KeySpace, 74 | "Backspace2": gocui.KeyBackspace2, 75 | "Ctrl8": gocui.KeyCtrl8, 76 | } 77 | -------------------------------------------------------------------------------- /request-headers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var REQUEST_HEADERS = []string{ 4 | "Accept", 5 | "Accept-Charset", 6 | "Accept-Encoding", 7 | "Accept-Language", 8 | "Accept-Datetime", 9 | "Authorization", 10 | "Cache-Control", 11 | "Connection", 12 | "Cookie", 13 | "Content-Length", 14 | "Content-MD5", 15 | "Content-Type", 16 | "Date", 17 | "Expect", 18 | "Forwarded", 19 | "From", 20 | "Host", 21 | "If-Match", 22 | "If-Modified-Since", 23 | "If-None-Match", 24 | "If-Range", 25 | "If-Unmodified-Since", 26 | "Max-Forwards", 27 | "Origin", 28 | "Pragma", 29 | "Proxy-Authorization", 30 | "Range", 31 | "Referer", 32 | "TE", 33 | "User-Agent", 34 | "Upgrade", 35 | "Via", 36 | "Warning", 37 | } 38 | -------------------------------------------------------------------------------- /sample-config.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | timeout = "1m" 3 | formatJSON = true 4 | insecure = false 5 | preserveScrollPosition = true 6 | followRedirects = true 7 | defaultURLScheme = "https" 8 | statusLine = "[wuzz {{.Version}}] [Response time: {{.Duration}}]" 9 | editor = "vim" 10 | 11 | # KEYBINDINGS 12 | [keys.global] 13 | CtrlR = "submit" 14 | CtrlC = "quit" 15 | CtrlS = "saveResponse" 16 | CtrlD = "deleteLine" 17 | CtrlW = "deleteWord" 18 | CtrlF = "loadRequest" 19 | CtrlE = "saveRequest" 20 | CtrlT = "toggleContextSpecificSearch" 21 | CtrlX = "clearHistory" 22 | Tab = "nextView" 23 | CtrlJ = "nextView" 24 | CtrlK = "prevView" 25 | AltH = "history" 26 | F2 = "focus url" 27 | F3 = "focus get" 28 | F4 = "focus method" 29 | F5 = "focus data" 30 | F6 = "focus headers" 31 | F7 = "focus search" 32 | F8 = "focus response-headers" 33 | F9 = "focus response-body" 34 | F11 = "redirects restriction mode" 35 | 36 | [keys.url] 37 | Enter = "submit" 38 | 39 | [keys.response-headers] 40 | ArrowUp = "scrollUp" 41 | ArrowDown = "scrollDown" 42 | PageUp = "pageUp" 43 | PageDown = "pageDown" 44 | 45 | [keys.response-body] 46 | ArrowUp = "scrollUp" 47 | ArrowDown = "scrollDown" 48 | PageUp = "pageUp" 49 | PageDown = "pageDown" 50 | 51 | [keys.help] 52 | ArrowUp = "scrollUp" 53 | ArrowDown = "scrollDown" 54 | PageUp = "pageUp" 55 | PageDown = "pageDown" 56 | -------------------------------------------------------------------------------- /status-line.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "text/template" 7 | 8 | "github.com/jroimartin/gocui" 9 | ) 10 | 11 | type StatusLine struct { 12 | tpl *template.Template 13 | } 14 | 15 | type StatusLineFunctions struct { 16 | app *App 17 | } 18 | 19 | func (_ *StatusLineFunctions) Version() string { 20 | return VERSION 21 | } 22 | 23 | func (s *StatusLineFunctions) Duration() string { 24 | if len(s.app.history) == 0 { 25 | return "" 26 | } 27 | return s.app.history[s.app.historyIndex].Duration.String() 28 | } 29 | 30 | func (s *StatusLineFunctions) HistorySize() string { 31 | return strconv.Itoa(len(s.app.history)) 32 | } 33 | 34 | func (s *StatusLineFunctions) RequestNumber() string { 35 | i := s.app.historyIndex 36 | if len(s.app.history) > 0 { 37 | i += 1 38 | } 39 | return strconv.Itoa(i) 40 | } 41 | 42 | func (s *StatusLineFunctions) SearchType() string { 43 | if len(s.app.history) > 0 && !s.app.history[s.app.historyIndex].Formatter.Searchable() { 44 | return "none" 45 | } 46 | if s.app.config.General.ContextSpecificSearch { 47 | return "response specific" 48 | } 49 | return "regex" 50 | } 51 | 52 | func (s *StatusLine) Update(v *gocui.View, a *App) { 53 | v.Clear() 54 | err := s.tpl.Execute(v, &StatusLineFunctions{app: a}) 55 | if err != nil { 56 | fmt.Fprintf(v, "StatusLine update error: %v", err) 57 | } 58 | } 59 | 60 | func (s *StatusLineFunctions) DisableRedirect() string { 61 | if s.app.config.General.FollowRedirects { 62 | return "" 63 | } 64 | return "Activated" 65 | } 66 | 67 | func NewStatusLine(format string) (*StatusLine, error) { 68 | tpl, err := template.New("status line").Parse(format) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return &StatusLine{ 73 | tpl: tpl, 74 | }, nil 75 | } 76 | -------------------------------------------------------------------------------- /wuzz.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/tls" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "mime/multipart" 14 | "net/http" 15 | "net/url" 16 | "os" 17 | "path" 18 | "regexp" 19 | "runtime" 20 | "sort" 21 | "strconv" 22 | "strings" 23 | "time" 24 | 25 | "golang.org/x/net/proxy" 26 | 27 | "github.com/asciimoo/wuzz/config" 28 | "github.com/asciimoo/wuzz/formatter" 29 | 30 | "github.com/alessio/shellescape" 31 | "github.com/jroimartin/gocui" 32 | "github.com/mattn/go-runewidth" 33 | "github.com/nsf/termbox-go" 34 | ) 35 | 36 | const VERSION = "0.5.0" 37 | 38 | const TIMEOUT_DURATION = 5 // in seconds 39 | const WINDOWS_OS = "windows" 40 | const SEARCH_PROMPT = "search> " 41 | 42 | const ( 43 | ALL_VIEWS = "" 44 | 45 | URL_VIEW = "url" 46 | URL_PARAMS_VIEW = "get" 47 | REQUEST_METHOD_VIEW = "method" 48 | REQUEST_DATA_VIEW = "data" 49 | REQUEST_HEADERS_VIEW = "headers" 50 | STATUSLINE_VIEW = "status-line" 51 | SEARCH_VIEW = "search" 52 | RESPONSE_HEADERS_VIEW = "response-headers" 53 | RESPONSE_BODY_VIEW = "response-body" 54 | 55 | SEARCH_PROMPT_VIEW = "prompt" 56 | POPUP_VIEW = "popup_view" 57 | AUTOCOMPLETE_VIEW = "autocomplete_view" 58 | ERROR_VIEW = "error_view" 59 | HISTORY_VIEW = "history" 60 | SAVE_DIALOG_VIEW = "save-dialog" 61 | SAVE_RESPONSE_DIALOG_VIEW = "save-response-dialog" 62 | LOAD_REQUEST_DIALOG_VIEW = "load-request-dialog" 63 | SAVE_REQUEST_FORMAT_DIALOG_VIEW = "save-request-format-dialog" 64 | SAVE_REQUEST_DIALOG_VIEW = "save-request-dialog" 65 | SAVE_RESULT_VIEW = "save-result" 66 | METHOD_LIST_VIEW = "method-list" 67 | HELP_VIEW = "help" 68 | ) 69 | 70 | var VIEW_TITLES = map[string]string{ 71 | POPUP_VIEW: "Info", 72 | ERROR_VIEW: "Error", 73 | HISTORY_VIEW: "History", 74 | SAVE_RESPONSE_DIALOG_VIEW: "Save Response (enter to submit, ctrl+q to cancel)", 75 | LOAD_REQUEST_DIALOG_VIEW: "Load Request (enter to submit, ctrl+q to cancel)", 76 | SAVE_REQUEST_DIALOG_VIEW: "Save Request (enter to submit, ctrl+q to cancel)", 77 | SAVE_REQUEST_FORMAT_DIALOG_VIEW: "Choose export format", 78 | SAVE_RESULT_VIEW: "Save Result (press enter to close)", 79 | METHOD_LIST_VIEW: "Methods", 80 | HELP_VIEW: "Help", 81 | } 82 | 83 | type position struct { 84 | // value = prc * MAX + abs 85 | pct float32 86 | abs int 87 | } 88 | 89 | type viewPosition struct { 90 | x0, y0, x1, y1 position 91 | } 92 | 93 | var VIEW_POSITIONS = map[string]viewPosition{ 94 | URL_VIEW: { 95 | position{0.0, 0}, 96 | position{0.0, 0}, 97 | position{1.0, -2}, 98 | position{0.0, 3}}, 99 | URL_PARAMS_VIEW: { 100 | position{0.0, 0}, 101 | position{0.0, 3}, 102 | position{0.3, 0}, 103 | position{0.25, 0}}, 104 | REQUEST_METHOD_VIEW: { 105 | position{0.0, 0}, 106 | position{0.25, 0}, 107 | position{0.3, 0}, 108 | position{0.25, 2}}, 109 | REQUEST_DATA_VIEW: { 110 | position{0.0, 0}, 111 | position{0.25, 2}, 112 | position{0.3, 0}, 113 | position{0.5, 1}}, 114 | REQUEST_HEADERS_VIEW: { 115 | position{0.0, 0}, 116 | position{0.5, 1}, 117 | position{0.3, 0}, 118 | position{1.0, -3}}, 119 | RESPONSE_HEADERS_VIEW: { 120 | position{0.3, 0}, 121 | position{0.0, 3}, 122 | position{1.0, -2}, 123 | position{0.25, 2}}, 124 | RESPONSE_BODY_VIEW: { 125 | position{0.3, 0}, 126 | position{0.25, 2}, 127 | position{1.0, -2}, 128 | position{1.0, -3}}, 129 | STATUSLINE_VIEW: { 130 | position{0.0, -1}, 131 | position{1.0, -4}, 132 | position{1.0, 0}, 133 | position{1.0, -1}}, 134 | SEARCH_VIEW: { 135 | position{0.0, 7}, 136 | position{1.0, -3}, 137 | position{1.0, -1}, 138 | position{1.0, -1}}, 139 | ERROR_VIEW: { 140 | position{0.0, 0}, 141 | position{0.0, 0}, 142 | position{1.0, -2}, 143 | position{1.0, -2}}, 144 | SEARCH_PROMPT_VIEW: { 145 | position{0.0, -1}, 146 | position{1.0, -3}, 147 | position{0.0, 8}, 148 | position{1.0, -1}}, 149 | POPUP_VIEW: { 150 | position{0.5, -9999}, // set before usage using len(msg) 151 | position{0.5, -1}, 152 | position{0.5, -9999}, // set before usage using len(msg) 153 | position{0.5, 1}}, 154 | AUTOCOMPLETE_VIEW: { 155 | position{0, -9999}, 156 | position{0, -9999}, 157 | position{0, -9999}, 158 | position{0, -9999}}, 159 | } 160 | 161 | type viewProperties struct { 162 | title string 163 | frame bool 164 | editable bool 165 | wrap bool 166 | editor gocui.Editor 167 | text string 168 | } 169 | 170 | var VIEW_PROPERTIES = map[string]viewProperties{ 171 | URL_VIEW: { 172 | title: "URL - press F1 for help", 173 | frame: true, 174 | editable: true, 175 | wrap: false, 176 | editor: &singleLineEditor{&defaultEditor}, 177 | }, 178 | URL_PARAMS_VIEW: { 179 | title: "URL params", 180 | frame: true, 181 | editable: true, 182 | wrap: false, 183 | editor: &defaultEditor, 184 | }, 185 | REQUEST_METHOD_VIEW: { 186 | title: "Method", 187 | frame: true, 188 | editable: true, 189 | wrap: false, 190 | editor: &singleLineEditor{&defaultEditor}, 191 | text: DEFAULT_METHOD, 192 | }, 193 | REQUEST_DATA_VIEW: { 194 | title: "Request data (POST/PUT/PATCH)", 195 | frame: true, 196 | editable: true, 197 | wrap: false, 198 | editor: &defaultEditor, 199 | }, 200 | REQUEST_HEADERS_VIEW: { 201 | title: "Request headers", 202 | frame: true, 203 | editable: true, 204 | wrap: false, 205 | editor: &AutocompleteEditor{&defaultEditor, func(str string) []string { 206 | return completeFromSlice(str, REQUEST_HEADERS) 207 | }, []string{}, false}, 208 | }, 209 | RESPONSE_HEADERS_VIEW: { 210 | title: "Response headers", 211 | frame: true, 212 | editable: true, 213 | wrap: true, 214 | editor: nil, // should be set using a.getViewEditor(g) 215 | }, 216 | RESPONSE_BODY_VIEW: { 217 | title: "Response body", 218 | frame: true, 219 | editable: true, 220 | wrap: true, 221 | editor: nil, // should be set using a.getViewEditor(g) 222 | }, 223 | SEARCH_VIEW: { 224 | title: "", 225 | frame: false, 226 | editable: true, 227 | wrap: false, 228 | editor: &singleLineEditor{&SearchEditor{&defaultEditor}}, 229 | }, 230 | STATUSLINE_VIEW: { 231 | title: "", 232 | frame: false, 233 | editable: false, 234 | wrap: false, 235 | editor: nil, 236 | text: "", 237 | }, 238 | SEARCH_PROMPT_VIEW: { 239 | title: "", 240 | frame: false, 241 | editable: false, 242 | wrap: false, 243 | editor: nil, 244 | text: SEARCH_PROMPT, 245 | }, 246 | POPUP_VIEW: { 247 | title: "Info", 248 | frame: true, 249 | editable: false, 250 | wrap: false, 251 | editor: nil, 252 | }, 253 | AUTOCOMPLETE_VIEW: { 254 | title: "", 255 | frame: false, 256 | editable: false, 257 | wrap: false, 258 | editor: nil, 259 | }, 260 | } 261 | 262 | var METHODS = []string{ 263 | http.MethodGet, 264 | http.MethodPost, 265 | http.MethodPut, 266 | http.MethodDelete, 267 | http.MethodPatch, 268 | http.MethodOptions, 269 | http.MethodTrace, 270 | http.MethodConnect, 271 | http.MethodHead, 272 | } 273 | 274 | var EXPORT_FORMATS = []struct { 275 | name string 276 | export func(r Request) []byte 277 | }{ 278 | { 279 | name: "JSON", 280 | export: exportJSON, 281 | }, 282 | { 283 | name: "curl", 284 | export: exportCurl, 285 | }, 286 | } 287 | 288 | const DEFAULT_METHOD = http.MethodGet 289 | 290 | var DEFAULT_FORMATTER = &formatter.TextFormatter{} 291 | 292 | var CLIENT = &http.Client{ 293 | Timeout: time.Duration(TIMEOUT_DURATION * time.Second), 294 | } 295 | var TRANSPORT = &http.Transport{ 296 | Proxy: http.ProxyFromEnvironment, 297 | } 298 | 299 | var VIEWS = []string{ 300 | URL_VIEW, 301 | URL_PARAMS_VIEW, 302 | REQUEST_METHOD_VIEW, 303 | REQUEST_DATA_VIEW, 304 | REQUEST_HEADERS_VIEW, 305 | SEARCH_VIEW, 306 | RESPONSE_HEADERS_VIEW, 307 | RESPONSE_BODY_VIEW, 308 | } 309 | 310 | var TLS_VERSIONS = map[string]uint16{ 311 | "SSL3.0": tls.VersionSSL30, 312 | "TLS1.0": tls.VersionTLS10, 313 | "TLS1.1": tls.VersionTLS11, 314 | "TLS1.2": tls.VersionTLS12, 315 | } 316 | 317 | var defaultEditor ViewEditor 318 | 319 | const ( 320 | MIN_WIDTH = 60 321 | MIN_HEIGHT = 20 322 | ) 323 | 324 | type Request struct { 325 | Url string 326 | Method string 327 | GetParams string 328 | Data string 329 | Headers string 330 | ResponseHeaders string 331 | RawResponseBody []byte 332 | ContentType string 333 | Duration time.Duration 334 | Formatter formatter.ResponseFormatter 335 | } 336 | 337 | type App struct { 338 | viewIndex int 339 | historyIndex int 340 | currentPopup string 341 | history []*Request 342 | config *config.Config 343 | statusLine *StatusLine 344 | } 345 | 346 | type ViewEditor struct { 347 | app *App 348 | g *gocui.Gui 349 | backTabEscape bool 350 | origEditor gocui.Editor 351 | } 352 | 353 | type AutocompleteEditor struct { 354 | wuzzEditor *ViewEditor 355 | completions func(string) []string 356 | currentCompletions []string 357 | isAutocompleting bool 358 | } 359 | 360 | type SearchEditor struct { 361 | wuzzEditor *ViewEditor 362 | } 363 | 364 | // The singleLineEditor removes multi lines capabilities 365 | type singleLineEditor struct { 366 | wuzzEditor gocui.Editor 367 | } 368 | 369 | func init() { 370 | TRANSPORT.DisableCompression = true 371 | CLIENT.Transport = TRANSPORT 372 | } 373 | 374 | // Editor funcs 375 | 376 | func (e *ViewEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 377 | // handle back-tab (\033[Z) sequence 378 | if e.backTabEscape { 379 | if ch == 'Z' { 380 | e.app.PrevView(e.g, nil) 381 | e.backTabEscape = false 382 | return 383 | } else { 384 | e.origEditor.Edit(v, 0, '[', gocui.ModAlt) 385 | } 386 | } 387 | if ch == '[' && mod == gocui.ModAlt { 388 | e.backTabEscape = true 389 | return 390 | } 391 | 392 | // disable infinite down scroll 393 | if key == gocui.KeyArrowDown && mod == gocui.ModNone { 394 | _, cY := v.Cursor() 395 | _, err := v.Line(cY) 396 | if err != nil { 397 | return 398 | } 399 | } 400 | 401 | e.origEditor.Edit(v, key, ch, mod) 402 | } 403 | 404 | var symbolPattern = regexp.MustCompile("[a-zA-Z0-9-]+$") 405 | 406 | func getLastSymbol(str string) string { 407 | return symbolPattern.FindString(str) 408 | } 409 | 410 | func completeFromSlice(str string, completions []string) []string { 411 | completed := []string{} 412 | if str == "" || strings.TrimRight(str, " \n") != str { 413 | return completed 414 | } 415 | for _, completion := range completions { 416 | if strings.HasPrefix(completion, str) && str != completion { 417 | completed = append(completed, completion) 418 | } 419 | } 420 | return completed 421 | } 422 | 423 | func (e *AutocompleteEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 424 | if key != gocui.KeyEnter { 425 | e.wuzzEditor.Edit(v, key, ch, mod) 426 | } 427 | 428 | cx, cy := v.Cursor() 429 | line, err := v.Line(cy) 430 | trimmedLine := line[:cx] 431 | 432 | if err != nil { 433 | e.wuzzEditor.Edit(v, key, ch, mod) 434 | return 435 | } 436 | 437 | lastSymbol := getLastSymbol(trimmedLine) 438 | if key == gocui.KeyEnter && e.isAutocompleting { 439 | currentCompletion := e.currentCompletions[0] 440 | shouldDelete := true 441 | if len(e.currentCompletions) == 1 { 442 | shouldDelete = false 443 | } 444 | 445 | if shouldDelete { 446 | for range lastSymbol { 447 | v.EditDelete(true) 448 | } 449 | } 450 | for _, char := range currentCompletion { 451 | v.EditWrite(char) 452 | } 453 | closeAutocomplete(e.wuzzEditor.g) 454 | e.isAutocompleting = false 455 | return 456 | } else if key == gocui.KeyEnter { 457 | e.wuzzEditor.Edit(v, key, ch, mod) 458 | } 459 | 460 | closeAutocomplete(e.wuzzEditor.g) 461 | e.isAutocompleting = false 462 | 463 | completions := e.completions(lastSymbol) 464 | e.currentCompletions = completions 465 | 466 | cx, cy = v.Cursor() 467 | sx, _ := v.Size() 468 | ox, oy, _, _, _ := e.wuzzEditor.g.ViewPosition(v.Name()) 469 | 470 | maxWidth := sx - cx 471 | maxHeight := 10 472 | 473 | if len(completions) > 0 { 474 | comps := completions 475 | x := ox + cx 476 | y := oy + cy 477 | if len(comps) == 1 { 478 | comps[0] = comps[0][len(lastSymbol):] 479 | } else { 480 | y += 1 481 | x -= len(lastSymbol) 482 | maxWidth += len(lastSymbol) 483 | } 484 | showAutocomplete(comps, x, y, maxWidth, maxHeight, e.wuzzEditor.g) 485 | e.isAutocompleting = true 486 | } 487 | } 488 | 489 | func (e *SearchEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 490 | e.wuzzEditor.Edit(v, key, ch, mod) 491 | e.wuzzEditor.g.Update(func(g *gocui.Gui) error { 492 | e.wuzzEditor.app.PrintBody(g) 493 | return nil 494 | }) 495 | } 496 | 497 | // The singleLineEditor removes multi lines capabilities 498 | func (e singleLineEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 499 | switch { 500 | case (ch != 0 || key == gocui.KeySpace) && mod == 0: 501 | e.wuzzEditor.Edit(v, key, ch, mod) 502 | // At the end of the line the default gcui editor adds a whitespace 503 | // Force him to remove 504 | ox, _ := v.Cursor() 505 | if ox > 1 && ox >= len(v.Buffer())-2 { 506 | v.EditDelete(false) 507 | } 508 | return 509 | case key == gocui.KeyEnter: 510 | return 511 | case key == gocui.KeyArrowRight: 512 | ox, _ := v.Cursor() 513 | if ox >= len(v.Buffer())-1 { 514 | return 515 | } 516 | case key == gocui.KeyHome || key == gocui.KeyArrowUp: 517 | v.SetCursor(0, 0) 518 | v.SetOrigin(0, 0) 519 | return 520 | case key == gocui.KeyEnd || key == gocui.KeyArrowDown: 521 | width, _ := v.Size() 522 | lineWidth := len(v.Buffer()) - 1 523 | if lineWidth > width { 524 | v.SetOrigin(lineWidth-width, 0) 525 | lineWidth = width - 1 526 | } 527 | v.SetCursor(lineWidth, 0) 528 | return 529 | } 530 | e.wuzzEditor.Edit(v, key, ch, mod) 531 | } 532 | 533 | // 534 | 535 | func (a *App) getResponseViewEditor(g *gocui.Gui) gocui.Editor { 536 | return &ViewEditor{a, g, false, gocui.EditorFunc(func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 537 | return 538 | })} 539 | } 540 | 541 | func (p position) getCoordinate(max int) int { 542 | return int(p.pct*float32(max)) + p.abs 543 | } 544 | 545 | func setView(g *gocui.Gui, viewName string) (*gocui.View, error) { 546 | maxX, maxY := g.Size() 547 | position := VIEW_POSITIONS[viewName] 548 | return g.SetView(viewName, 549 | position.x0.getCoordinate(maxX+1), 550 | position.y0.getCoordinate(maxY+1), 551 | position.x1.getCoordinate(maxX+1), 552 | position.y1.getCoordinate(maxY+1)) 553 | } 554 | 555 | func setViewProperties(v *gocui.View, name string) { 556 | v.Title = VIEW_PROPERTIES[name].title 557 | v.Frame = VIEW_PROPERTIES[name].frame 558 | v.Editable = VIEW_PROPERTIES[name].editable 559 | v.Wrap = VIEW_PROPERTIES[name].wrap 560 | v.Editor = VIEW_PROPERTIES[name].editor 561 | setViewTextAndCursor(v, VIEW_PROPERTIES[name].text) 562 | } 563 | 564 | func (a *App) Layout(g *gocui.Gui) error { 565 | maxX, maxY := g.Size() 566 | 567 | if maxX < MIN_WIDTH || maxY < MIN_HEIGHT { 568 | if v, err := setView(g, ERROR_VIEW); err != nil { 569 | if err != gocui.ErrUnknownView { 570 | return err 571 | } 572 | setViewDefaults(v) 573 | v.Title = VIEW_TITLES[ERROR_VIEW] 574 | g.Cursor = false 575 | fmt.Fprintln(v, "Terminal is too small") 576 | } 577 | return nil 578 | } 579 | if _, err := g.View(ERROR_VIEW); err == nil { 580 | g.DeleteView(ERROR_VIEW) 581 | g.Cursor = true 582 | a.setView(g) 583 | } 584 | 585 | for _, name := range []string{RESPONSE_HEADERS_VIEW, RESPONSE_BODY_VIEW} { 586 | vp := VIEW_PROPERTIES[name] 587 | vp.editor = a.getResponseViewEditor(g) 588 | VIEW_PROPERTIES[name] = vp 589 | } 590 | 591 | if a.config.General.DefaultURLScheme != "" && !strings.HasSuffix(a.config.General.DefaultURLScheme, "://") { 592 | p := VIEW_PROPERTIES[URL_VIEW] 593 | p.text = a.config.General.DefaultURLScheme + "://" 594 | VIEW_PROPERTIES[URL_VIEW] = p 595 | } 596 | 597 | for _, name := range []string{ 598 | URL_VIEW, 599 | URL_PARAMS_VIEW, 600 | REQUEST_METHOD_VIEW, 601 | REQUEST_DATA_VIEW, 602 | REQUEST_HEADERS_VIEW, 603 | RESPONSE_HEADERS_VIEW, 604 | RESPONSE_BODY_VIEW, 605 | STATUSLINE_VIEW, 606 | SEARCH_PROMPT_VIEW, 607 | SEARCH_VIEW, 608 | } { 609 | if v, err := setView(g, name); err != nil { 610 | if err != gocui.ErrUnknownView { 611 | return err 612 | } 613 | setViewProperties(v, name) 614 | } 615 | } 616 | refreshStatusLine(a, g) 617 | 618 | return nil 619 | } 620 | 621 | func (a *App) NextView(g *gocui.Gui, v *gocui.View) error { 622 | a.viewIndex = (a.viewIndex + 1) % len(VIEWS) 623 | return a.setView(g) 624 | } 625 | 626 | func (a *App) PrevView(g *gocui.Gui, v *gocui.View) error { 627 | a.viewIndex = (a.viewIndex - 1 + len(VIEWS)) % len(VIEWS) 628 | return a.setView(g) 629 | } 630 | 631 | func (a *App) setView(g *gocui.Gui) error { 632 | a.closePopup(g, a.currentPopup) 633 | _, err := g.SetCurrentView(VIEWS[a.viewIndex]) 634 | return err 635 | } 636 | 637 | func (a *App) setViewByName(g *gocui.Gui, name string) error { 638 | for i, v := range VIEWS { 639 | if v == name { 640 | a.viewIndex = i 641 | return a.setView(g) 642 | } 643 | } 644 | return fmt.Errorf("View not found") 645 | } 646 | 647 | func popup(g *gocui.Gui, msg string) { 648 | pos := VIEW_POSITIONS[POPUP_VIEW] 649 | pos.x0.abs = -len(msg)/2 - 1 650 | pos.x1.abs = len(msg)/2 + 1 651 | VIEW_POSITIONS[POPUP_VIEW] = pos 652 | 653 | p := VIEW_PROPERTIES[POPUP_VIEW] 654 | p.text = msg 655 | VIEW_PROPERTIES[POPUP_VIEW] = p 656 | 657 | if v, err := setView(g, POPUP_VIEW); err != nil { 658 | if err != gocui.ErrUnknownView { 659 | return 660 | } 661 | setViewProperties(v, POPUP_VIEW) 662 | g.SetViewOnTop(POPUP_VIEW) 663 | } 664 | } 665 | 666 | func minInt(x, y int) int { 667 | if x < y { 668 | return x 669 | } 670 | return y 671 | } 672 | 673 | func closeAutocomplete(g *gocui.Gui) { 674 | g.DeleteView(AUTOCOMPLETE_VIEW) 675 | } 676 | 677 | func showAutocomplete(completions []string, left, top, maxWidth, maxHeight int, g *gocui.Gui) { 678 | // Get the width of the widest completion 679 | completionsWidth := 0 680 | for _, completion := range completions { 681 | thisCompletionWidth := len(completion) 682 | if thisCompletionWidth > completionsWidth { 683 | completionsWidth = thisCompletionWidth 684 | } 685 | } 686 | 687 | // Get the width and height of the autocomplete window 688 | width := minInt(completionsWidth, maxWidth) 689 | height := minInt(len(completions), maxHeight) 690 | 691 | newPos := viewPosition{ 692 | x0: position{0, left}, 693 | y0: position{0, top}, 694 | x1: position{0, left + width + 1}, 695 | y1: position{0, top + height + 1}, 696 | } 697 | 698 | VIEW_POSITIONS[AUTOCOMPLETE_VIEW] = newPos 699 | 700 | p := VIEW_PROPERTIES[AUTOCOMPLETE_VIEW] 701 | p.text = strings.Join(completions, "\n") 702 | VIEW_PROPERTIES[AUTOCOMPLETE_VIEW] = p 703 | 704 | if v, err := setView(g, AUTOCOMPLETE_VIEW); err != nil { 705 | if err != gocui.ErrUnknownView { 706 | return 707 | } 708 | setViewProperties(v, AUTOCOMPLETE_VIEW) 709 | v.BgColor = gocui.ColorBlue 710 | v.FgColor = gocui.ColorDefault 711 | g.SetViewOnTop(AUTOCOMPLETE_VIEW) 712 | } 713 | } 714 | 715 | func writeSortedHeaders(output io.Writer, h http.Header) { 716 | hkeys := make([]string, 0, len(h)) 717 | for hname := range h { 718 | hkeys = append(hkeys, hname) 719 | } 720 | 721 | sort.Strings(hkeys) 722 | 723 | for _, hname := range hkeys { 724 | fmt.Fprintf(output, "\x1b[0;33m%v:\x1b[0;0m %v\n", hname, strings.Join(h[hname], ",")) 725 | } 726 | } 727 | 728 | func (a *App) SubmitRequest(g *gocui.Gui, _ *gocui.View) error { 729 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 730 | vrb.Clear() 731 | vrh, _ := g.View(RESPONSE_HEADERS_VIEW) 732 | vrh.Clear() 733 | popup(g, "Sending request..") 734 | 735 | var r *Request = &Request{} 736 | 737 | go func(g *gocui.Gui, a *App, r *Request) error { 738 | defer g.DeleteView(POPUP_VIEW) 739 | // parse url 740 | r.Url = getViewValue(g, URL_VIEW) 741 | u, err := url.Parse(r.Url) 742 | if err != nil { 743 | g.Update(func(g *gocui.Gui) error { 744 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 745 | fmt.Fprintf(vrb, "URL parse error: %v", err) 746 | return nil 747 | }) 748 | return nil 749 | } 750 | 751 | q, err := url.ParseQuery(strings.Replace(getViewValue(g, URL_PARAMS_VIEW), "\n", "&", -1)) 752 | if err != nil { 753 | g.Update(func(g *gocui.Gui) error { 754 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 755 | fmt.Fprintf(vrb, "Invalid GET parameters: %v", err) 756 | return nil 757 | }) 758 | return nil 759 | } 760 | originalQuery := u.Query() 761 | for k, v := range q { 762 | for _, qp := range v { 763 | originalQuery.Add(k, qp) 764 | } 765 | } 766 | u.RawQuery = originalQuery.Encode() 767 | r.GetParams = u.RawQuery 768 | 769 | // parse method 770 | r.Method = getViewValue(g, REQUEST_METHOD_VIEW) 771 | 772 | // set headers 773 | headers := http.Header{} 774 | headers.Set("User-Agent", "") 775 | r.Headers = getViewValue(g, REQUEST_HEADERS_VIEW) 776 | for _, header := range strings.Split(r.Headers, "\n") { 777 | if header != "" { 778 | header_parts := strings.SplitN(header, ": ", 2) 779 | if len(header_parts) != 2 { 780 | g.Update(func(g *gocui.Gui) error { 781 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 782 | fmt.Fprintf(vrb, "Invalid header: %v", header) 783 | return nil 784 | }) 785 | return nil 786 | } 787 | headers.Set(header_parts[0], header_parts[1]) 788 | } 789 | } 790 | 791 | var body io.Reader 792 | 793 | // parse POST/PUT/PATCH data 794 | if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch { 795 | bodyStr := getViewValue(g, REQUEST_DATA_VIEW) 796 | r.Data = bodyStr 797 | if headers.Get("Content-Type") != "multipart/form-data" { 798 | if headers.Get("Content-Type") == "application/x-www-form-urlencoded" { 799 | bodyStr = strings.Replace(bodyStr, "\n", "&", -1) 800 | } 801 | body = bytes.NewBufferString(bodyStr) 802 | } else { 803 | var bodyBytes bytes.Buffer 804 | multiWriter := multipart.NewWriter(&bodyBytes) 805 | defer multiWriter.Close() 806 | postData, err := url.ParseQuery(strings.Replace(getViewValue(g, REQUEST_DATA_VIEW), "\n", "&", -1)) 807 | if err != nil { 808 | return err 809 | } 810 | for postKey, postValues := range postData { 811 | for i := range postValues { 812 | if len([]rune(postValues[i])) > 0 && postValues[i][0] == '@' { 813 | file, err := os.Open(postValues[i][1:]) 814 | if err != nil { 815 | g.Update(func(g *gocui.Gui) error { 816 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 817 | fmt.Fprintf(vrb, "Error: %v", err) 818 | return nil 819 | }) 820 | return err 821 | } 822 | defer file.Close() 823 | fw, err := multiWriter.CreateFormFile(postKey, path.Base(postValues[i][1:])) 824 | if err != nil { 825 | return err 826 | } 827 | if _, err := io.Copy(fw, file); err != nil { 828 | return err 829 | } 830 | } else { 831 | fw, err := multiWriter.CreateFormField(postKey) 832 | if err != nil { 833 | return err 834 | } 835 | if _, err := fw.Write([]byte(postValues[i])); err != nil { 836 | return err 837 | } 838 | } 839 | } 840 | } 841 | body = bytes.NewReader(bodyBytes.Bytes()) 842 | } 843 | } 844 | 845 | // create request 846 | req, err := http.NewRequest(r.Method, u.String(), body) 847 | if err != nil { 848 | g.Update(func(g *gocui.Gui) error { 849 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 850 | fmt.Fprintf(vrb, "Request error: %v", err) 851 | return nil 852 | }) 853 | return nil 854 | } 855 | req.Header = headers 856 | 857 | // set the `Host` header 858 | if headers.Get("Host") != "" { 859 | req.Host = headers.Get("Host") 860 | } 861 | 862 | // do request 863 | start := time.Now() 864 | response, err := CLIENT.Do(req) 865 | r.Duration = time.Since(start) 866 | if err != nil { 867 | g.Update(func(g *gocui.Gui) error { 868 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 869 | fmt.Fprintf(vrb, "Response error: %v", err) 870 | return nil 871 | }) 872 | return nil 873 | } 874 | defer response.Body.Close() 875 | 876 | // extract body 877 | r.ContentType = response.Header.Get("Content-Type") 878 | if response.Header.Get("Content-Encoding") == "gzip" { 879 | reader, err := gzip.NewReader(response.Body) 880 | if err == nil { 881 | defer reader.Close() 882 | response.Body = reader 883 | } else { 884 | g.Update(func(g *gocui.Gui) error { 885 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 886 | fmt.Fprintf(vrb, "Cannot uncompress response: %v", err) 887 | return nil 888 | }) 889 | return nil 890 | } 891 | } 892 | 893 | bodyBytes, err := ioutil.ReadAll(response.Body) 894 | if err == nil { 895 | r.RawResponseBody = bodyBytes 896 | } 897 | 898 | r.Formatter = formatter.New(a.config, r.ContentType) 899 | 900 | // add to history 901 | a.history = append(a.history, r) 902 | a.historyIndex = len(a.history) - 1 903 | 904 | // render response 905 | g.Update(func(g *gocui.Gui) error { 906 | vrh, _ := g.View(RESPONSE_HEADERS_VIEW) 907 | 908 | a.PrintBody(g) 909 | 910 | // print status code 911 | status_color := 32 912 | if response.StatusCode != 200 { 913 | status_color = 31 914 | } 915 | header := &strings.Builder{} 916 | fmt.Fprintf( 917 | header, 918 | "\x1b[0;%dmHTTP/1.1 %v %v\x1b[0;0m\n", 919 | status_color, 920 | response.StatusCode, 921 | http.StatusText(response.StatusCode), 922 | ) 923 | 924 | writeSortedHeaders(header, response.Header) 925 | 926 | // According to the Go documentation, the Trailer maps trailer 927 | // keys to values in the same format as Header 928 | writeSortedHeaders(header, response.Trailer) 929 | 930 | r.ResponseHeaders = header.String() 931 | 932 | fmt.Fprint(vrh, r.ResponseHeaders) 933 | if _, err := vrh.Line(0); err != nil { 934 | vrh.SetOrigin(0, 0) 935 | } 936 | 937 | return nil 938 | }) 939 | return nil 940 | }(g, a, r) 941 | 942 | return nil 943 | } 944 | 945 | func (a *App) PrintBody(g *gocui.Gui) { 946 | g.Update(func(g *gocui.Gui) error { 947 | if len(a.history) == 0 { 948 | return nil 949 | } 950 | req := a.history[a.historyIndex] 951 | if req.RawResponseBody == nil { 952 | return nil 953 | } 954 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 955 | vrb.Clear() 956 | 957 | var responseFormatter formatter.ResponseFormatter 958 | responseFormatter = req.Formatter 959 | 960 | vrb.Title = VIEW_PROPERTIES[vrb.Name()].title + " " + responseFormatter.Title() 961 | 962 | search_text := getViewValue(g, "search") 963 | if search_text == "" || !responseFormatter.Searchable() { 964 | err := responseFormatter.Format(vrb, req.RawResponseBody) 965 | if err != nil { 966 | fmt.Fprintf(vrb, "Error: cannot decode response body: %v", err) 967 | return nil 968 | } 969 | if _, err := vrb.Line(0); !a.config.General.PreserveScrollPosition || err != nil { 970 | vrb.SetOrigin(0, 0) 971 | } 972 | return nil 973 | } 974 | if !a.config.General.ContextSpecificSearch { 975 | responseFormatter = DEFAULT_FORMATTER 976 | } 977 | vrb.SetOrigin(0, 0) 978 | results, err := responseFormatter.Search(search_text, req.RawResponseBody) 979 | if err != nil { 980 | fmt.Fprint(vrb, "Search error: ", err) 981 | return nil 982 | } 983 | if len(results) == 0 { 984 | vrb.Title = "No results" 985 | fmt.Fprint(vrb, "Error: no results") 986 | return nil 987 | } 988 | vrb.Title = fmt.Sprintf("%d results", len(results)) 989 | for _, result := range results { 990 | fmt.Fprintf(vrb, "-----\n%s\n", result) 991 | } 992 | return nil 993 | }) 994 | } 995 | 996 | func parseKey(k string) (interface{}, gocui.Modifier, error) { 997 | mod := gocui.ModNone 998 | if strings.Index(k, "Alt") == 0 { 999 | mod = gocui.ModAlt 1000 | k = k[3:] 1001 | } 1002 | switch len(k) { 1003 | case 0: 1004 | return 0, 0, errors.New("Empty key string") 1005 | case 1: 1006 | if mod != gocui.ModNone { 1007 | k = strings.ToLower(k) 1008 | } 1009 | return rune(k[0]), mod, nil 1010 | } 1011 | 1012 | key, found := KEYS[k] 1013 | if !found { 1014 | return 0, 0, fmt.Errorf("Unknown key: %v", k) 1015 | } 1016 | return key, mod, nil 1017 | } 1018 | 1019 | func (a *App) setKey(g *gocui.Gui, keyStr, commandStr, viewName string) error { 1020 | if commandStr == "" { 1021 | return nil 1022 | } 1023 | key, mod, err := parseKey(keyStr) 1024 | if err != nil { 1025 | return err 1026 | } 1027 | commandParts := strings.SplitN(commandStr, " ", 2) 1028 | command := commandParts[0] 1029 | var commandArgs string 1030 | if len(commandParts) == 2 { 1031 | commandArgs = commandParts[1] 1032 | } 1033 | keyFnGen, found := COMMANDS[command] 1034 | if !found { 1035 | return fmt.Errorf("Unknown command: %v", command) 1036 | } 1037 | keyFn := keyFnGen(commandArgs, a) 1038 | if err := g.SetKeybinding(viewName, key, mod, keyFn); err != nil { 1039 | return fmt.Errorf("Failed to set key '%v': %v", keyStr, err) 1040 | } 1041 | return nil 1042 | } 1043 | 1044 | func (a *App) printViewKeybindings(v io.Writer, viewName string) { 1045 | keys, found := a.config.Keys[viewName] 1046 | if !found { 1047 | return 1048 | } 1049 | mk := make([]string, len(keys)) 1050 | i := 0 1051 | for k := range keys { 1052 | mk[i] = k 1053 | i++ 1054 | } 1055 | sort.Strings(mk) 1056 | fmt.Fprintf(v, "\n %v\n", viewName) 1057 | for _, key := range mk { 1058 | fmt.Fprintf(v, " %-15v %v\n", key, keys[key]) 1059 | } 1060 | } 1061 | 1062 | func (a *App) SetKeys(g *gocui.Gui) error { 1063 | // load config keybindings 1064 | for viewName, keys := range a.config.Keys { 1065 | if viewName == "global" { 1066 | viewName = ALL_VIEWS 1067 | } 1068 | for keyStr, commandStr := range keys { 1069 | if err := a.setKey(g, keyStr, commandStr, viewName); err != nil { 1070 | return err 1071 | } 1072 | } 1073 | } 1074 | 1075 | g.SetKeybinding(ALL_VIEWS, gocui.KeyF1, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1076 | if a.currentPopup == HELP_VIEW { 1077 | a.closePopup(g, HELP_VIEW) 1078 | return nil 1079 | } 1080 | 1081 | help, err := a.CreatePopupView(HELP_VIEW, 60, 40, g) 1082 | if err != nil { 1083 | return err 1084 | } 1085 | help.Title = VIEW_TITLES[HELP_VIEW] 1086 | help.Highlight = false 1087 | fmt.Fprint(help, "Keybindings:\n") 1088 | a.printViewKeybindings(help, "global") 1089 | for _, viewName := range VIEWS { 1090 | if _, found := a.config.Keys[viewName]; !found { 1091 | continue 1092 | } 1093 | a.printViewKeybindings(help, viewName) 1094 | } 1095 | g.SetViewOnTop(HELP_VIEW) 1096 | g.SetCurrentView(HELP_VIEW) 1097 | return nil 1098 | }) 1099 | 1100 | g.SetKeybinding(ALL_VIEWS, gocui.KeyF11, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1101 | a.config.General.FollowRedirects = !a.config.General.FollowRedirects 1102 | refreshStatusLine(a, g) 1103 | return nil 1104 | }) 1105 | 1106 | g.SetKeybinding(REQUEST_METHOD_VIEW, gocui.KeyEnter, gocui.ModNone, a.ToggleMethodList) 1107 | 1108 | cursDown := func(g *gocui.Gui, v *gocui.View) error { 1109 | cx, cy := v.Cursor() 1110 | v.SetCursor(cx, cy+1) 1111 | return nil 1112 | } 1113 | cursUp := func(g *gocui.Gui, v *gocui.View) error { 1114 | cx, cy := v.Cursor() 1115 | if cy > 0 { 1116 | cy -= 1 1117 | } 1118 | v.SetCursor(cx, cy) 1119 | return nil 1120 | } 1121 | // history key bindings 1122 | g.SetKeybinding(HISTORY_VIEW, gocui.KeyArrowDown, gocui.ModNone, cursDown) 1123 | g.SetKeybinding(HISTORY_VIEW, gocui.KeyArrowUp, gocui.ModNone, cursUp) 1124 | g.SetKeybinding(HISTORY_VIEW, gocui.KeyEnter, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1125 | _, cy := v.Cursor() 1126 | // TODO error 1127 | if len(a.history) <= cy { 1128 | return nil 1129 | } 1130 | a.restoreRequest(g, cy) 1131 | return nil 1132 | }) 1133 | 1134 | // method key bindings 1135 | g.SetKeybinding(REQUEST_METHOD_VIEW, gocui.KeyArrowDown, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1136 | value := strings.TrimSpace(v.Buffer()) 1137 | for i, val := range METHODS { 1138 | if val == value && i != len(METHODS)-1 { 1139 | setViewTextAndCursor(v, METHODS[i+1]) 1140 | } 1141 | } 1142 | return nil 1143 | }) 1144 | 1145 | g.SetKeybinding(REQUEST_METHOD_VIEW, gocui.KeyArrowUp, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1146 | value := strings.TrimSpace(v.Buffer()) 1147 | for i, val := range METHODS { 1148 | if val == value && i != 0 { 1149 | setViewTextAndCursor(v, METHODS[i-1]) 1150 | } 1151 | } 1152 | return nil 1153 | }) 1154 | g.SetKeybinding(METHOD_LIST_VIEW, gocui.KeyArrowDown, gocui.ModNone, cursDown) 1155 | g.SetKeybinding(METHOD_LIST_VIEW, gocui.KeyArrowUp, gocui.ModNone, cursUp) 1156 | g.SetKeybinding(METHOD_LIST_VIEW, gocui.KeyEnter, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1157 | _, cy := v.Cursor() 1158 | v, _ = g.View(REQUEST_METHOD_VIEW) 1159 | setViewTextAndCursor(v, METHODS[cy]) 1160 | a.closePopup(g, METHOD_LIST_VIEW) 1161 | return nil 1162 | }) 1163 | g.SetKeybinding(SAVE_REQUEST_FORMAT_DIALOG_VIEW, gocui.KeyArrowDown, gocui.ModNone, cursDown) 1164 | g.SetKeybinding(SAVE_REQUEST_FORMAT_DIALOG_VIEW, gocui.KeyArrowUp, gocui.ModNone, cursUp) 1165 | 1166 | g.SetKeybinding(SAVE_DIALOG_VIEW, gocui.KeyCtrlQ, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1167 | a.closePopup(g, SAVE_DIALOG_VIEW) 1168 | return nil 1169 | }) 1170 | 1171 | g.SetKeybinding(SAVE_RESULT_VIEW, gocui.KeyEnter, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1172 | a.closePopup(g, SAVE_RESULT_VIEW) 1173 | return nil 1174 | }) 1175 | return nil 1176 | } 1177 | 1178 | func (a *App) closePopup(g *gocui.Gui, viewname string) { 1179 | _, err := g.View(viewname) 1180 | if err == nil { 1181 | a.currentPopup = "" 1182 | g.DeleteView(viewname) 1183 | g.SetCurrentView(VIEWS[a.viewIndex%len(VIEWS)]) 1184 | g.Cursor = true 1185 | } 1186 | } 1187 | 1188 | // CreatePopupView create a popup like view 1189 | func (a *App) CreatePopupView(name string, width, height int, g *gocui.Gui) (v *gocui.View, err error) { 1190 | // Remove any concurrent popup 1191 | a.closePopup(g, a.currentPopup) 1192 | 1193 | g.Cursor = false 1194 | maxX, maxY := g.Size() 1195 | if height > maxY-4 { 1196 | height = maxY - 4 1197 | } 1198 | if width > maxX-4 { 1199 | width = maxX - 4 1200 | } 1201 | v, err = g.SetView(name, maxX/2-width/2-1, maxY/2-height/2-1, maxX/2+width/2, maxY/2+height/2+1) 1202 | if err != nil && err != gocui.ErrUnknownView { 1203 | return 1204 | } 1205 | err = nil 1206 | v.Wrap = false 1207 | v.Frame = true 1208 | v.Highlight = true 1209 | v.SelFgColor = gocui.ColorYellow 1210 | v.SelBgColor = gocui.ColorDefault 1211 | a.currentPopup = name 1212 | return 1213 | } 1214 | 1215 | func (a *App) LoadRequest(g *gocui.Gui, loadLocation string) (err error) { 1216 | requestJson, ioErr := ioutil.ReadFile(loadLocation) 1217 | if ioErr != nil { 1218 | g.Update(func(g *gocui.Gui) error { 1219 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 1220 | vrb.Clear() 1221 | fmt.Fprintf(vrb, "File reading error: %v", ioErr) 1222 | return nil 1223 | }) 1224 | return nil 1225 | } 1226 | 1227 | var requestMap map[string]string 1228 | jsonErr := json.Unmarshal(requestJson, &requestMap) 1229 | if jsonErr != nil { 1230 | g.Update(func(g *gocui.Gui) error { 1231 | vrb, _ := g.View(RESPONSE_BODY_VIEW) 1232 | vrb.Clear() 1233 | fmt.Fprintf(vrb, "JSON decoding error: %v", jsonErr) 1234 | return nil 1235 | }) 1236 | return nil 1237 | } 1238 | 1239 | var v *gocui.View 1240 | url, exists := requestMap[URL_VIEW] 1241 | if exists { 1242 | v, _ = g.View(URL_VIEW) 1243 | setViewTextAndCursor(v, url) 1244 | } 1245 | 1246 | method, exists := requestMap[REQUEST_METHOD_VIEW] 1247 | if exists { 1248 | v, _ = g.View(REQUEST_METHOD_VIEW) 1249 | setViewTextAndCursor(v, method) 1250 | } 1251 | 1252 | params, exists := requestMap[URL_PARAMS_VIEW] 1253 | if exists { 1254 | v, _ = g.View(URL_PARAMS_VIEW) 1255 | setViewTextAndCursor(v, params) 1256 | } 1257 | 1258 | data, exists := requestMap[REQUEST_DATA_VIEW] 1259 | if exists { 1260 | g.Update(func(g *gocui.Gui) error { 1261 | v, _ = g.View(REQUEST_DATA_VIEW) 1262 | v.Clear() 1263 | fmt.Fprintf(v, "%v", data) 1264 | return nil 1265 | }) 1266 | } 1267 | 1268 | headers, exists := requestMap[REQUEST_HEADERS_VIEW] 1269 | if exists { 1270 | v, _ = g.View(REQUEST_HEADERS_VIEW) 1271 | setViewTextAndCursor(v, headers) 1272 | } 1273 | return nil 1274 | } 1275 | 1276 | func (a *App) ToggleHistory(g *gocui.Gui, _ *gocui.View) (err error) { 1277 | // Destroy if present 1278 | if a.currentPopup == HISTORY_VIEW { 1279 | a.closePopup(g, HISTORY_VIEW) 1280 | return 1281 | } 1282 | 1283 | history, err := a.CreatePopupView(HISTORY_VIEW, 100, len(a.history), g) 1284 | if err != nil { 1285 | return 1286 | } 1287 | 1288 | history.Title = VIEW_TITLES[HISTORY_VIEW] 1289 | 1290 | if len(a.history) == 0 { 1291 | setViewTextAndCursor(history, "[!] No items in history") 1292 | return 1293 | } 1294 | for i, r := range a.history { 1295 | req_str := fmt.Sprintf("[%02d] %v %v", i, r.Method, r.Url) 1296 | if r.GetParams != "" { 1297 | req_str += fmt.Sprintf("?%v", strings.Replace(r.GetParams, "\n", "&", -1)) 1298 | } 1299 | if r.Data != "" { 1300 | req_str += fmt.Sprintf(" %v", strings.Replace(r.Data, "\n", "&", -1)) 1301 | } 1302 | if r.Headers != "" { 1303 | req_str += fmt.Sprintf(" %v", strings.Replace(r.Headers, "\n", ";", -1)) 1304 | } 1305 | fmt.Fprintln(history, req_str) 1306 | } 1307 | g.SetViewOnTop(HISTORY_VIEW) 1308 | g.SetCurrentView(HISTORY_VIEW) 1309 | history.SetCursor(0, a.historyIndex) 1310 | return 1311 | } 1312 | 1313 | func (a *App) SaveRequest(g *gocui.Gui, _ *gocui.View) (err error) { 1314 | // Destroy if present 1315 | if a.currentPopup == SAVE_REQUEST_FORMAT_DIALOG_VIEW { 1316 | a.closePopup(g, SAVE_REQUEST_FORMAT_DIALOG_VIEW) 1317 | return 1318 | } 1319 | // Create the view listing the possible formats 1320 | popup, err := a.CreatePopupView(SAVE_REQUEST_FORMAT_DIALOG_VIEW, 30, len(EXPORT_FORMATS), g) 1321 | if err != nil { 1322 | return err 1323 | } 1324 | 1325 | popup.Title = VIEW_TITLES[SAVE_REQUEST_FORMAT_DIALOG_VIEW] 1326 | 1327 | // Populate the popup witht the available formats 1328 | for _, r := range EXPORT_FORMATS { 1329 | fmt.Fprintln(popup, r.name) 1330 | } 1331 | 1332 | g.SetViewOnTop(SAVE_REQUEST_FORMAT_DIALOG_VIEW) 1333 | g.SetCurrentView(SAVE_REQUEST_FORMAT_DIALOG_VIEW) 1334 | popup.SetCursor(0, 0) 1335 | 1336 | // Bind the enter key, when the format is chosen, save the choice and open 1337 | // the save popup 1338 | g.SetKeybinding(SAVE_REQUEST_FORMAT_DIALOG_VIEW, gocui.KeyEnter, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error { 1339 | // Save the format index 1340 | _, format := v.Cursor() 1341 | // Open the Save popup 1342 | return a.OpenSaveDialog(VIEW_TITLES[SAVE_REQUEST_DIALOG_VIEW], g, 1343 | func(g *gocui.Gui, _ *gocui.View) error { 1344 | defer a.closePopup(g, SAVE_DIALOG_VIEW) 1345 | saveLocation := getViewValue(g, SAVE_DIALOG_VIEW) 1346 | 1347 | r := Request{ 1348 | Url: getViewValue(g, URL_VIEW), 1349 | Method: getViewValue(g, REQUEST_METHOD_VIEW), 1350 | GetParams: getViewValue(g, URL_PARAMS_VIEW), 1351 | Data: getViewValue(g, REQUEST_DATA_VIEW), 1352 | Headers: getViewValue(g, REQUEST_HEADERS_VIEW), 1353 | } 1354 | 1355 | // Export the request using the chosent format 1356 | request := EXPORT_FORMATS[format].export(r) 1357 | 1358 | // Write the file 1359 | ioerr := ioutil.WriteFile(saveLocation, []byte(request), 0644) 1360 | 1361 | saveResult := fmt.Sprintf("Request saved successfully in %s", EXPORT_FORMATS[format].name) 1362 | if ioerr != nil { 1363 | saveResult = "Error saving request: " + ioerr.Error() 1364 | } 1365 | viewErr := a.OpenSaveResultView(saveResult, g) 1366 | 1367 | return viewErr 1368 | }, 1369 | ) 1370 | }) 1371 | 1372 | return 1373 | } 1374 | 1375 | func (a *App) ToggleMethodList(g *gocui.Gui, _ *gocui.View) (err error) { 1376 | // Destroy if present 1377 | if a.currentPopup == METHOD_LIST_VIEW { 1378 | a.closePopup(g, METHOD_LIST_VIEW) 1379 | return 1380 | } 1381 | 1382 | method, err := a.CreatePopupView(METHOD_LIST_VIEW, 50, len(METHODS), g) 1383 | if err != nil { 1384 | return 1385 | } 1386 | method.Title = VIEW_TITLES[METHOD_LIST_VIEW] 1387 | 1388 | cur := getViewValue(g, REQUEST_METHOD_VIEW) 1389 | 1390 | for i, r := range METHODS { 1391 | fmt.Fprintln(method, r) 1392 | if cur == r { 1393 | method.SetCursor(0, i) 1394 | } 1395 | } 1396 | g.SetViewOnTop(METHOD_LIST_VIEW) 1397 | g.SetCurrentView(METHOD_LIST_VIEW) 1398 | return 1399 | } 1400 | 1401 | func (a *App) OpenSaveDialog(title string, g *gocui.Gui, save func(g *gocui.Gui, v *gocui.View) error) error { 1402 | dialog, err := a.CreatePopupView(SAVE_DIALOG_VIEW, 60, 1, g) 1403 | if err != nil { 1404 | return err 1405 | } 1406 | g.Cursor = true 1407 | 1408 | dialog.Title = title 1409 | dialog.Editable = true 1410 | dialog.Wrap = false 1411 | 1412 | currentDir, err := os.Getwd() 1413 | if err != nil { 1414 | currentDir = "" 1415 | } 1416 | currentDir += "/" 1417 | 1418 | setViewTextAndCursor(dialog, currentDir) 1419 | 1420 | g.SetViewOnTop(SAVE_DIALOG_VIEW) 1421 | g.SetCurrentView(SAVE_DIALOG_VIEW) 1422 | dialog.SetCursor(0, len(currentDir)) 1423 | g.DeleteKeybinding(SAVE_DIALOG_VIEW, gocui.KeyEnter, gocui.ModNone) 1424 | g.SetKeybinding(SAVE_DIALOG_VIEW, gocui.KeyEnter, gocui.ModNone, save) 1425 | return nil 1426 | } 1427 | 1428 | func (a *App) OpenSaveResultView(saveResult string, g *gocui.Gui) (err error) { 1429 | popupTitle := VIEW_TITLES[SAVE_RESULT_VIEW] 1430 | saveResHeight := 1 1431 | saveResWidth := len(saveResult) + 1 1432 | if len(popupTitle)+2 > saveResWidth { 1433 | saveResWidth = len(popupTitle) + 2 1434 | } 1435 | maxX, _ := g.Size() 1436 | if saveResWidth > maxX { 1437 | saveResHeight = saveResWidth/maxX + 1 1438 | saveResWidth = maxX 1439 | } 1440 | 1441 | saveResultPopup, err := a.CreatePopupView(SAVE_RESULT_VIEW, saveResWidth, saveResHeight, g) 1442 | saveResultPopup.Title = popupTitle 1443 | setViewTextAndCursor(saveResultPopup, saveResult) 1444 | g.SetViewOnTop(SAVE_RESULT_VIEW) 1445 | g.SetCurrentView(SAVE_RESULT_VIEW) 1446 | return err 1447 | } 1448 | 1449 | func (a *App) restoreRequest(g *gocui.Gui, idx int) { 1450 | if idx < 0 || idx >= len(a.history) { 1451 | return 1452 | } 1453 | a.closePopup(g, HISTORY_VIEW) 1454 | a.historyIndex = idx 1455 | r := a.history[idx] 1456 | 1457 | v, _ := g.View(URL_VIEW) 1458 | setViewTextAndCursor(v, r.Url) 1459 | 1460 | v, _ = g.View(REQUEST_METHOD_VIEW) 1461 | setViewTextAndCursor(v, r.Method) 1462 | 1463 | v, _ = g.View(URL_PARAMS_VIEW) 1464 | setViewTextAndCursor(v, r.GetParams) 1465 | 1466 | v, _ = g.View(REQUEST_DATA_VIEW) 1467 | setViewTextAndCursor(v, r.Data) 1468 | 1469 | v, _ = g.View(REQUEST_HEADERS_VIEW) 1470 | setViewTextAndCursor(v, r.Headers) 1471 | 1472 | v, _ = g.View(RESPONSE_HEADERS_VIEW) 1473 | setViewTextAndCursor(v, r.ResponseHeaders) 1474 | 1475 | a.PrintBody(g) 1476 | 1477 | } 1478 | 1479 | func (a *App) LoadConfig(configPath string) error { 1480 | if configPath == "" { 1481 | // Load config from default path 1482 | configPath = config.GetDefaultConfigLocation() 1483 | } 1484 | 1485 | // If the config file doesn't exist, load the default config 1486 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 1487 | a.config = &config.DefaultConfig 1488 | a.config.Keys = config.DefaultKeys 1489 | a.statusLine, _ = NewStatusLine(a.config.General.StatusLine) 1490 | return nil 1491 | } 1492 | 1493 | conf, err := config.LoadConfig(configPath) 1494 | if err != nil { 1495 | a.config = &config.DefaultConfig 1496 | a.config.Keys = config.DefaultKeys 1497 | return err 1498 | } 1499 | 1500 | a.config = conf 1501 | sl, err := NewStatusLine(conf.General.StatusLine) 1502 | if err != nil { 1503 | a.config = &config.DefaultConfig 1504 | a.config.Keys = config.DefaultKeys 1505 | return err 1506 | } 1507 | a.statusLine = sl 1508 | return nil 1509 | } 1510 | 1511 | func (a *App) ParseArgs(g *gocui.Gui, args []string) error { 1512 | a.Layout(g) 1513 | g.SetCurrentView(VIEWS[a.viewIndex]) 1514 | vheader, err := g.View(REQUEST_HEADERS_VIEW) 1515 | if err != nil { 1516 | return errors.New("Too small screen") 1517 | } 1518 | vheader.Clear() 1519 | vget, _ := g.View(URL_PARAMS_VIEW) 1520 | vget.Clear() 1521 | content_type := "" 1522 | set_data := false 1523 | set_method := false 1524 | set_binary_data := false 1525 | arg_index := 1 1526 | args_len := len(args) 1527 | accept_types := make([]string, 0, 8) 1528 | var body_data []string 1529 | for arg_index < args_len { 1530 | arg := args[arg_index] 1531 | switch arg { 1532 | case "-H", "--header": 1533 | if arg_index == args_len-1 { 1534 | return errors.New("No header value specified") 1535 | } 1536 | arg_index += 1 1537 | header := args[arg_index] 1538 | fmt.Fprintf(vheader, "%v\n", header) 1539 | case "-d", "--data", "--data-binary", "--data-urlencode": 1540 | if arg_index == args_len-1 { 1541 | return errors.New("No POST/PUT/PATCH value specified") 1542 | } 1543 | 1544 | arg_index += 1 1545 | set_data = true 1546 | set_binary_data = arg == "--data-binary" 1547 | arg_data := args[arg_index] 1548 | 1549 | if !set_binary_data { 1550 | content_type = "form" 1551 | } 1552 | 1553 | if arg == "--data-urlencode" { 1554 | // TODO: Replace with `url.PathEscape(..)` in Go 1.8 1555 | arg_data_url := &url.URL{Path: arg_data} 1556 | arg_data = arg_data_url.String() 1557 | } 1558 | 1559 | body_data = append(body_data, arg_data) 1560 | case "-j", "--json": 1561 | if arg_index == args_len-1 { 1562 | return errors.New("No POST/PUT/PATCH value specified") 1563 | } 1564 | 1565 | arg_index += 1 1566 | json_str := args[arg_index] 1567 | content_type = "json" 1568 | accept_types = append(accept_types, config.ContentTypes["json"]) 1569 | set_data = true 1570 | vdata, _ := g.View(REQUEST_DATA_VIEW) 1571 | setViewTextAndCursor(vdata, json_str) 1572 | case "-X", "--request": 1573 | if arg_index == args_len-1 { 1574 | return errors.New("No HTTP method specified") 1575 | } 1576 | arg_index++ 1577 | set_method = true 1578 | method := args[arg_index] 1579 | if content_type == "" && (method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch) { 1580 | content_type = "form" 1581 | } 1582 | vmethod, _ := g.View(REQUEST_METHOD_VIEW) 1583 | setViewTextAndCursor(vmethod, method) 1584 | case "-t", "--timeout": 1585 | if arg_index == args_len-1 { 1586 | return errors.New("No timeout value specified") 1587 | } 1588 | arg_index += 1 1589 | timeout, err := strconv.Atoi(args[arg_index]) 1590 | if err != nil || timeout <= 0 { 1591 | return errors.New("Invalid timeout value") 1592 | } 1593 | a.config.General.Timeout = config.Duration{Duration: time.Duration(timeout) * time.Millisecond} 1594 | case "--compressed": 1595 | vh, _ := g.View(REQUEST_HEADERS_VIEW) 1596 | if !strings.Contains(getViewValue(g, REQUEST_HEADERS_VIEW), "Accept-Encoding") { 1597 | fmt.Fprintln(vh, "Accept-Encoding: gzip, deflate") 1598 | } 1599 | case "-e", "--editor": 1600 | if arg_index == args_len-1 { 1601 | return errors.New("No timeout value specified") 1602 | } 1603 | arg_index += 1 1604 | a.config.General.Editor = args[arg_index] 1605 | case "-k", "--insecure": 1606 | a.config.General.Insecure = true 1607 | case "-R", "--disable-redirects": 1608 | a.config.General.FollowRedirects = false 1609 | case "--tlsv1.0": 1610 | a.config.General.TLSVersionMin = tls.VersionTLS10 1611 | a.config.General.TLSVersionMax = tls.VersionTLS10 1612 | case "--tlsv1.1": 1613 | a.config.General.TLSVersionMin = tls.VersionTLS11 1614 | a.config.General.TLSVersionMax = tls.VersionTLS11 1615 | case "--tlsv1.2": 1616 | a.config.General.TLSVersionMin = tls.VersionTLS12 1617 | a.config.General.TLSVersionMax = tls.VersionTLS12 1618 | case "-1", "--tlsv1": 1619 | a.config.General.TLSVersionMin = tls.VersionTLS10 1620 | a.config.General.TLSVersionMax = tls.VersionTLS12 1621 | case "-T", "--tls": 1622 | if arg_index >= args_len-1 { 1623 | return errors.New("Missing TLS version range: MIN,MAX") 1624 | } 1625 | arg_index++ 1626 | arg := args[arg_index] 1627 | v := strings.Split(arg, ",") 1628 | min := v[0] 1629 | max := min 1630 | if len(v) > 1 { 1631 | max = v[1] 1632 | } 1633 | minV, minFound := TLS_VERSIONS[min] 1634 | if !minFound { 1635 | return errors.New("Minimum TLS version not found: " + min) 1636 | } 1637 | maxV, maxFound := TLS_VERSIONS[max] 1638 | if !maxFound { 1639 | return errors.New("Maximum TLS version not found: " + max) 1640 | } 1641 | a.config.General.TLSVersionMin = minV 1642 | a.config.General.TLSVersionMax = maxV 1643 | case "-x", "--proxy": 1644 | if arg_index == args_len-1 { 1645 | return errors.New("Missing proxy URL") 1646 | } 1647 | arg_index += 1 1648 | u, err := url.Parse(args[arg_index]) 1649 | if err != nil { 1650 | return fmt.Errorf("Invalid proxy URL: %v", err) 1651 | } 1652 | switch u.Scheme { 1653 | case "", "http", "https": 1654 | TRANSPORT.Proxy = http.ProxyURL(u) 1655 | case "socks", "socks5": 1656 | dialer, err := proxy.SOCKS5("tcp", u.Host, nil, proxy.Direct) 1657 | if err != nil { 1658 | return fmt.Errorf("Can't connect to proxy: %v", err) 1659 | } 1660 | TRANSPORT.Dial = dialer.Dial 1661 | default: 1662 | return errors.New("Unknown proxy protocol") 1663 | } 1664 | case "-F", "--form": 1665 | if arg_index == args_len-1 { 1666 | return errors.New("No POST/PUT/PATCH value specified") 1667 | } 1668 | 1669 | arg_index += 1 1670 | form_str := args[arg_index] 1671 | content_type = "multipart" 1672 | set_data = true 1673 | vdata, _ := g.View(REQUEST_DATA_VIEW) 1674 | setViewTextAndCursor(vdata, form_str) 1675 | case "-f", "--file": 1676 | if arg_index == args_len-1 { 1677 | return errors.New("-f or --file requires a file path be provided as an argument") 1678 | } 1679 | arg_index += 1 1680 | loadLocation := args[arg_index] 1681 | a.LoadRequest(g, loadLocation) 1682 | default: 1683 | u := args[arg_index] 1684 | if strings.Index(u, "http://") != 0 && strings.Index(u, "https://") != 0 { 1685 | u = fmt.Sprintf("%v://%v", a.config.General.DefaultURLScheme, u) 1686 | } 1687 | parsed_url, err := url.Parse(u) 1688 | if err != nil || parsed_url.Host == "" { 1689 | return errors.New("Invalid url") 1690 | } 1691 | if parsed_url.Path == "" { 1692 | parsed_url.Path = "/" 1693 | } 1694 | vurl, _ := g.View(URL_VIEW) 1695 | vurl.Clear() 1696 | for k, v := range parsed_url.Query() { 1697 | for _, vv := range v { 1698 | fmt.Fprintf(vget, "%v=%v\n", k, vv) 1699 | } 1700 | } 1701 | parsed_url.RawQuery = "" 1702 | setViewTextAndCursor(vurl, parsed_url.String()) 1703 | } 1704 | arg_index += 1 1705 | } 1706 | 1707 | if set_data && !set_method { 1708 | vmethod, _ := g.View(REQUEST_METHOD_VIEW) 1709 | setViewTextAndCursor(vmethod, http.MethodPost) 1710 | } 1711 | 1712 | if !set_binary_data && content_type != "" && !a.hasHeader(g, "Content-Type") { 1713 | fmt.Fprintf(vheader, "Content-Type: %v\n", config.ContentTypes[content_type]) 1714 | } 1715 | 1716 | if len(accept_types) > 0 && !a.hasHeader(g, "Accept") { 1717 | fmt.Fprintf(vheader, "Accept: %v\n", strings.Join(accept_types, ",")) 1718 | } 1719 | 1720 | var merged_body_data string 1721 | if set_data && !set_binary_data { 1722 | merged_body_data = strings.Join(body_data, "&") 1723 | } 1724 | 1725 | vdata, _ := g.View(REQUEST_DATA_VIEW) 1726 | setViewTextAndCursor(vdata, merged_body_data) 1727 | 1728 | return nil 1729 | } 1730 | 1731 | func (a *App) hasHeader(g *gocui.Gui, h string) bool { 1732 | for _, header := range strings.Split(getViewValue(g, REQUEST_HEADERS_VIEW), "\n") { 1733 | if header == "" { 1734 | continue 1735 | } 1736 | header_parts := strings.SplitN(header, ": ", 2) 1737 | if len(header_parts) != 2 { 1738 | continue 1739 | } 1740 | if header_parts[0] == h { 1741 | return true 1742 | } 1743 | } 1744 | return false 1745 | } 1746 | 1747 | // Apply startup config values. This is run after a.ParseArgs, so that 1748 | // args can override the provided config values 1749 | func (a *App) InitConfig() { 1750 | CLIENT.Timeout = a.config.General.Timeout.Duration 1751 | TRANSPORT.TLSClientConfig = &tls.Config{ 1752 | InsecureSkipVerify: a.config.General.Insecure, 1753 | MinVersion: a.config.General.TLSVersionMin, 1754 | MaxVersion: a.config.General.TLSVersionMax, 1755 | } 1756 | CLIENT.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { 1757 | if a.config.General.FollowRedirects { 1758 | return nil 1759 | } 1760 | return http.ErrUseLastResponse 1761 | } 1762 | } 1763 | 1764 | func refreshStatusLine(a *App, g *gocui.Gui) { 1765 | sv, _ := g.View(STATUSLINE_VIEW) 1766 | sv.BgColor = gocui.ColorDefault | gocui.AttrReverse 1767 | sv.FgColor = gocui.ColorDefault | gocui.AttrReverse 1768 | a.statusLine.Update(sv, a) 1769 | } 1770 | 1771 | func initApp(a *App, g *gocui.Gui) { 1772 | g.Cursor = true 1773 | g.InputEsc = false 1774 | g.BgColor = gocui.ColorDefault 1775 | g.FgColor = gocui.ColorDefault 1776 | g.SetManagerFunc(a.Layout) 1777 | } 1778 | 1779 | func getViewValue(g *gocui.Gui, name string) string { 1780 | v, err := g.View(name) 1781 | if err != nil { 1782 | return "" 1783 | } 1784 | return strings.TrimSpace(v.Buffer()) 1785 | } 1786 | 1787 | func setViewDefaults(v *gocui.View) { 1788 | v.Frame = true 1789 | v.Wrap = false 1790 | } 1791 | 1792 | func setViewTextAndCursor(v *gocui.View, s string) { 1793 | v.Clear() 1794 | fmt.Fprint(v, s) 1795 | v.SetCursor(len(s), 0) 1796 | } 1797 | 1798 | func help() { 1799 | fmt.Println(`wuzz - Interactive cli tool for HTTP inspection 1800 | 1801 | Usage: wuzz [-H|--header HEADER]... [-d|--data|--data-binary DATA] [-X|--request METHOD] [-t|--timeout MSECS] [URL] 1802 | 1803 | Other command line options: 1804 | -c, --config PATH Specify custom configuration file 1805 | -e, --editor EDITOR Specify external editor command 1806 | -f, --file REQUEST Load a previous request 1807 | -F, --form DATA Add multipart form request data and set related request headers 1808 | If the value starts with @ it will be handled as a file path for upload 1809 | -h, --help Show this 1810 | -j, --json JSON Add JSON request data and set related request headers 1811 | -k, --insecure Allow insecure SSL certs 1812 | -R, --disable-redirects Do not follow HTTP redirects 1813 | -T, --tls MIN,MAX Restrict allowed TLS versions (values: SSL3.0,TLS1.0,TLS1.1,TLS1.2) 1814 | Examples: wuzz -T TLS1.1 (TLS1.1 only) 1815 | wuzz -T TLS1.0,TLS1.1 (from TLS1.0 up to TLS1.1) 1816 | --tlsv1.0 Forces TLS1.0 only 1817 | --tlsv1.1 Forces TLS1.1 only 1818 | --tlsv1.2 Forces TLS1.2 only 1819 | -1, --tlsv1 Forces TLS version 1.x (1.0, 1.1 or 1.2) 1820 | -v, --version Display version number 1821 | -x, --proxy URL Set HTTP(S) or SOCKS5 proxy 1822 | 1823 | Key bindings: 1824 | ctrl+r Send request 1825 | ctrl+s Save response 1826 | ctrl+e Save request 1827 | ctrl+f Load request 1828 | tab, ctrl+j Next window 1829 | shift+tab, ctrl+k Previous window 1830 | alt+h Show history 1831 | pageUp Scroll up the current window 1832 | pageDown Scroll down the current window`, 1833 | ) 1834 | } 1835 | 1836 | func main() { 1837 | configPath := "" 1838 | args := os.Args 1839 | for i, arg := range os.Args { 1840 | switch arg { 1841 | case "-h", "--help": 1842 | help() 1843 | return 1844 | case "-v", "--version": 1845 | fmt.Printf("wuzz %v\n", VERSION) 1846 | return 1847 | case "-c", "--config": 1848 | configPath = os.Args[i+1] 1849 | args = append(os.Args[:i], os.Args[i+2:]...) 1850 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 1851 | log.Fatal("Config file specified but does not exist: \"" + configPath + "\"") 1852 | } 1853 | } 1854 | } 1855 | var g *gocui.Gui 1856 | var err error 1857 | for _, outputMode := range []gocui.OutputMode{gocui.Output256, gocui.OutputNormal, gocui.OutputMode(termbox.OutputGrayscale)} { 1858 | g, err = gocui.NewGui(outputMode) 1859 | if err == nil { 1860 | break 1861 | } 1862 | } 1863 | if err != nil { 1864 | log.Panicln(err) 1865 | } 1866 | 1867 | if runtime.GOOS == WINDOWS_OS && runewidth.IsEastAsian() { 1868 | g.ASCII = true 1869 | } 1870 | 1871 | app := &App{history: make([]*Request, 0, 31)} 1872 | 1873 | // overwrite default editor 1874 | defaultEditor = ViewEditor{app, g, false, gocui.DefaultEditor} 1875 | 1876 | initApp(app, g) 1877 | 1878 | // load config (must be done *before* app.ParseArgs, as arguments 1879 | // should be able to override config values). An empty string passed 1880 | // to LoadConfig results in LoadConfig loading the default config 1881 | // location. If there is no config, the values in 1882 | // config.DefaultConfig will be used. 1883 | err = app.LoadConfig(configPath) 1884 | if err != nil { 1885 | g.Close() 1886 | log.Fatalf("Error loading config file: %v", err) 1887 | } 1888 | 1889 | err = app.ParseArgs(g, args) 1890 | 1891 | // Some of the values in the config need to have some startup 1892 | // behavior associated with them. This is run after ParseArgs so 1893 | // that command-line arguments can override configuration values. 1894 | app.InitConfig() 1895 | 1896 | if err != nil { 1897 | g.Close() 1898 | fmt.Println("Error!", err) 1899 | os.Exit(1) 1900 | } 1901 | 1902 | err = app.SetKeys(g) 1903 | 1904 | if err != nil { 1905 | g.Close() 1906 | fmt.Println("Error!", err) 1907 | os.Exit(1) 1908 | } 1909 | 1910 | defer g.Close() 1911 | 1912 | if err := g.MainLoop(); err != nil && err != gocui.ErrQuit { 1913 | log.Panicln(err) 1914 | } 1915 | } 1916 | 1917 | func exportJSON(r Request) []byte { 1918 | requestMap := map[string]string{ 1919 | URL_VIEW: r.Url, 1920 | REQUEST_METHOD_VIEW: r.Method, 1921 | URL_PARAMS_VIEW: r.GetParams, 1922 | REQUEST_DATA_VIEW: r.Data, 1923 | REQUEST_HEADERS_VIEW: r.Headers, 1924 | } 1925 | 1926 | request, err := json.Marshal(requestMap) 1927 | if err != nil { 1928 | return []byte{} 1929 | } 1930 | return request 1931 | } 1932 | 1933 | func exportCurl(r Request) []byte { 1934 | var headers, params string 1935 | for _, header := range strings.Split(r.Headers, "\n") { 1936 | if header == "" { 1937 | continue 1938 | } 1939 | headers = fmt.Sprintf("%s -H %s", headers, shellescape.Quote(header)) 1940 | } 1941 | if r.GetParams != "" { 1942 | params = fmt.Sprintf("?%s", r.GetParams) 1943 | } 1944 | return []byte(fmt.Sprintf("curl %s -X %s -d %s %s\n", headers, r.Method, shellescape.Quote(r.Data), shellescape.Quote(r.Url+params))) 1945 | } 1946 | --------------------------------------------------------------------------------