├── .github └── screenshot.png ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── bench ├── bench └── main.lua ├── go.mod ├── internal └── humanize │ └── humanize.go └── main.go /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuarli/srv/fb87813ed0c9b5d243753496a311fa85d51bf6d1/.github/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | srv 2 | srv-debug 3 | build/ 4 | release/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | ## [0.6] - Unreleased 10 | 11 | Nothing planned at the moment. 12 | 13 | 14 | ## [0.5] - Feb 14 2021 15 | ### Fixed 16 | - Correctness fixes with serving more complicated filenames (links needed to be path escaped, and request uris path unescaped). 17 | 18 | ## [0.4] - 2020-10-09 19 | ### Changed 20 | - Mimetypes are now inferred firstly by file extension, and go's DetectContentType failing that. 21 | 22 | ### Fixed 23 | - As a result of the mimetype inference change mentioned above, CSS files for example will now be served with the appropriate text/css mimetype. 24 | 25 | ## [0.3] - 2020-08-16 26 | ### Added 27 | - Usage now shows Go's runtime version. Also builds with 0.15. 28 | 29 | ### Changed 30 | - User Agent strings are now logged. 31 | 32 | ### Fixed 33 | - Links to filenames with quotes are now html-escaped so they work. 34 | 35 | ## [0.2] - 2020-06-05 36 | ### Added 37 | - Custom bind address with `-b address`. 38 | - Optional TLS with `-c certfile -k keyfile`. 39 | 40 | ### Changed 41 | - Directory entries are now naturally/alphanumerically sorted. 42 | - Symlinks were made forbidden. 43 | - Sends `Cache-Control: no-store` for HTTP 1.1+ clients that obey it (pretty much all major browsers). 44 | - Rendering performance and size was improved. 45 | - Browsers should not request favicons anymore. 46 | 47 | ## 0.1 - 2019-09-03 48 | Initial release. 49 | 50 | 51 | [0.6]: https://github.com/joshuarli/srv/compare/0.6...HEAD 52 | [0.5]: https://github.com/joshuarli/srv/compare/0.4...0.5 53 | [0.4]: https://github.com/joshuarli/srv/compare/0.3...0.4 54 | [0.3]: https://github.com/joshuarli/srv/compare/0.2...0.3 55 | [0.2]: https://github.com/joshuarli/srv/compare/0.1...0.2 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 JoshuaRLi (Joshua Li) 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := srv 2 | VERSION := $(shell printf git-%s-%s\\n "$$(git describe --tags --abbrev=0)" "$$(git log -1 --pretty=format:'%h')") 3 | GO_BUILDFLAGS := -trimpath 4 | GO_LDFLAGS := -ldflags "-s -w -X main.VERSION=$(VERSION)" 5 | GO_LDFLAGS_DEBUG := -ldflags "-X main.VERSION=$(VERSION)-DEBUG" 6 | GO_LDFLAGS_STATIC := -tags netgo -ldflags "-s -w -X main.VERSION=$(VERSION) -extldflags -static" 7 | 8 | .PHONY: build debug fmt lint clean release 9 | 10 | build: clean fmt lint $(NAME) 11 | 12 | $(NAME): main.go 13 | go build $(GO_BUILDFLAGS) -o $@ $(GO_LDFLAGS) . 14 | 15 | debug: $(NAME)-debug 16 | $(NAME)-debug: main.go 17 | go build $(GO_BUILDFLAGS) -o $@ -gcflags="all=-N -l" $(GO_LDFLAGS_DEBUG) . 18 | 19 | fmt: 20 | go fmt 21 | 22 | lint: 23 | golint 24 | 25 | clean: 26 | rm -f $(NAME) $(NAME)-debug 27 | rm -rf release 28 | 29 | # release static crossbuilds 30 | define buildrelease 31 | GOOS=$(1) GOARCH=$(2) go build $(GO_BUILDFLAGS) \ 32 | -a \ 33 | -o release/$(NAME)-$(1)-$(2) \ 34 | $(GO_LDFLAGS_STATIC) . ; 35 | upx -9 release/$(NAME)-$(1)-$(2); 36 | sha512sum release/$(NAME)-$(1)-$(2) > release/$(NAME)-$(1)-$(2).sha512sum; 37 | endef 38 | 39 | GOOSARCHES = linux/arm linux/arm64 linux/amd64 darwin/amd64 40 | 41 | release: main.go 42 | $(foreach GOOSARCH,$(GOOSARCHES), $(call buildrelease,$(subst /,,$(dir $(GOOSARCH))),$(notdir $(GOOSARCH)))) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # srv 2 | 3 | minimalist http(s) server and file browser. 4 | 5 | 6 | 7 | 8 | ## download 9 | 10 | static executables for some platforms can be found [here](https://github.com/joshuarli/srv/releases). 11 | 12 | 13 | ## usage 14 | 15 | Simply `srv`. Defaults are `-p 8000 -b 127.0.0.1 -d .` 16 | 17 | 18 | ## usage: TLS 19 | 20 | TLS and HTTP/2 are enabled if you pass `-c certfile -k keyfile`. 21 | 22 | to make self-signed certs: 23 | 24 | openssl req -nodes -new -x509 -keyout key.pem -out cert.pem -subj "/" 25 | 26 | or better, locally trusted certs with [mkcert](https://github.com/FiloSottile/mkcert): 27 | 28 | mkcert -install 29 | mkcert -key-file key.pem -cert-file cert.pem -ecdsa 127.0.0.1 30 | -------------------------------------------------------------------------------- /bench/bench: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | wrk -t8 -c8 -d5s -s "${HERE}/main.lua" http://127.0.0.1:8000 6 | -------------------------------------------------------------------------------- /bench/main.lua: -------------------------------------------------------------------------------- 1 | local threads = {} 2 | 3 | function setup(thread) 4 | table.insert(threads, thread) 5 | thread:set("tid", table.getn(threads)) 6 | end 7 | 8 | function init(args) 9 | responses = {} 10 | end 11 | 12 | wrk.method = "GET" 13 | wrk.path = "/" 14 | 15 | function response(status, headers, body) 16 | if responses[status] == nil then 17 | responses[status] = 1 18 | else 19 | responses[status] = responses[status] + 1 20 | end 21 | end 22 | 23 | function done(summary, latency, requests) 24 | print("wrk is done. response code counts:") 25 | local freqs = {} 26 | for _, thread in pairs(threads) do 27 | for code, freq in pairs(thread:get("responses")) do 28 | if freqs[code] == nil then 29 | freqs[code] = freq 30 | else 31 | freqs[code] = freqs[code] + freq 32 | end 33 | end 34 | end 35 | for code, freq in pairs(freqs) do 36 | print(code, freq) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joshuarli/srv 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /internal/humanize/humanize.go: -------------------------------------------------------------------------------- 1 | package humanize 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func FileSize(nbytes int64) string { 8 | if nbytes < 1024 { 9 | return fmt.Sprintf("%d", nbytes) 10 | } 11 | var exp int 12 | n := float64(nbytes) 13 | for exp = 0; exp < 4; exp++ { 14 | n /= 1024 15 | if n < 1024 { 16 | break 17 | } 18 | } 19 | return fmt.Sprintf("%.1f%c", float64(n), "KMGT"[exp]) 20 | } 21 | 22 | func isdigit(b byte) bool { return '0' <= b && b <= '9' } 23 | 24 | // Vendored and slightly adapted from github.com/fvbommel/util/blob/master/sortorder/natsort.go 25 | // I wrote my own alphanumeric/natural sort, but it turned out to be inferior to this one. 26 | // Limitations (deliberate tradeoffs for speed and simplicity): 27 | // - only ASCII digits (0-9) are considered 28 | // - does not understand signs, scientific notation, or floating point representations 29 | func NaturalLess(str1, str2 string) bool { 30 | idx1, idx2 := 0, 0 31 | for idx1 < len(str1) && idx2 < len(str2) { 32 | c1, c2 := str1[idx1], str2[idx2] 33 | dig1, dig2 := isdigit(c1), isdigit(c2) 34 | switch { 35 | case dig1 != dig2: // Digits before other characters. 36 | return dig1 // True if LHS is a digit, false if the RHS is one. 37 | case !dig1: // && !dig2, because dig1 == dig2 38 | // UTF-8 compares bytewise-lexicographically, no need to decode 39 | // codepoints. 40 | if c1 != c2 { 41 | return c1 < c2 42 | } 43 | idx1++ 44 | idx2++ 45 | default: // Digits 46 | // Eat zeros. 47 | for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ { 48 | } 49 | for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ { 50 | } 51 | // Eat all digits. 52 | nonZero1, nonZero2 := idx1, idx2 53 | for ; idx1 < len(str1) && isdigit(str1[idx1]); idx1++ { 54 | } 55 | for ; idx2 < len(str2) && isdigit(str2[idx2]); idx2++ { 56 | } 57 | // If lengths of numbers with non-zero prefix differ, the shorter 58 | // one is less. 59 | if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 { 60 | return len1 < len2 61 | } 62 | // If they're equal, string comparison is correct. 63 | if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 { 64 | return nr1 < nr2 65 | } 66 | // Otherwise, the one with less zeros is less. 67 | // Because everything up to the number is equal, comparing the index 68 | // after the zeros is sufficient. 69 | if nonZero1 != nonZero2 { 70 | return nonZero1 < nonZero2 71 | } 72 | } 73 | // They're identical so far, so continue comparing. 74 | } 75 | // So far they are identical. At least one is ended. If the other continues, 76 | // it sorts last. 77 | return len(str1) < len(str2) 78 | } 79 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path" 13 | "runtime" 14 | "sort" 15 | "strings" 16 | "time" 17 | 18 | "github.com/joshuarli/srv/internal/humanize" 19 | ) 20 | 21 | type context struct { 22 | srvDir string 23 | } 24 | 25 | // We write the shortest browser-valid base64 data string, 26 | // so that the browser does not request the favicon. 27 | const listingPrelude = ` 28 | ` 29 | 30 | func renderListing(w http.ResponseWriter, r *http.Request, f *os.File) error { 31 | files, err := f.Readdir(-1) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | io.WriteString(w, listingPrelude) 37 | 38 | sort.Slice(files, func(i, j int) bool { 39 | // TODO: add switch to make case sensitive 40 | // TODO: add switch to disable natural sort 41 | return humanize.NaturalLess( 42 | strings.ToLower(files[i].Name()), 43 | strings.ToLower(files[j].Name()), 44 | ) 45 | }) 46 | 47 | var fn, fnEscaped string 48 | for _, fi := range files { 49 | fn = fi.Name() 50 | fnEscaped = url.PathEscape(fn) 51 | switch m := fi.Mode(); { 52 | // is a directory - render a link 53 | case m&os.ModeDir != 0: 54 | fmt.Fprintf(w, "", fnEscaped, fn) 55 | // is a regular file - render both a link and a file size 56 | case m&os.ModeType == 0: 57 | fs := humanize.FileSize(fi.Size()) 58 | fmt.Fprintf(w, "", fnEscaped, fn, fs) 59 | // otherwise, don't render a clickable link 60 | default: 61 | fmt.Fprintf(w, "", fn) 62 | } 63 | } 64 | 65 | io.WriteString(w, "
%s/
%s%s

