├── .github └── workflows │ └── build.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── doc ├── add.png ├── macos_2_1.png ├── macos_2_2.png ├── macos_4_1.png ├── macos_4_2.png ├── macos_4_3.png ├── macos_5.png ├── windows_2.png ├── windows_3.png ├── windows_4_1.png ├── windows_4_2.png └── windows_5.png ├── favicon.ico ├── favicon.svg ├── go.mod ├── go.sum └── quickserv.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Created by Jacob Strieb 2 | # September, 2021 3 | # 4 | # Based on previous work for lsnow99/dudu 5 | # https://git.io/JzBBK 6 | # 7 | # This GitHub Actions workflow compiles QuickServ for several architectures and 8 | # generates a release on GitHub. 9 | 10 | name: Compile and Release 11 | 12 | on: 13 | push: 14 | tags: 15 | - v* 16 | 17 | jobs: 18 | build: 19 | name: Compile for different operating systems and architectures, and release 20 | runs-on: ubuntu-20.04 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | 25 | # QuickServ uses an embedded filesystem, so it requires a Go version 26 | # greater than 1.16 27 | - uses: actions/setup-go@v2 28 | with: 29 | go-version: '^1.16.0' 30 | 31 | 32 | - name: Setup 33 | run: | 34 | mkdir -p bin 35 | go mod tidy 36 | 37 | 38 | - name: Compile for Windows 39 | run: | 40 | export GOOS=windows 41 | GOARCH=386 go build -o bin/quickserv_windows_x86.exe github.com/jstrieb/quickserv 42 | GOARCH=amd64 go build -o bin/quickserv_windows_x64.exe github.com/jstrieb/quickserv 43 | 44 | 45 | - name: Compile for MacOS 46 | run: | 47 | export GOOS=darwin 48 | GOARCH=amd64 go build -o bin/quickserv_macos_x64 github.com/jstrieb/quickserv 49 | GOARCH=arm64 go build -o bin/quickserv_macos_arm64 github.com/jstrieb/quickserv 50 | 51 | # Zip each file up so that the permission bits are preserved after 52 | # download, which matters for double-clicking on MacOS 53 | cd bin 54 | for f in quickserv_macos*; do 55 | zip "${f}.zip" "${f}" 56 | done 57 | 58 | 59 | - name: Compile for Linux 60 | run: | 61 | export GOOS=linux 62 | GOARCH=386 go build -o bin/quickserv_linux_x86 github.com/jstrieb/quickserv 63 | GOARCH=amd64 go build -o bin/quickserv_linux_x64 github.com/jstrieb/quickserv 64 | GOARCH=arm64 go build -o bin/quickserv_linux_arm64 github.com/jstrieb/quickserv 65 | GOARCH=arm GOARM=6 go build -o bin/quickserv_raspi_arm github.com/jstrieb/quickserv 66 | 67 | 68 | - name: Create GitHub Release 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | run: | 72 | cd bin 73 | TAG="$(git describe --tags --abbrev=0)" 74 | 75 | gh release create \ 76 | "${TAG}" \ 77 | --title "${TAG}" \ 78 | --notes "Pre-compiled binaries for QuickServ ${TAG}" \ 79 | *.exe \ 80 | *.zip \ 81 | quickserv_linux_* \ 82 | quickserv_raspi_* 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | *.d 3 | 4 | # Object files 5 | *.o 6 | *.ko 7 | *.obj 8 | *.elf 9 | 10 | # Linker output 11 | *.ilk 12 | *.map 13 | *.exp 14 | 15 | # Precompiled Headers 16 | *.gch 17 | *.pch 18 | 19 | # Libraries 20 | *.lib 21 | *.a 22 | *.la 23 | *.lo 24 | 25 | # Shared objects (inc. Windows DLLs) 26 | *.dll 27 | *.so 28 | *.so.* 29 | *.dylib 30 | 31 | # Executables 32 | *.exe 33 | *.out 34 | *.app 35 | *.i*86 36 | *.x86_64 37 | *.hex 38 | quickserv 39 | bin/* 40 | 41 | # Debug files 42 | *.dSYM/ 43 | *.su 44 | *.idb 45 | *.pdb 46 | 47 | # Kernel Module Compile Results 48 | *.cmd 49 | .tmp_versions/ 50 | modules.order 51 | Module.symvers 52 | Mkfile.old 53 | dkms.conf 54 | 55 | # Vim temporary files 56 | *.swp 57 | *.swo 58 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "examples"] 2 | path = examples 3 | url = https://github.com/jstrieb/quickserv-examples 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jacob Strieb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # **QuickServ** 4 | 5 | **Quick**, no-setup web **Serv**er 6 | 7 | 8 | # About 9 | 10 | QuickServ makes creating web applications [*dangerously*](#disclaimer) easy, no 11 | matter what programming language you use. 12 | 13 | QuickServ is a dependency-free, statically-linked, single-file web server that: 14 | 15 | - Has sensible defaults 16 | - Prints helpful error messages directly to the console 17 | - Runs on any modern computer, with no setup or installation 18 | - Needs no configuration 19 | - Knows which files to run server-side, and which to serve plain 20 | - Works with any programming language that can `read` and `write` 21 | - Doesn't require understanding the intricacies of HTTP 22 | - Enables Cross Origin Request Sharing (CORS) by default 23 | - Works with or without the command line 24 | 25 | QuickServ brings the heady fun of the 1990s Internet to the 2020s. It is 26 | inspired by the [Common Gateway Interface 27 | (CGI)](https://en.wikipedia.org/wiki/Common_Gateway_Interface), but is much 28 | easier to set up and use. Unlike CGI, it works out of the box with no searching 29 | for obscure log files, no learning how HTTP headers work, no fiddling with 30 | permission bits, no worrying about CORS, no wondering where to put your scripts, 31 | and no struggling with Apache `mod_cgi` configurations. 32 | 33 | Unlike with CGI, you don't have to know what anything from the previous 34 | paragraph means to use QuickServ. 35 | 36 | 37 | 38 | It is perfect for: 39 | 40 | - Building hackathon projects without learning a web framework 41 | - Creating internal tools 42 | - Prototyping web applications using any language 43 | - Attaching web interfaces to scripts 44 | - Controlling hardware with Raspberry Pis on your local network 45 | - Trying out web development without being overwhelmed 46 | 47 | [QuickServ should not be used on the public Internet. It should only be used on 48 | private networks.](#disclaimer) 49 | 50 | 51 | # Get Started 52 | 53 | Using QuickServ is as easy as downloading the program, dragging it to your 54 | project folder, and double clicking to run. It automatically detects which files 55 | to execute, and which to serve directly to the user. 56 | 57 | ## Windows 58 | 59 |
60 | Click to view details 61 | 62 | 1. [Download for 63 | Windows](https://github.com/jstrieb/quickserv/releases/latest/download/quickserv_windows_x64.exe). 64 | 65 | 1. Make a project folder and add files to it. For example, if Python is 66 | installed, create a file called `test.py` in the project folder containing: 67 | 68 | ``` python 69 | #!python 70 | 71 | # Put your code here. For example: 72 | import random 73 | print(random.randint(0, 420)) 74 | ``` 75 | 76 |
77 | 78 |
79 | 80 | Since `test.py` starts with `#!something`, where `something test.py` is the 81 | command to execute the file, QuickServ will know to run it. If QuickServ is 82 | not running your file, make sure to add this to the beginning. 83 | 84 | On Windows, QuickServ also knows to automatically run files that end in 85 | `.exe` and `.bat`. Any other file type needs to start with `#!something` if 86 | it should be run. 87 | 88 | 1. Move the downloaded `quickserv_windows_x64.exe` file to the project folder. 89 | 90 |
91 | 92 |
93 | 94 | 1. Double click `quickserv_windows_x64.exe` in the project folder to start 95 | QuickServ. Allow access through Windows Defender if prompted. 96 | 97 |
98 |

99 | 100 | 101 |
102 | 103 | 1. Go to (or the address shown by QuickServ) to connect 104 | to your web application. In the example, to run `test.py`, go to 105 | . 106 | 107 |
108 | 109 |
110 | 111 |
112 | 113 | ## Mac 114 | 115 |
116 | Click to view details 117 | 118 | 1. Download the right version for your computer. If necessary, [check what type 119 | of processor your Mac 120 | has](https://www.howtogeek.com/706226/how-to-check-if-your-mac-is-using-an-intel-or-apple-silicon-processor/). 121 | You will have to unzip the files after you download them. 122 | - [Download for 123 | Intel](https://github.com/jstrieb/quickserv/releases/latest/download/quickserv_macos_x64.zip). 124 | - [Download for Apple 125 | Silicon](https://github.com/jstrieb/quickserv/releases/latest/download/quickserv_macos_arm64.zip). 126 | 127 | 1. Make a project folder and add files to it. For example, if Python is 128 | installed, create a file called `test.py` in the project folder containing: 129 | 130 | ``` python 131 | #!python 132 | 133 | # Put your code here. For example: 134 | import random 135 | print(random.randint(0, 420)) 136 | ``` 137 | 138 |
139 | 140 |
141 | 142 | If you are making the file with TextEdit, you will need to go into `Format > 143 | Make Plain Text` to save the file in the correct format. 144 | 145 |
146 | 147 |
148 | 149 | Since `test.py` starts with `#!something`, where `something test.py` is the 150 | command to execute the file, QuickServ will know to run it. If QuickServ is 151 | not running your file, make sure to add this to the beginning. 152 | 153 | On Mac, QuickServ also knows to automatically run files that have been 154 | compiled. Any other file type needs to start with `#!something` if it should 155 | be run. 156 | 157 | 1. Move the downloaded `quickserv_macos_x64` or `quickserv_macos_arm64` file to 158 | the project folder. 159 | 160 | 1. Right click `quickserv_macos_x64` or `quickserv_macos_arm64` in the project 161 | folder and select "Open." Then, press "Open" in the confirmation dialog box. 162 | After running it this way once, you will be able to start QuickServ by simply 163 | double-clicking the file. 164 | 165 |
166 |

167 |

168 | 169 |
170 | 171 | 1. Go to (or the address shown by QuickServ) to connect 172 | to your web application. In the example, to run `test.py`, go to 173 | . 174 | 175 |
176 | 177 |
178 | 179 |
180 | 181 | ## Raspberry Pi 182 | 183 |
184 | Click to view details 185 | 186 | It's easiest to install and run via the command line. [Open the 187 | Terminal](https://projects.raspberrypi.org/en/projects/raspberry-pi-using/8). 188 | 189 | Enter the following commands. A password may be required for the first commands. 190 | 191 | ``` bash 192 | # Download 193 | sudo curl \ 194 | --location \ 195 | --output /usr/local/bin/quickserv \ 196 | https://github.com/jstrieb/quickserv/releases/latest/download/quickserv_raspi_arm 197 | 198 | # Make executable 199 | sudo chmod +x /usr/local/bin/quickserv 200 | 201 | # Make a project folder 202 | mkdir -p my/project/folder 203 | 204 | # Go to project folder 205 | cd my/project/folder 206 | 207 | # Add a test file 208 | cat < test.py 209 | #!python3 210 | 211 | # Put your code here. For example: 212 | import random 213 | print(random.randint(0, 420)) 214 | EOF 215 | 216 | # Run QuickServ 217 | quickserv 218 | ``` 219 | 220 | Go to (or the address shown by QuickServ) to connect to 221 | your web application. For example, to run `test.py`, go to 222 | . 223 | 224 |
225 | 226 | ## Other Operating Systems 227 | 228 |
229 | Click to view details 230 | 231 | Clicking to run executables does not have consistent behavior across Linux 232 | distros, so it's easiest to install and run via the command line. Depending 233 | on your computer's architecture, it may be necessary to change the filename 234 | at the end of the `curl` HTTP request URL below. 235 | 236 | See all download options on the [releases 237 | page](https://github.com/jstrieb/quickserv/releases/latest). 238 | 239 | ``` bash 240 | # Download 241 | sudo curl \ 242 | --location \ 243 | --output /usr/local/bin/quickserv \ 244 | https://github.com/jstrieb/quickserv/releases/latest/download/quickserv_linux_x64 245 | 246 | # Make executable 247 | sudo chmod +x /usr/local/bin/quickserv 248 | 249 | # Make a project folder 250 | mkdir -p /my/project/folder 251 | 252 | # Go to project folder 253 | cd /my/project/folder 254 | 255 | # Add a test file 256 | cat < test.py 257 | #!python3 258 | 259 | # Put your code here. For example: 260 | import random 261 | print(random.randint(0, 420)) 262 | EOF 263 | 264 | # Run QuickServ 265 | quickserv 266 | ``` 267 | 268 | Go to (or the address shown by QuickServ) to connect to 269 | your web application. For example, to run `test.py`, go to 270 | . 271 | 272 | Alternatively, use the instructions below to compile from source. 273 | 274 |
275 | 276 | ## Compile From Source 277 | 278 |
279 | 280 | Click to view details 281 | 282 | Compile and install from source using the following command. A version of Go 283 | greater than 1.16 is required because of the dependency on embedded filesystems. 284 | 285 | ``` bash 286 | go install github.com/jstrieb/quickserv@latest 287 | ``` 288 | 289 | Then create your project folder, populate it, and run QuickServ. 290 | 291 | ``` bash 292 | # Make a project folder 293 | mkdir -p /my/project/folder 294 | 295 | # Go to project folder 296 | cd /my/project/folder 297 | 298 | # Add a test file 299 | cat < test.py 300 | #!python3 301 | 302 | # Put your code here. For example: 303 | import random 304 | print(random.randint(0, 420)) 305 | EOF 306 | 307 | # Run QuickServ 308 | quickserv 309 | ``` 310 | 311 |
312 | 313 | 314 | # Tutorial 315 | 316 | To demonstrate key features of QuickServ, we will build a simple web 317 | application to perform addition. The code will not follow best practices, but 318 | it will show how little is needed to get started building with QuickServ. 319 | 320 | First, we create create a project folder and drag the QuickServ executable into 321 | the folder, as in the [getting started](#get-started) steps. 322 | 323 | Next, inside the folder, we save the following text as `index.html`: 324 | 325 | ``` html 326 |
327 | + = ??? 328 |
329 | 330 |
331 | ``` 332 | 333 | This code submits two variables to the `/calculate` page. In the browser, it 334 | looks like this: 335 | 336 |
337 | 338 |
339 | 340 | Then, we create a folder called `calculate` inside the project folder. Inside 341 | the `calculate` folder, we save the following code as `index.py`. The name 342 | `index.whatever` tells QuickServ to run this file when a user visits 343 | `http://website/calculate` instead of needing them to visit 344 | `http://website/calculate/index.py`. 345 | 346 | Pay special attention to the code comments. They highlight a number of 347 | important QuickServ features. 348 | 349 | ``` python 350 | #!python3 351 | 352 | # Each QuickServ script must begin with a line like the one above so that 353 | # QuickServ knows how to run the file. This line tells QuickServ that I would 354 | # type `python3 this_file.py` to run this file at the command prompt. For 355 | # example, if you wanted to do `julia this_file.py` instead, then you would 356 | # make the first line of `this_file.py` be `#!julia`. 357 | # 358 | # Since we just want QuickServ to show the HTML code to the user and not run 359 | # it, index.html does not begin with this. The first line is only required when 360 | # QuickServ has to run the code. 361 | 362 | import argparse 363 | 364 | # All HTML form values get turned into command line arguments. The names are 365 | # formatted like "--name" and the value comes right after the name. 366 | parser = argparse.ArgumentParser() 367 | parser.add_argument("--first", type=int, required=True) 368 | parser.add_argument("--second", type=int, required=True) 369 | args = parser.parse_args() 370 | 371 | # Print the result -- anything printed out goes right to the user. In this 372 | # case, the output is text. But you can print anything and QuickServ will guess 373 | # the file type. Even printing the contents of image and video files works. 374 | print(args.first + args.second) 375 | ``` 376 | 377 | Now double click QuickServ in your project folder and try it out in your 378 | browser. That's it! 379 | 380 | See the examples linked in the next section for more QuickServ demonstrations. 381 | Read more details in the [How it Works](#how-it-works) section, and in the code 382 | itself. The [Advanced](#advanced) section has additional information about the 383 | environment QuickServ sets up for executables it runs. 384 | 385 | 386 | # Examples 387 | 388 | All examples are located in the `examples` folder, which is a Git submodule 389 | connected to the 390 | [jstrieb/quickserv-examples](https://github.com/jstrieb/quickserv-examples) 391 | repo. Go to that repo for more information on how to run the examples. 392 | 393 | Some highlights: 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 |
SummaryLanguageWindowsMacLinux
YouTube to GIFShell
Create Bloom filtersC
Synchronized movie listTypeScript
Brainfuck interpreterx86-64 Assembly
Mandelbrot zoomShell
Cryptocurrency dashboardGo
PDF toolsPython
Typeset Markdown as PDFStandard ML
470 | 471 | 472 | # How It Works 473 | 474 | All of the QuickServ code lives in 475 | [`quickserv.go`](https://github.com/jstrieb/quickserv/blob/master/quickserv.go). 476 | This well-commented file is about 700 lines long, and should take an experienced 477 | programmer with no Golang familiarity at most an hour to read. 478 | 479 |
480 | Click to view details 481 | 482 | QuickServ has two main parts. The first is an initialization procedure, run 483 | exactly once at startup. The second is a handler function, called every time a 484 | user makes an HTTP request to the server. 485 | 486 | ## Initialization Routine 487 | 488 | When QuickServ starts up, it checks for command-line configuration flags, opens 489 | a log file if one is passed with `--logfile` (otherwise it logs to the standard 490 | output), and changes directories if a working directory is passed with `--dir`. 491 | Note that the log file path is relative to the current working directory, not 492 | relative to the one passed with `--dir`. 493 | 494 | Next, QuickServ scans the working directory for files to run. It prints all of 495 | the files that will be executed. This behavior is useful for determining if 496 | QuickServ recognizes a script as executable. It also prints helpful information 497 | for the user such as the web address to visit to access the server, and what 498 | folder the server is running in, as well as how to stop it. 499 | 500 | If any part of the initialization fails, an error is reported. In the event of a 501 | fatal error, QuickServ waits for user input before quitting. This way, a user 502 | who double-clicks the executable (as opposed to starting it from the command 503 | line) does not have a window appear and then immediately disappear, flashing too 504 | quickly for the error to be read. 505 | 506 | Error messages are purposefully written with as little technical jargon as 507 | possible, though some is unavoidable. Likely causes for the errors are also 508 | included in error messages, so that they are easier for users to identify and 509 | fix. 510 | 511 | As the last step in the initialization procedure, QuickServ starts a web server 512 | with a single handler function for all requests. The server listens on the 513 | default port of `42069`, or on a random port if a user specified the 514 | `--random-port` command-line flag. A random port would be desirable if the user 515 | has to show a project built with QuickServ to someone humorless, for example. 516 | 517 | ## Request Handler 518 | 519 | When a user visits a web page, QuickServ handles the request by calling the lone 520 | handler function. 521 | 522 | First, this function tries to open the file the user requested. If it cannot 523 | find or open the file, it tries to serve a default version of the file. For 524 | example, there is an embedded, default `favicon.ico` that gets served. If there 525 | is no default file matching the path, it lets the built-in Go fileserver handle 526 | the error and respond with a 404 error code. 527 | 528 | If the file the user requested is present, it checks whether it is a directory. 529 | If it is a directory, QuickServ looks inside for a file named `index.xxx` where 530 | `xxx` is any file extension. If an index file is found, the index is served (and 531 | possibly executed) as if it were the original page requested. Otherwise, the 532 | user must have requested a directory without a default index, so QuickServ 533 | responds with a listing of the other files in the directory. 534 | 535 | If the file the user requested is present and not a directory (_i.e._, it is a 536 | regular file), QuickServ checks whether or not it is executable. If so, it 537 | executes the file it found. If not, it returns the raw file contents to the 538 | user. In both cases, QuickServ will guess what filetype (and therefore which 539 | `mimetype`) to use for the response. 540 | 541 | The technique for determining if a file is executable depends on the runtime 542 | operating system. On Windows, any file with a `.bat` or `.exe` extension is 543 | considered executable. On non-Windows systems, any file with the executable 544 | permission bit set is considered executable. On all systems, a file is 545 | executable if it has a valid pseudo-shebang at the beginning. The shebang must 546 | be on the very first line, must begin with `#!`, and must be a valid command. 547 | For example, both of the following are acceptable, assuming `python3` is 548 | installed and on the `PATH`: 549 | 550 | - `#!/usr/bin/python3` 551 | - `#!python3` 552 | 553 | To execute a file, QuickServ either runs the file itself (if it is an `.exe` or 554 | has the executable bit set), or it passes the file's path as the first argument 555 | to the executable listed in its shebang. The request body is passed to the 556 | program on standard input, and everything printed by the program on standard 557 | output is used as the response body. Executed programs are neither responsible 558 | for writing—nor able to write—HTTP response headers. 559 | 560 | All parsed HTTP form variables (if the `Content-Type` is 561 | `x-www-form-urlencoded`) are also passed as command line arguments when the 562 | program is executed. This way, the user does not need to parse the variables 563 | themselves. 564 | 565 | Whatever the executed program prints on standard error is logged by QuickServ, 566 | which means it gets printed in the console window by default. This is handy for 567 | debugging. If the program terminates with a non-zero exit code, QuickServ 568 | responds with a 500 internal server error. Otherwise it returns with a 200. 569 | 570 | If the request is a URL-encoded POST request with form data, QuickServ 571 | URL-decodes all of the characters except for three symbols: `%`, `&`, and `=`. 572 | The user is responsible for substituting these. Note that it is important to 573 | always URL-decode `%` last in the program that processes the form data. 574 | 575 |
576 | 577 | 578 | # Disclaimer 579 | 580 | Do not run QuickServ on the public Internet. Only run it on private networks. 581 | 582 | QuickServ is not designed for production use. It was not created to be fast or 583 | secure. Using QuickServ in production puts your users and yourself at risk, 584 | please do not do it. 585 | 586 | QuickServ lets people build dangerously insecure things. It does not sanitize 587 | any inputs or outputs. It uses one process per request, and is susceptible to a 588 | denial of service attack. Its security model presumes web users are trustworthy. 589 | These characteristics make prototyping easier, but are not safe on the public 590 | Internet. 591 | 592 | To deter using QuickServ in production, it runs on port `42069`. Hopefully that 593 | makes everyone think twice before entering it into a reverse proxy or port 594 | forward config. For a more professional demo, the command-line flag 595 | `--random-port` will instead use a random port, determined at runtime. 596 | 597 | QuickServ is similar to the ancient CGI protocol. There are many 598 | well-articulated, well-established [reasons that CGI is bad in 599 | production](https://www.embedthis.com/blog/posts/stop-using-cgi/stop-using-cgi.html), 600 | and they all apply to QuickServ in production. 601 | 602 | 603 | # Advanced 604 | 605 | ## Command Line Options 606 | 607 | QuickServ has advanced options configured via command line flags. These 608 | change how and where QuickServ runs, as well as where it saves its output. 609 | 610 | ``` 611 | Usage: 612 | quickserv [options] 613 | 614 | Options: 615 | --dir string 616 | Folder to serve files from. (default ".") 617 | --logfile string 618 | Log file path. Stdout if unspecified. (default "-") 619 | --no-pause 620 | Don't pause before exiting after fatal error. 621 | --random-port 622 | Use a random port instead of 42069. 623 | ``` 624 | 625 | ## HTTP Headers & Environment Variables 626 | 627 | In imitation of CGI, HTTP headers are passed to the executed program as 628 | environment variables. A header called `Header-Name` will be set as the 629 | environment variable `HTTP_HEADER_NAME`. 630 | 631 | There is also a `REQUEST_TYPE` variable that specifies whether the request was 632 | `GET`, `POST`, *etc*. 633 | 634 | ## Read From Standard Input 635 | 636 | HTTP requests with a body pass the body to the executed program on standard 637 | input. In most cases, the request body is passed verbatim. This is not the case 638 | for HTML forms. 639 | 640 | HTML form data can either be read from command line arguments, as in the 641 | tutorial, or parsed from standard input. Variables take the form 642 | 643 | ``` 644 | name=value&othername=othervalue 645 | ``` 646 | 647 | The simple addition example from the [tutorial](#tutorial) can be rewritten to 648 | parse HTTP form values from the standard input instead of from the command line 649 | arguments. 650 | 651 | ``` python 652 | #!python3 653 | 654 | import sys 655 | 656 | 657 | # In the form input, "=" and "&" determine where variables start and end. So if 658 | # they are literally included in the variable name or value, they must be 659 | # specially decoded. This code replaces every instance of the text on the left 660 | # with the text on the right to do the decoding: 661 | # %3D -> = 662 | # %26 -> & 663 | # %25 -> % 664 | # 665 | # NOTE: Order matters! "%" must be decoded last. If not, it can mess with 666 | # decoding the others, since their encoded version uses "%" 667 | def decode_characters(text): 668 | text = text.replace("%3D", "=") 669 | text = text.replace("%26", "&") 670 | text = text.replace("%25", "%") 671 | return text 672 | 673 | first = second = 0 674 | 675 | # Read all of the input into a variable. We are expecting the raw data to look 676 | # like: 677 | # first=123&second=456 678 | data = sys.stdin.read() 679 | 680 | # The raw data looks like the above, so split it into pairs at each "&" 681 | pairs = data.split("&") 682 | for pair in pairs: 683 | # Each pair looks like the following, so split at each "=": 684 | # name=value 685 | name, value = pair.split("=") 686 | 687 | # Decode any special characters (=, &, %) now that we have split the 688 | # variables up. This isn't necessary here since we're expecting numbers and 689 | # not expecting any of those characters. But it matters a lot when a user 690 | # could submit text with those characters 691 | name = decode_characters(name) 692 | value = decode_characters(value) 693 | 694 | # If the name is what we're looking for, store the value for adding 695 | if name == "first": 696 | first = int(value) 697 | elif name == "second": 698 | second = int(value) 699 | 700 | # Print the result -- anything printed out goes right to the user. In this 701 | # case, the output is text. But you can print anything and QuickServ will try and 702 | # guess the file type. 703 | print(first + second) 704 | ``` 705 | 706 | 707 | # Project Status & Contributing 708 | 709 | This project is actively developed and maintained. If there are no recent 710 | commits, it means that everything is running smoothly! 711 | 712 | Please [open an issue](https://github.com/jstrieb/quickserv/issues/new) with any 713 | bugs, suggestions, or questions. This especially includes discussions about how 714 | to make error messages as clear as possible, and how to make the default 715 | settings applicable to as many users as possible. 716 | 717 | Pull requests without prior discussion will be ignored – don't waste time 718 | writing code before confirming that it will be merged in. As a busy, lone 719 | developer, it is easier to be responsive when all code contributions have 720 | context. 721 | 722 | If you make a blog post, video, tutorial, hackathon project, or anything else 723 | using QuickServ, please [open an 724 | issue](https://github.com/jstrieb/quickserv/issues/new) or message me on my 725 | [contact form](https://jstrieb.github.io/about#contact) so that I can link back 726 | to it! 727 | 728 | 729 | # Support the Project 730 | 731 | There are a few ways to support the project: 732 | 733 | - Star the repository and follow me on GitHub 734 | - Share and upvote on sites like Twitter, Reddit, and Hacker News 735 | - Report any bugs, glitches, or errors that you find 736 | - Translate into other languages so everyone can use the project 737 | - Build and share your own projects made with QuickServ 738 | 739 | These things motivate me to to keep sharing what I build, and they provide 740 | validation that my work is appreciated! They also help me improve the project. 741 | Thanks in advance! 742 | 743 | If you are insistent on spending money to show your support, I encourage you to 744 | instead make a generous donation to one of the following organizations. By 745 | advocating for Internet freedoms, organizations like these help me to feel 746 | comfortable releasing work publicly on the Web. 747 | 748 | - [Electronic Frontier Foundation](https://supporters.eff.org/donate/) 749 | - [Signal Foundation](https://signal.org/donate/) 750 | - [Mozilla](https://donate.mozilla.org/en-US/) 751 | - [The Internet Archive](https://archive.org/donate/index.php) 752 | 753 | 754 | # Acknowledgments 755 | 756 | This project would not be possible without the help of: 757 | 758 | - [Logan Snow](https://github.com/lsnow99) 759 | - [Amy Liu](https://www.linkedin.com/in/amyjl/) 760 | - Hacker News user [rchaves](https://news.ycombinator.com/user?id=rchaves), who 761 | helpfully [suggested passing parsed form values as command line 762 | arguments](https://news.ycombinator.com/item?id=29005407) 763 | - Everyone who [supports the project](#support-the-project) 764 | -------------------------------------------------------------------------------- /doc/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/add.png -------------------------------------------------------------------------------- /doc/macos_2_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/macos_2_1.png -------------------------------------------------------------------------------- /doc/macos_2_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/macos_2_2.png -------------------------------------------------------------------------------- /doc/macos_4_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/macos_4_1.png -------------------------------------------------------------------------------- /doc/macos_4_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/macos_4_2.png -------------------------------------------------------------------------------- /doc/macos_4_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/macos_4_3.png -------------------------------------------------------------------------------- /doc/macos_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/macos_5.png -------------------------------------------------------------------------------- /doc/windows_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/windows_2.png -------------------------------------------------------------------------------- /doc/windows_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/windows_3.png -------------------------------------------------------------------------------- /doc/windows_4_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/windows_4_1.png -------------------------------------------------------------------------------- /doc/windows_4_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/windows_4_2.png -------------------------------------------------------------------------------- /doc/windows_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/doc/windows_5.png -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/quickserv/a3477261a6fe902b4f10df7ca0de8cc171c0ac1f/favicon.ico -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 49 | 59 | 60 | 62 | 64 | 65 | 67 | image/svg+xml 68 | 70 | 71 | 72 | 73 | Jacob Strieb 74 | 75 | 76 | 77 | 78 | 79 | 84 | 86 | 90 | 97 | Q 112 | 115 | 122 | Q 137 | 138 | 141 | 143 | 150 | Q 165 | 169 | 176 | Q 191 | 192 | Q 209 | 211 | Q 232 | 233 | 236 | Q 257 | 258 | 259 | 261 | 265 | 274 | 280 | 281 | 285 | 292 | 298 | 299 | 303 | 310 | 316 | 317 | 318 | 319 | 320 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jstrieb/quickserv 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 7 | github.com/jstrieb/killfam v0.0.0-20210723063253-e95463771b4c 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 2 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 3 | github.com/jstrieb/killfam v0.0.0-20210723063253-e95463771b4c h1:ZkrmBEzuyi9UK551ytyFiUw3kGsL96YXGqmCMNrhV28= 4 | github.com/jstrieb/killfam v0.0.0-20210723063253-e95463771b4c/go.mod h1:rv3dDy+TiBUvPLQ5qJZtYHLjkN45kZCXnkF92gGVgI8= 5 | -------------------------------------------------------------------------------- /quickserv.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "crypto/rand" 8 | "embed" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "io/fs" 13 | "log" 14 | "math/big" 15 | "net" 16 | "net/http" 17 | "net/url" 18 | "os" 19 | "os/exec" 20 | "path" 21 | "path/filepath" 22 | "regexp" 23 | "runtime" 24 | "strconv" 25 | "strings" 26 | 27 | "github.com/google/shlex" 28 | "github.com/jstrieb/killfam" 29 | ) 30 | 31 | /****************************************************************************** 32 | * Global Variables and Constants 33 | *****************************************************************************/ 34 | 35 | var logger *log.Logger 36 | var noPause, randomPort bool 37 | var logfileName, wd string 38 | 39 | //go:embed favicon.ico 40 | var embedFS embed.FS 41 | 42 | /****************************************************************************** 43 | * Helper Functions 44 | *****************************************************************************/ 45 | 46 | // NewLogFile initializes the logfile relative to the current working directory. 47 | // As such, for the log file path to be relative to the initial working 48 | // directory, this function must be called before the working directory is 49 | // changed. 50 | func NewLogFile(logfileName string) *log.Logger { 51 | var logfile *os.File 52 | if logfileName == "-" { 53 | logfile = os.Stdout 54 | } else { 55 | mode := os.O_WRONLY | os.O_APPEND | os.O_CREATE 56 | var err error 57 | logfile, err = os.OpenFile(logfileName, mode, os.ModePerm) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | if abspath, err := filepath.Abs(logfileName); err == nil { 62 | fmt.Printf("Logging to file:\n%v\n\n", abspath) 63 | } else { 64 | log.Fatal(err) 65 | } 66 | } 67 | return log.New(logfile, "", log.LstdFlags) 68 | } 69 | 70 | // PickPort returns the port that the server should run on. It either returns 71 | // port 42069 or a random port depending on the value of the argument. 72 | func PickPort(randomPort bool) int64 { 73 | if randomPort { 74 | // Avoid privileged ports (those below 1024). Cryptographic randomness 75 | // might be a bit much here, but ¯\(°_o)/¯ 76 | rawPort, err := rand.Int(rand.Reader, big.NewInt(65535-1025)) 77 | if err != nil { 78 | Fatal(err) 79 | } 80 | port := rawPort.Int64() + 1025 81 | fmt.Printf("Using port %v.\n\n", port) 82 | return port 83 | } else { 84 | return 42069 85 | } 86 | } 87 | 88 | // Fatal prints a fatal error, then pauses before exit so the user can see error 89 | // messages. Useful if they double clicked the executable instead of running it 90 | // from the command line. 91 | func Fatal(s interface{}) { 92 | logger.Println(s) 93 | if !noPause { 94 | fmt.Println("Press Enter to quit!") 95 | fmt.Scanln() 96 | } 97 | os.Exit(1) 98 | } 99 | 100 | // GetLocalIP finds the IP address of the computer on the local area network so 101 | // anyone on the same network can connect to the server. Code inspired by: 102 | // https://stackoverflow.com/a/37382208/1376127 103 | // 104 | // GetLocalIP also returns a modified version of the raw IP string in brackets 105 | // if it is an IPv6 address. See: 106 | // https://en.wikipedia.org/wiki/IPv6_address#Literal_IPv6_addresses_in_network_resource_identifiers 107 | func GetLocalIP() string { 108 | conn, err := net.Dial("udp", "example.com:80") 109 | if err != nil { 110 | logger.Println(err) 111 | logger.Println("Could not get local IP address.") 112 | return "127.0.0.1" 113 | } 114 | defer conn.Close() 115 | 116 | localAddr := conn.LocalAddr().(*net.UDPAddr) 117 | resultString := localAddr.IP.String() 118 | 119 | // If it cannot be coerced into 4-byte representation it's IPv6 (hopefully) 120 | if localAddr.IP.To4() == nil { 121 | resultString = "[" + resultString + "]" 122 | } 123 | return resultString 124 | } 125 | 126 | // DecodeForm performs URL query unescaping on encoded form data to make parsing 127 | // easier. Remaining encoded strings are: 128 | // 129 | // % -> %25 130 | // & -> %26 131 | // = -> %3D 132 | // 133 | // If "%" is not encoded first in the pre-encoding step, then it will encode the 134 | // percent signs from the encoding of & and = in addition to real percent signs, 135 | // which will give incorrect results. 136 | func DecodeForm(form url.Values) ([]byte, error) { 137 | // Pre-encoding step where special characters are encoded before the entire 138 | // form is encoded and then decoded 139 | newForm := make(url.Values, len(form)) 140 | for k, vs := range form { 141 | // Replace equals, percent, and ampersands in form variable names 142 | // NOTE: "%" must be encoded first -- see above 143 | newK := strings.ReplaceAll(k, "%", "%25") 144 | newK = strings.ReplaceAll(newK, "&", "%26") 145 | newK = strings.ReplaceAll(newK, "=", "%3D") 146 | newForm[newK] = make([]string, len(form[k])) 147 | 148 | // Replace equals, percent, and ampersands in form variable values 149 | // NOTE: "%" must be encoded first -- see above 150 | for i, v := range vs { 151 | v = strings.ReplaceAll(v, "%", "%25") 152 | v = strings.ReplaceAll(v, "&", "%26") 153 | v = strings.ReplaceAll(v, "=", "%3D") 154 | newForm[newK][i] = v 155 | } 156 | } 157 | 158 | // Encode the form as a string and decode as almost entirely plain text 159 | rawFormData := []byte(newForm.Encode()) 160 | formData, err := url.QueryUnescape(string(rawFormData)) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | return []byte(formData), nil 166 | } 167 | 168 | // IsWSL returns whether or not the binary is running inside Windows Subsystem 169 | // for Linux (WSL). It guesses at this based on some heuristics. For more, see: 170 | // https://github.com/microsoft/WSL/issues/423#issuecomment-221627364 171 | func IsWSL() bool { 172 | if runtime.GOOS != "linux" { 173 | return false 174 | } 175 | 176 | filesToCheck := []string{"/proc/version", "/proc/sys/kernel/osrelease"} 177 | 178 | r, err := regexp.Compile("(?i)(wsl|microsoft|windows)") 179 | if err != nil { 180 | logger.Println("Error compiling regular expression.") 181 | Fatal(err) 182 | return false 183 | } 184 | 185 | for _, filename := range filesToCheck { 186 | raw, err := os.ReadFile(filename) 187 | if err == nil && r.Match(raw) { 188 | return true 189 | } 190 | } 191 | return false 192 | } 193 | 194 | // ChangeDirIfMacOS changes the working directory to the location of the 195 | // QuickServ executable. This happens only on MacOS if the executable is running 196 | // in the user's home directory, when the executable was called using an 197 | // absolute path, and when the "--dir" flag is not the default. These fairly 198 | // unique set of circumstances occur when a binary is double-clicked on MacOS, 199 | // and is a rough heuristic to detect this. 200 | // 201 | // This irritating procedure is necessary because all double-clicked binaries 202 | // are run with the home directory as the working directory. 203 | func ChangeDirIfMacOS(dirFlag string) { 204 | if runtime.GOOS != "darwin" || dirFlag != "." { 205 | return 206 | } 207 | 208 | exePath := os.Args[0] 209 | if !filepath.IsAbs(exePath) { 210 | return 211 | } 212 | 213 | wd, err := os.Getwd() 214 | if err != nil { 215 | Fatal(err) 216 | } 217 | homeDir, err := os.UserHomeDir() 218 | if err != nil { 219 | Fatal(err) 220 | } 221 | absHome, err := filepath.Abs(homeDir) 222 | if err != nil { 223 | Fatal(err) 224 | } 225 | if wd != absHome { 226 | return 227 | } 228 | 229 | exeDir, _ := filepath.Split(exePath) 230 | if err := os.Chdir(filepath.Clean(exeDir)); err != nil { 231 | Fatal(err) 232 | } 233 | } 234 | 235 | // GetShebang returns the shebang of the input path if possible. If there is no 236 | // shebang, or if the input path is invalid, the empty string is returned. 237 | func GetShebang(path string) string { 238 | f, err := http.Dir(".").Open(path) 239 | if err != nil { 240 | logger.Println(err) 241 | logger.Printf("Can't open file %v to get get first line.\n", path) 242 | return "" 243 | } 244 | defer f.Close() 245 | 246 | stat, err := f.Stat() 247 | if err != nil || stat.IsDir() { 248 | return "" 249 | } 250 | 251 | reader := bufio.NewReader(f) 252 | firstLine, err := reader.ReadBytes('\n') 253 | if err != nil && err != io.EOF { 254 | logger.Println(err) 255 | logger.Printf("Can't read file %v to get get first line.\n", path) 256 | return "" 257 | } 258 | 259 | r, err := regexp.Compile(`^#!\S.*`) 260 | if err != nil { 261 | logger.Fatal(err) 262 | } 263 | 264 | result := r.Find(firstLine) 265 | 266 | // Trim carriage returns added by Windows 267 | return strings.TrimSuffix(strings.TrimPrefix(string(result), "#!"), "\r") 268 | } 269 | 270 | // GetFormAsArguments converts a parsed form such that the variables: 271 | // 272 | // name=value 273 | // n=val 274 | // noval= 275 | // 276 | // become the following. Multi-character names are preceded by two dashes "--" 277 | // while single-character names are preceded by one dash "-". Names with no 278 | // value are passed literally with no preceding dashes. 279 | // 280 | // []string{"--name", "value", "-n", "val", "noval"} 281 | // 282 | // No guarantees are made about the order of the variables in the resulting 283 | // slice, except that every name directly precedes its respective value. 284 | func GetFormAsArguments(form url.Values) []string { 285 | var result []string 286 | for k, vs := range form { 287 | if k != "" { 288 | for _, v := range vs { 289 | if v != "" { 290 | switch len(k) { 291 | case 1: 292 | result = append(result, "-"+k, v) 293 | default: 294 | result = append(result, "--"+k, v) 295 | } 296 | } else { 297 | result = append(result, k) 298 | } 299 | } 300 | } else { 301 | result = append(result, vs...) 302 | } 303 | } 304 | return result 305 | } 306 | 307 | // IsPathExecutable returns whether or not a given file is executable based on 308 | // its file extension and permission bits (depending on the operating system), 309 | // and/or its shebang-style first line (irrespective of operating system). 310 | // 311 | // On Windows, a file is executable if it has a file extension of "exe," "bat," 312 | // or "cmd." On other operating systems, any file with the execute bit set for 313 | // at least one user is deemed executable. 314 | // 315 | // On all operating systems, if a file begins with a shebang (starting with "#!" 316 | // and an executable path), it is deemed executable. 317 | // 318 | // NOTE: This function may not return accurate results if given a directory as 319 | // input. This has not been extensively tested. 320 | func IsPathExecutable(path string, fileinfo fs.FileInfo) bool { 321 | // Check if we are in Windows Subsystem for Linux. If so, behave differently 322 | // since it's Linux but we can run .exe files, and since the permission bits 323 | // are all messed up such that everything will be viewed as executable. 324 | goos := runtime.GOOS 325 | if IsWSL() { 326 | goos = "wsl" 327 | } 328 | 329 | switch strings.ToLower(goos) { 330 | case "windows": 331 | // Register executable handlers based on file extension 332 | switch strings.ToLower(filepath.Ext(path)) { 333 | case ".exe", ".bat", ".cmd": 334 | return true 335 | } 336 | 337 | case "wsl": 338 | // Register executable handlers based on file extension 339 | switch strings.ToLower(filepath.Ext(path)) { 340 | case ".exe": 341 | return true 342 | } 343 | 344 | default: 345 | // TODO: Does it make sense to look for files executable by any user? 346 | filemode := fileinfo.Mode() 347 | if !filemode.IsDir() && filemode.Perm()&0111 != 0 { 348 | return true 349 | } 350 | } 351 | 352 | return GetShebang(path) != "" 353 | } 354 | 355 | // ExecutePath executes the file at the path, passes the request body via 356 | // standard input, gets the response via standard output and writes that as the 357 | // response body. 358 | // 359 | // NOTE: Expects the input path to be rooted with forward slashes as the 360 | // separator (HTTP request style) 361 | func ExecutePath(ctx context.Context, execPath string, w http.ResponseWriter, r *http.Request) { 362 | logger.Println("Executing:", execPath) 363 | 364 | // Clean up the path and make it un-rooted 365 | if strings.HasPrefix(execPath, "/") { 366 | execPath = "." + execPath 367 | } 368 | execPath = path.Clean(execPath) 369 | abspath, err := filepath.Abs(filepath.FromSlash(execPath)) 370 | if err != nil { 371 | logger.Println(err) 372 | http.Error(w, http.StatusText(500), 500) 373 | return 374 | } 375 | dir, _ := filepath.Split(abspath) 376 | 377 | // Get form variables as additional arguments if applicable 378 | var formArguments []string 379 | if r.Method == "GET" || (len(r.Header["Content-Type"]) > 0 && 380 | r.Header["Content-Type"][0] == "application/x-www-form-urlencoded") { 381 | // Parse form data into r.Form 382 | err = r.ParseForm() 383 | if err != nil { 384 | logger.Println(err) 385 | logger.Println("Couldn't parse the request form.") 386 | http.Error(w, http.StatusText(500), 500) 387 | return 388 | } 389 | 390 | formArguments = GetFormAsArguments(r.Form) 391 | } 392 | 393 | var cmd *exec.Cmd 394 | if shebang := GetShebang(execPath); shebang == "" { 395 | cmd = exec.Command(abspath, formArguments...) 396 | } else { 397 | // Split the shebang using github.com/google/shlex. See 398 | // https://github.com/jstrieb/quickserv/pull/2 for discussion 399 | splitShebang, err := shlex.Split(shebang) 400 | if err != nil { 401 | logger.Println(err) 402 | logger.Println("Couldn't parse the shebang.") 403 | http.Error(w, http.StatusText(500), 500) 404 | return 405 | } 406 | splitShebang = append(splitShebang, abspath) 407 | cmd = exec.Command( 408 | splitShebang[0], append(splitShebang[1:], formArguments...)..., 409 | ) 410 | } 411 | 412 | // Create the command using all environment variables. Include a 413 | // REQUEST_METHOD environment variable in imitation of CGI 414 | cmd.Env = append(os.Environ(), "REQUEST_METHOD="+r.Method) 415 | 416 | // I tried to do exec.CommandContext here, but it doesn't kill child 417 | // processes, so anything run from a script keeps on going when the 418 | // connection terminates. Instead I use a goroutine listening to the context 419 | // with a custom package to make the process children killable. 420 | killfam.Augment(cmd) 421 | 422 | // Pass headers as environment variables in imitation of CGI 423 | for k, v := range r.Header { 424 | // The same header can have multiple values 425 | for _, s := range v { 426 | cmd.Env = append(cmd.Env, "HTTP_"+strings.ReplaceAll(k, "-", "_")+"="+s) 427 | } 428 | } 429 | 430 | // Execute the route in its own directory so relative paths in the executed 431 | // program behave sensibly 432 | cmd.Dir = dir 433 | 434 | // Pass request body on standard input 435 | stdin, err := cmd.StdinPipe() 436 | if err != nil { 437 | logger.Println(err) 438 | logger.Println("Couldn't pass the request body via stdin.") 439 | http.Error(w, http.StatusText(500), 500) 440 | return 441 | } 442 | go func() { 443 | defer stdin.Close() 444 | 445 | if r.Method == "GET" || (len(r.Header["Content-Type"]) > 0 && 446 | r.Header["Content-Type"][0] == "application/x-www-form-urlencoded") { 447 | // If the submission is a GET request, or is a form submission 448 | // according to content type, treat it like a form 449 | formData, err := DecodeForm(r.Form) 450 | if err != nil { 451 | logger.Println(err) 452 | logger.Println("Couldn't percent-decode the request form.") 453 | http.Error(w, http.StatusText(500), 500) 454 | return 455 | } 456 | _, err = io.Copy(stdin, bytes.NewReader(formData)) 457 | if err != nil { 458 | logger.Println(err) 459 | logger.Println("Couldn't copy the form data to the program.") 460 | http.Error(w, http.StatusText(500), 500) 461 | return 462 | } 463 | } else { 464 | // This POST/PUT/DELETE data is not form data (may be a JSON API 465 | // request, for example), so don't encode it as a form. If it is a 466 | // multipart or other form submission, it will be properly encoded 467 | // already. 468 | _, err := io.Copy(stdin, r.Body) 469 | if err != nil { 470 | logger.Println(err) 471 | logger.Println("Couldn't copy the request body to the program.") 472 | http.Error(w, http.StatusText(500), 500) 473 | return 474 | } 475 | } 476 | }() 477 | 478 | // Print out stderror messages for debugging 479 | stderr, err := cmd.StderrPipe() 480 | if err != nil { 481 | logger.Println(err) 482 | logger.Println("Couldn't get stderr output for printing.") 483 | http.Error(w, http.StatusText(500), 500) 484 | return 485 | } 486 | go func() { 487 | defer stderr.Close() 488 | data, _ := io.ReadAll(stderr) 489 | if len(data) > 0 { 490 | logger.Println(string(data)) 491 | } 492 | }() 493 | 494 | // Kill the process if the user terminates their connection 495 | cmdDone := make(chan error) 496 | go func() { 497 | select { 498 | case <-ctx.Done(): 499 | logger.Println("User disconnected. Killing program.") 500 | if err := killfam.KillTree(cmd); err != nil { 501 | logger.Println(err) 502 | } 503 | 504 | case <-cmdDone: 505 | return 506 | } 507 | }() 508 | 509 | // Execute the command and write the output as the HTTP response 510 | out, err := cmd.Output() 511 | cmdDone <- err 512 | if err != nil { 513 | logger.Println(err) 514 | http.Error(w, http.StatusText(500), 500) 515 | return 516 | } 517 | 518 | w.Write(out) 519 | } 520 | 521 | // FindIndexFile returns the path to the index file of the directory path given 522 | // as input (if one exists). If there is no index file, or if there was a fatal 523 | // error during the search, the the first returned value is the empty string and 524 | // the second value is false. 525 | // 526 | // NOTE: The input dir is expected to be a rooted path with forward slashes, and 527 | // the output has the same format 528 | func FindIndexFile(dir string) (string, bool) { 529 | file, err := http.Dir(".").Open(dir) 530 | if err != nil { 531 | return "", false 532 | } 533 | defer file.Close() 534 | 535 | files, err := file.Readdir(-1) 536 | if err != nil { 537 | return "", false 538 | } 539 | for _, file := range files { 540 | filename := file.Name() 541 | if IsPathExecutable(path.Clean(dir+"/"+filename), file) && 542 | strings.ToLower(strings.TrimSuffix(filename, path.Ext(filename))) == "index" { 543 | return path.Join(dir, filename), true 544 | } 545 | } 546 | return "", false 547 | } 548 | 549 | // FindExecutablePaths walks the current directory and locates paths that will 550 | // be executed when visited. It returns them as a map. In the map keys are paths 551 | // that cause a file to be executed, and values are either the empty string or 552 | // the file to be executed when the path is accessed. 553 | func FindExecutablePaths(logfileName string) (map[string]string, error) { 554 | routes := make(map[string]string) 555 | 556 | err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { 557 | if err != nil { 558 | return nil 559 | } 560 | 561 | // Clean up the path and format it HTTP-style 562 | path = filepath.Clean(path) 563 | path = "/" + filepath.ToSlash(path) 564 | 565 | // Ignore the executable for quickserv itself if it's in the directory. 566 | // Also ignore the logfile if it's present. 567 | _, filename := filepath.Split(path) 568 | fileinfo, err := d.Info() 569 | if err != nil { 570 | logger.Printf("Couldn't get file info for %v.\n", filename) 571 | return nil 572 | } 573 | if f := strings.ToLower(filename); f == logfileName || strings.HasPrefix(f, "quickserv_") { 574 | return nil 575 | } 576 | 577 | // Find the index file if path is a directory 578 | if fileinfo.IsDir() { 579 | index, found := FindIndexFile(path) 580 | if found { 581 | routes[path] = index 582 | } 583 | return nil 584 | } 585 | 586 | // Print a result if executable 587 | if IsPathExecutable(path, fileinfo) { 588 | routes[path] = "" 589 | } 590 | return nil 591 | }) 592 | 593 | return routes, err 594 | } 595 | 596 | // ServeStaticFile serves static files in one of two ways. First, it tries to 597 | // find a default file in the embedded filesystem. If that doesn't work, it 598 | // falls back on the input fileserver. 599 | func ServeStaticFile(local http.Handler, reqPath string, w http.ResponseWriter, r *http.Request) { 600 | reqPath = strings.TrimPrefix(reqPath, "/") 601 | 602 | f, err := embedFS.Open(reqPath) 603 | if err != nil { 604 | logger.Println(err) 605 | // If we can't open the file, let the FileServer handle it correctly 606 | local.ServeHTTP(w, r) 607 | return 608 | } 609 | defer f.Close() 610 | 611 | d, err := f.Stat() 612 | if err != nil { 613 | logger.Println(err) 614 | // If we can't open the file, let the FileServer handle it correctly 615 | local.ServeHTTP(w, r) 616 | return 617 | } 618 | 619 | logger.Printf("Serving default file %v\n", reqPath) 620 | 621 | // See: https://github.com/golang/go/issues/44175#issuecomment-775545730 622 | http.ServeContent(w, r, reqPath, d.ModTime(), f.(io.ReadSeeker)) 623 | } 624 | 625 | // NewMainHandler returns an http.Handler that looks at the file a user requests 626 | // and decides whether to execute it, or pass it to an http.FileServer. 627 | func NewMainHandler(filesystem http.FileSystem) http.Handler { 628 | fileserver := http.FileServer(filesystem) 629 | 630 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 631 | // Write maximally permissive CORS headers 632 | w.Header().Set("Access-Control-Allow-Origin", "*") 633 | w.Header().Set("Access-Control-Allow-Methods", "*") 634 | w.Header().Set("Access-Control-Allow-Headers", "*") 635 | w.Header().Set("Access-Control-Expose-Headers", "*") 636 | 637 | // Clean up the request path 638 | reqPath := r.URL.Path 639 | if !strings.HasPrefix(reqPath, "/") { 640 | reqPath = "/" + reqPath 641 | r.URL.Path = reqPath 642 | } 643 | reqPath = path.Clean(reqPath) 644 | 645 | // Open the path in the filesystem for further inspection 646 | f, err := filesystem.Open(reqPath) 647 | if err != nil { 648 | // If we can't open the file, try to serve a default version or let 649 | // the FileServer handle it correctly 650 | ServeStaticFile(fileserver, reqPath, w, r) 651 | return 652 | } 653 | defer f.Close() 654 | d, err := f.Stat() 655 | if err != nil { 656 | // If we can't open the file, try to serve a default version or let 657 | // the FileServer handle it correctly 658 | ServeStaticFile(fileserver, reqPath, w, r) 659 | return 660 | } 661 | 662 | // If the path is a directory, look for an index file. If none found, 663 | // serve up the directory. Otherwise, act like the executable was the 664 | // original requested path. 665 | if d.IsDir() { 666 | index, found := FindIndexFile(reqPath) 667 | if !found { 668 | fileserver.ServeHTTP(w, r) 669 | return 670 | } else { 671 | reqPath = index 672 | fNew, err := filesystem.Open(reqPath) 673 | if err != nil { 674 | // If we can't open the file, let the FileServer handle it correctly 675 | logger.Println(err) 676 | fileserver.ServeHTTP(w, r) 677 | return 678 | } 679 | defer fNew.Close() 680 | d, err = fNew.Stat() 681 | if err != nil { 682 | // If we can't open the file, let the FileServer handle it correctly 683 | logger.Println(err) 684 | fileserver.ServeHTTP(w, r) 685 | return 686 | } 687 | } 688 | } 689 | 690 | if IsPathExecutable(reqPath, d) { 691 | // If the path is executable, run it 692 | ExecutePath(r.Context(), reqPath, w, r) 693 | } else { 694 | fileserver.ServeHTTP(w, r) 695 | } 696 | }) 697 | } 698 | 699 | /****************************************************************************** 700 | * Main Function 701 | *****************************************************************************/ 702 | 703 | func init() { 704 | // Parse command line arguments 705 | flag.StringVar(&logfileName, "logfile", "-", "Log file path. Stdout if unspecified.") 706 | flag.StringVar(&wd, "dir", ".", "Folder to serve files from.") 707 | flag.BoolVar(&randomPort, "random-port", false, "Use a random port instead of 42069.") 708 | flag.BoolVar(&noPause, "no-pause", false, "Don't pause before exiting after fatal error.") 709 | flag.Parse() 710 | } 711 | 712 | func main() { 713 | logger = NewLogFile(logfileName) 714 | 715 | // Switch directories and print the current working directory 716 | ChangeDirIfMacOS(wd) 717 | if err := os.Chdir(wd); err != nil { 718 | Fatal(err) 719 | } 720 | wd, err := os.Getwd() 721 | if err != nil { 722 | Fatal(err) 723 | } 724 | fmt.Printf("Running in folder:\n%v\n\n", wd) 725 | 726 | // Print non-static routes that will be executed (if any) 727 | routes, err := FindExecutablePaths(logfileName) 728 | if err != nil { 729 | Fatal(err) 730 | } 731 | if len(routes) > 0 { 732 | fmt.Println("Files that will be executed if accessed: ") 733 | for k, v := range routes { 734 | if v == "" { 735 | fmt.Println(k) 736 | } else { 737 | fmt.Printf("%v -> %v\n", k, v) 738 | } 739 | } 740 | } else { 741 | logger.Println("No executable files found!") 742 | fmt.Println(` 743 | To make a script executable: start the first line with "#!xxx" where "xxx" is 744 | the command to run the script. For example, if you normally run your code with 745 | "python3 myfile.py" make the first line of myfile.py be "#!python3" (without 746 | quotation marks). 747 | 748 | For more information see the documentation here: 749 | https://github.com/jstrieb/quickserv`) 750 | } 751 | fmt.Println("") 752 | 753 | // Pick a random port if the user wants -- for slightly more professional 754 | // demos where the number 42069 might be undesirable 755 | port := PickPort(randomPort) 756 | 757 | localIP := GetLocalIP() 758 | logger.Println("Starting a server...") 759 | fmt.Printf("Visit http://%v:%v to access the server from the local network.\n", localIP, port) 760 | fmt.Print("Press Control + C or close this window to stop the server.\n\n") 761 | 762 | // Build a handler that decides whether to serve static files or dynamically 763 | // execute them 764 | handler := NewMainHandler(http.Dir(".")) 765 | addr := ":" + strconv.FormatInt(port, 10) 766 | if err = http.ListenAndServe(addr, handler); err != nil { 767 | logger.Println("Make sure you are only running one instance of QuickServ!") 768 | Fatal(err) 769 | } 770 | } 771 | --------------------------------------------------------------------------------