├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------