%s

") 66 | return nil 67 | } 68 | 69 | func (c *context) handler(w http.ResponseWriter, r *http.Request) { 70 | // TODO: better log styling 71 | log.Printf("\t%s [%s]: %s %s %s", r.RemoteAddr, r.UserAgent(), r.Method, r.Proto, r.Host+r.RequestURI) 72 | 73 | // Tell HTTP 1.1+ clients to not cache responses. 74 | w.Header().Set("Cache-Control", "no-store") 75 | 76 | switch r.Method { 77 | case http.MethodGet: 78 | // Filenames could contain special uri characters, so we use r.RequestURI 79 | // instead of r.URL.Path. 80 | // XXX: Might also have to do QueryUnescape (and then also QueryEscape in the renderer), 81 | // but haven't run into that as a need in my usage. 82 | fp, err := url.PathUnescape(r.RequestURI) 83 | if err != nil { 84 | http.Error(w, fmt.Sprintf("failed to path unescape: %s", err), http.StatusInternalServerError) 85 | return 86 | } 87 | fp = path.Join(c.srvDir, fp) 88 | fi, err := os.Lstat(fp) 89 | if err != nil { 90 | // NOTE: errors.Is is generally preferred, since it can unwrap errors created like so: 91 | // fmt.Errorf("can't read file: %w", err) 92 | // But in this case we just want to check right after a stat. 93 | if os.IsNotExist(err) { 94 | http.Error(w, "file not found", http.StatusNotFound) 95 | return 96 | } 97 | http.Error(w, fmt.Sprintf("failed to stat file: %s", err), http.StatusInternalServerError) 98 | return 99 | } 100 | 101 | f, err := os.Open(fp) 102 | if err != nil { 103 | http.Error(w, fmt.Sprintf("failed to open file: %s", err), http.StatusInternalServerError) 104 | return 105 | } 106 | defer f.Close() 107 | 108 | switch m := fi.Mode(); { 109 | // is a directory - serve an index.html if it exists, otherwise generate and serve a directory listing 110 | case m&os.ModeDir != 0: 111 | // XXX: if a symlink has name "index.html", it will be served here. 112 | // i could add an extra lstat here, but the scenario is just too rare 113 | // to justify the additional file operation. 114 | html, err := os.Open(path.Join(fp, "index.html")) 115 | if err == nil { 116 | io.Copy(w, html) 117 | html.Close() 118 | return 119 | } 120 | html.Close() 121 | err = renderListing(w, r, f) 122 | if err != nil { 123 | http.Error(w, "failed to render directory listing: "+err.Error(), http.StatusInternalServerError) 124 | } 125 | // is a regular file - serve its contents 126 | case m&os.ModeType == 0: 127 | // This deduces a mimetype from the file extension first, then falls back to DetectContentType. 128 | // io.Copy'ing would only DetectContentType, which is insufficient for like, css files. 129 | http.ServeContent(w, r, fp, time.Time{}, f) 130 | // is a symlink - refuse to serve 131 | case m&os.ModeSymlink != 0: 132 | // TODO: add a flag to allow serving symlinks 133 | http.Error(w, "file is a symlink", http.StatusForbidden) 134 | default: 135 | http.Error(w, "file isn't a regular file or directory", http.StatusForbidden) 136 | } 137 | default: 138 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 139 | } 140 | } 141 | 142 | func die(format string, v ...interface{}) { 143 | fmt.Fprintf(os.Stderr, format, v...) 144 | os.Stderr.Write([]byte("\n")) 145 | os.Exit(1) 146 | } 147 | 148 | // VERSION passed at build time 149 | var VERSION = "unknown" 150 | 151 | func main() { 152 | flag.Usage = func() { 153 | die(`srv %s (go version %s) 154 | 155 | usage: %s [-q] [-p port] [-c certfile -k keyfile] directory 156 | 157 | directory path to directory to serve (default: .) 158 | 159 | -q quiet; disable all logging 160 | -p port port to listen on (default: 8000) 161 | -b address listener socket's bind address (default: 127.0.0.1) 162 | -c certfile optional path to a PEM-format X.509 certificate 163 | -k keyfile optional path to a PEM-format X.509 key 164 | `, VERSION, runtime.Version(), os.Args[0]) 165 | } 166 | 167 | var quiet bool 168 | var port, bindAddr, certFile, keyFile string 169 | flag.BoolVar(&quiet, "q", false, "") 170 | flag.StringVar(&port, "p", "8000", "") 171 | flag.StringVar(&bindAddr, "b", "127.0.0.1", "") 172 | flag.StringVar(&certFile, "c", "", "") 173 | flag.StringVar(&keyFile, "k", "", "") 174 | flag.Parse() 175 | 176 | certFileSpecified := certFile != "" 177 | keyFileSpecified := keyFile != "" 178 | if certFileSpecified != keyFileSpecified { 179 | die("You must specify both -c certfile -k keyfile.") 180 | } 181 | 182 | listenAddr := net.JoinHostPort(bindAddr, port) 183 | _, err := net.ResolveTCPAddr("tcp", listenAddr) 184 | if err != nil { 185 | die("Could not resolve the address to listen to: %s", listenAddr) 186 | } 187 | 188 | srvDir := "." 189 | posArgs := flag.Args() 190 | if len(posArgs) > 0 { 191 | srvDir = posArgs[0] 192 | } 193 | f, err := os.Open(srvDir) 194 | if err != nil { 195 | die(err.Error()) 196 | } 197 | defer f.Close() 198 | if fi, err := f.Stat(); err != nil || !fi.IsDir() { 199 | die("%s isn't a directory.", srvDir) 200 | } 201 | 202 | c := &context{ 203 | srvDir: srvDir, 204 | } 205 | 206 | if quiet { 207 | log.SetFlags(0) // disable log formatting to save cpu 208 | log.SetOutput(io.Discard) 209 | } 210 | 211 | http.HandleFunc("/", c.handler) 212 | 213 | if certFileSpecified && keyFileSpecified { 214 | log.Printf("\tServing %s over HTTPS on %s", srvDir, listenAddr) 215 | err = http.ListenAndServeTLS(listenAddr, certFile, keyFile, nil) 216 | } else { 217 | log.Printf("\tServing %s over HTTP on %s", srvDir, listenAddr) 218 | err = http.ListenAndServe(listenAddr, nil) 219 | } 220 | 221 | die(err.Error()) 222 | } 223 | --------------------------------------------------------------------------------