Merge pull request #6 from halverneus/feature/refactor_and_unit_testing

Feature/refactor and unit testing
This commit is contained in:
Jeromy Streets 2018-07-29 12:04:06 -07:00 committed by GitHub
commit 24c4662416
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1765 additions and 254 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
vendor/

View File

@ -1,7 +1,20 @@
FROM golang:latest as builder
COPY serve.go /
WORKDIR /
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o serve .
FROM golang:1.10.3 as builder
ENV BUILD_DIR /go/src/github.com/halverneus/static-file-server
ENV MAIN github.com/halverneus/static-file-server/bin/serve
ENV DEP_VERSION v0.4.1
RUN curl -fsSL -o /usr/local/bin/dep \
https://github.com/golang/dep/releases/download/$DEP_VERSION/dep-linux-amd64 && \
chmod +x /usr/local/bin/dep
RUN mkdir -p ${BUILD_DIR}
WORKDIR ${BUILD_DIR}
COPY . .
RUN dep ensure -vendor-only
RUN go test -race -cover ./...
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o /serve ${MAIN}
FROM scratch
COPY --from=builder /serve /

15
Gopkg.lock generated Normal file
View File

@ -0,0 +1,15 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183"
version = "v2.2.1"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "ce4cfa8aa3eb29f45e7ba341fdeac9820969e663181e81bddfc4a3aa2d5169bb"
solver-name = "gps-cdcl"
solver-version = 1

34
Gopkg.toml Normal file
View File

@ -0,0 +1,34 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
# [prune]
# non-go = false
# go-tests = true
# unused-packages = true
[[constraint]]
name = "gopkg.in/yaml.v2"
version = "2.2.1"
[prune]
go-tests = true
unused-packages = true

View File

@ -25,25 +25,40 @@ TLS_CERT=
TLS_KEY=
```
### Without Docker
Matching configuration YAML settings. YAML settings are individually overridden
by the corresponding environment variable. The following is an example
configuration file with defaults. Pass in the path to the configuration file
using the command line option ('-c', '-config', '--config').
```yaml
host: ""
port: 8080
show-listing: true
folder: /web
url-prefix: ""
tls-cert: ""
tls-key: ""
```
### Without Docker
```bash
PORT=8888 FOLDER=. ./serve
```
Files can then be accessed by going to http://localhost:8888/my/file.txt
### With Docker
```
```bash
docker run -d -v /my/folder:/web -p 8080:8080 halverneus/static-file-server:latest
```
This will serve the folder "/my/folder" over http://localhost:9090/my/file.txt
This will serve the folder "/my/folder" over http://localhost:8080/my/file.txt
Any of the variables can also be modified:
```
```bash
docker run -d -v /home/me/dev/source:/content/html -v /home/me/dev/files:/content/more/files -e FOLDER=/content -p 8080:8080 halverneus/static-file-server:latest
```
### Also try...
```
```bash
./serve help
# OR
docker run -it halverneus/static-file-server:latest help

13
bin/serve/main.go Normal file
View File

@ -0,0 +1,13 @@
package main
import (
"log"
"github.com/halverneus/static-file-server/cli"
)
func main() {
if err := cli.Execute(); nil != err {
log.Fatalf("Error: %v\n", err)
}
}

27
cli/args.go Normal file
View File

@ -0,0 +1,27 @@
package cli
// Args parsed from the command-line.
type Args []string
// Parse command-line arguments into Args. Value is returned to support daisy
// chaining.
func Parse(values []string) Args {
args := Args(values)
return args
}
// Matches is used to determine if the arguments match the provided pattern.
func (args Args) Matches(pattern ...string) bool {
// If lengths don't match then nothing does.
if len(pattern) != len(args) {
return false
}
// Compare slices using '*' as a wildcard.
for index, value := range pattern {
if "*" != value && value != args[index] {
return false
}
}
return true
}

81
cli/args_test.go Normal file
View File

@ -0,0 +1,81 @@
package cli
import (
"testing"
)
func TestParse(t *testing.T) {
matches := func(args Args, orig []string) bool {
if nil == orig {
return nil == args
}
if len(orig) != len(args) {
return false
}
for index, value := range args {
if orig[index] != value {
return false
}
}
return true
}
testCases := []struct {
name string
value []string
}{
{"Nil arguments", nil},
{"No arguments", []string{}},
{"Arguments", []string{"first", "second", "*"}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if args := Parse(tc.value); !matches(args, tc.value) {
t.Errorf("Expected [%v] but got [%v]", tc.value, args)
}
})
}
}
func TestMatches(t *testing.T) {
testCases := []struct {
name string
value []string
pattern []string
result bool
}{
{"Nil args and nil pattern", nil, nil, true},
{"No args and nil pattern", []string{}, nil, true},
{"Nil args and no pattern", nil, []string{}, true},
{"No args and no pattern", []string{}, []string{}, true},
{"Nil args and pattern", nil, []string{"test"}, false},
{"No args and pattern", []string{}, []string{"test"}, false},
{"Args and nil pattern", []string{"test"}, nil, false},
{"Args and no pattern", []string{"test"}, []string{}, false},
{"Simple single compare", []string{"test"}, []string{"test"}, true},
{"Simple double compare", []string{"one", "two"}, []string{"one", "two"}, true},
{"Bad single", []string{"one"}, []string{"two"}, false},
{"Bad double", []string{"one", "two"}, []string{"one", "owt"}, false},
{"Count mismatch", []string{"one", "two"}, []string{"one"}, false},
{"Nil args and wild", nil, []string{"*"}, false},
{"No args and wild", []string{}, []string{"*"}, false},
{"Single arg and wild", []string{"one"}, []string{"*"}, true},
{"Double arg and first wild", []string{"one", "two"}, []string{"*", "two"}, true},
{"Double arg and second wild", []string{"one", "two"}, []string{"one", "*"}, true},
{"Double arg and first wild mismatched", []string{"one", "two"}, []string{"*", "owt"}, false},
{"Double arg and second wild mismatched", []string{"one", "two"}, []string{"eno", "*"}, false},
{"Double arg and double wild", []string{"one", "two"}, []string{"*", "*"}, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
args := Parse(tc.value)
if resp := args.Matches(tc.pattern...); tc.result != resp {
msg := "For arguments [%v] matched to pattern [%v] expected " +
"%b but got %b"
t.Errorf(msg, tc.value, tc.pattern, tc.result, resp)
}
})
}
}

95
cli/execute.go Normal file
View File

@ -0,0 +1,95 @@
package cli
import (
"flag"
"fmt"
"github.com/halverneus/static-file-server/cli/help"
"github.com/halverneus/static-file-server/cli/server"
"github.com/halverneus/static-file-server/cli/version"
"github.com/halverneus/static-file-server/config"
)
var (
option struct {
configFile string
helpFlag bool
versionFlag bool
}
)
// Assignments used to simplify testing.
var (
selectRoutine = selectionRoutine
unknownArgsFunc = unknownArgs
runServerFunc = server.Run
runHelpFunc = help.Run
runVersionFunc = version.Run
loadConfig = config.Load
)
func init() {
setupFlags()
}
func setupFlags() {
flag.StringVar(&option.configFile, "config", "", "")
flag.StringVar(&option.configFile, "c", "", "")
flag.BoolVar(&option.helpFlag, "help", false, "")
flag.BoolVar(&option.helpFlag, "h", false, "")
flag.BoolVar(&option.versionFlag, "version", false, "")
flag.BoolVar(&option.versionFlag, "v", false, "")
}
// Execute CLI arguments.
func Execute() (err error) {
// Parse flag options, then parse commands arguments.
flag.Parse()
args := Parse(flag.Args())
job := selectRoutine(args)
return job()
}
func selectionRoutine(args Args) func() error {
switch {
// serve help
// serve --help
// serve -h
case args.Matches("help") || option.helpFlag:
return runHelpFunc
// serve version
// serve --version
// serve -v
case args.Matches("version") || option.versionFlag:
return runVersionFunc
// serve
case args.Matches():
return withConfig(runServerFunc)
// Unknown arguments.
default:
return unknownArgsFunc(args)
}
}
func unknownArgs(args Args) func() error {
return func() error {
return fmt.Errorf(
"unknown arguments provided [%v], try: 'help'",
args,
)
}
}
func withConfig(routine func() error) func() error {
return func() (err error) {
if err = loadConfig(option.configFile); nil != err {
return
}
return routine()
}
}

162
cli/execute_test.go Normal file
View File

@ -0,0 +1,162 @@
package cli
import (
"errors"
"flag"
"os"
"testing"
)
func TestSetupFlags(t *testing.T) {
app := os.Args[0]
file := "file.txt"
wConfig := "Config (file.txt)"
testCases := []struct {
name string
args []string
config string
help bool
version bool
}{
{"Empty args", []string{app}, "", false, false},
{"Help (--help)", []string{app, "--help"}, "", true, false},
{"Help (-help)", []string{app, "-help"}, "", true, false},
{"Help (-h)", []string{app, "-h"}, "", true, false},
{"Version (--version)", []string{app, "--version"}, "", false, true},
{"Version (-version)", []string{app, "-version"}, "", false, true},
{"Version (-v)", []string{app, "-v"}, "", false, true},
{"Config ()", []string{app, "--config", ""}, "", false, false},
{wConfig, []string{app, "--config", file}, file, false, false},
{wConfig, []string{app, "--config=file.txt"}, file, false, false},
{wConfig, []string{app, "-config", file}, file, false, false},
{wConfig, []string{app, "-config=file.txt"}, file, false, false},
{wConfig, []string{app, "-c", file}, file, false, false},
{"All set", []string{app, "-h", "-v", "-c", file}, file, true, true},
}
reset := func() {
option.configFile = ""
option.helpFlag = false
option.versionFlag = false
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
reset()
os.Args = tc.args
flag.Parse()
if option.configFile != tc.config {
t.Errorf(
"For options [%v] expected a config file of %s but got %s",
tc.args, tc.config, option.configFile,
)
}
if option.helpFlag != tc.help {
t.Errorf(
"For options [%v] expected help flag of %t but got %t",
tc.args, tc.help, option.helpFlag,
)
}
if option.versionFlag != tc.version {
t.Errorf(
"For options [%v] expected version flag of %t but got %t",
tc.args, tc.version, option.versionFlag,
)
}
})
}
}
func TestExecuteAndSelection(t *testing.T) {
app := os.Args[0]
runHelpFuncError := errors.New("help")
runHelpFunc = func() error {
return runHelpFuncError
}
runVersionFuncError := errors.New("version")
runVersionFunc = func() error {
return runVersionFuncError
}
runServerFuncError := errors.New("server")
runServerFunc = func() error {
return runServerFuncError
}
unknownArgsFuncError := errors.New("unknown")
unknownArgsFunc = func(Args) func() error {
return func() error {
return unknownArgsFuncError
}
}
reset := func() {
option.configFile = ""
option.helpFlag = false
option.versionFlag = false
}
testCases := []struct {
name string
args []string
result error
}{
{"Help", []string{app, "help"}, runHelpFuncError},
{"Help", []string{app, "--help"}, runHelpFuncError},
{"Version", []string{app, "version"}, runVersionFuncError},
{"Version", []string{app, "--version"}, runVersionFuncError},
{"Serve", []string{app}, runServerFuncError},
{"Unknown", []string{app, "unknown"}, unknownArgsFuncError},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
reset()
os.Args = tc.args
if err := Execute(); tc.result != err {
t.Errorf(
"Expected error for %v but got %v",
tc.result, err,
)
}
})
}
}
func TestUnknownArgs(t *testing.T) {
errFunc := unknownArgs(Args{"unknown"})
if err := errFunc(); nil == err {
t.Errorf(
"Expected a given unknown argument error but got %v",
err,
)
}
}
func TestWithConfig(t *testing.T) {
configError := errors.New("config")
routineError := errors.New("routine")
routine := func() error { return routineError }
testCases := []struct {
name string
loadConfig func(string) error
result error
}{
{"Config error", func(string) error { return configError }, configError},
{"Routine error", func(string) error { return nil }, routineError},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
loadConfig = tc.loadConfig
errFunc := withConfig(routine)
if err := errFunc(); tc.result != err {
t.Errorf("Expected error %v but got %v", tc.result, err)
}
})
}
}

135
cli/help/help.go Normal file
View File

@ -0,0 +1,135 @@
package help
import (
"fmt"
)
// Run print operation.
func Run() error {
fmt.Println(Text)
return nil
}
var (
// Text for directly accessing help.
Text = `
NAME
static-file-server
SYNOPSIS
static-file-server
static-file-server [ -c | -config | --config ] /path/to/config.yml
static-file-server [ help | -help | --help ]
static-file-server [ version | -version | --version ]
DESCRIPTION
The Static File Server is intended to be a tiny, fast and simple solution
for serving files over HTTP. The features included are limited to make to
binding to a host name and port, selecting a folder to serve, choosing a
URL path prefix and selecting TLS certificates. If you want really awesome
reverse proxy features, I recommend Nginx.
DEPENDENCIES
None... not even libc!
ENVIRONMENT VARIABLES
FOLDER
The path to the folder containing the contents to be served over
HTTP(s). If not supplied, defaults to '/web' (for Docker reasons).
HOST
The hostname used for binding. If not supplied, contents will be served
to a client without regard for the hostname.
PORT
The port used for binding. If not supplied, defaults to port '8080'.
SHOW_LISTING
Automatically serve the index file for the directory if requested. For
example, if the client requests 'http://127.0.0.1/' the 'index.html'
file in the root of the directory being served is returned. If the value
is set to 'false', the same request will return a 'NOT FOUND'. Default
value is 'true'.
TLS_CERT
Path to the TLS certificate file to serve files using HTTPS. If supplied
then TLS_KEY must also be supplied. If not supplied, contents will be
served via HTTP.
TLS_KEY
Path to the TLS key file to serve files using HTTPS. If supplied then
TLS_CERT must also be supplied. If not supplied, contents will be served
via HTTPS
URL_PREFIX
The prefix to use in the URL path. If supplied, then the prefix must
start with a forward-slash and NOT end with a forward-slash. If not
supplied then no prefix is used.
CONFIGURATION FILE
Configuration can also managed used a YAML configuration file. To select the
configuration values using the YAML file, pass in the path to the file using
the appropriate flags (-c, --config). Environment variables take priority
over the configuration file. The following is an example configuration using
the default values.
Example config.yml with defaults:
----------------------------------------------------------------------------
folder: /web
host: ""
port: 8080
show-listing: true
tls-cert: ""
tls-key: ""
url-prefix: ""
----------------------------------------------------------------------------
USAGE
FILE LAYOUT
/var/www/sub/my.file
/var/www/index.html
COMMAND
export FOLDER=/var/www/sub
static-file-server
Retrieve with: wget http://localhost:8080/my.file
wget http://my.machine:8080/my.file
export FOLDER=/var/www
export HOST=my.machine
export PORT=80
static-file-server
Retrieve with: wget http://my.machine/sub/my.file
export FOLDER=/var/www
static-file-server -c config.yml
Result: Runs with values from config.yml, but with the folder being
served overridden by the FOLDER environment variable.
export FOLDER=/var/www/sub
export HOST=my.machine
export PORT=80
export URL_PREFIX=/my/stuff
static-file-server
Retrieve with: wget http://my.machine/my/stuff/my.file
export FOLDER=/var/www/sub
export TLS_CERT=/etc/server/my.machine.crt
export TLS_KEY=/etc/server/my.machine.key
static-file-server
Retrieve with: wget https://my.machine:8080/my.file
export FOLDER=/var/www/sub
export PORT=443
export TLS_CERT=/etc/server/my.machine.crt
export TLS_KEY=/etc/server/my.machine.key
static-file-server
Retrieve with: wget https://my.machine/my.file
export FOLDER=/var/www
export PORT=80
export SHOW_LISTING=true # Default behavior
static-file-server
Retrieve 'index.html' with: wget http://my.machine/
export FOLDER=/var/www
export PORT=80
export SHOW_LISTING=false
static-file-server
Returns 'NOT FOUND': wget http://my.machine/
`
)

9
cli/help/help_test.go Normal file
View File

@ -0,0 +1,9 @@
package help
import "testing"
func TestRun(t *testing.T) {
if err := Run(); nil != err {
t.Errorf("While running help got %v", err)
}
}

61
cli/server/server.go Normal file
View File

@ -0,0 +1,61 @@
package server
import (
"fmt"
"net/http"
"github.com/halverneus/static-file-server/config"
"github.com/halverneus/static-file-server/handle"
)
var (
// Values to be overridden to simplify unit testing.
selectHandler = handlerSelector
selectListener = listenerSelector
)
// Run server.
func Run() error {
// Choose and set the appropriate, optimized static file serving function.
handler := selectHandler()
// Serve files over HTTP or HTTPS based on paths to TLS files being
// provided.
listener := selectListener()
binding := fmt.Sprintf("%s:%d", config.Get.Host, config.Get.Port)
return listener(binding, handler)
}
// handlerSelector returns the appropriate request handler based on
// configuration.
func handlerSelector() (handler http.HandlerFunc) {
// Choose and set the appropriate, optimized static file serving function.
if 0 == len(config.Get.URLPrefix) {
handler = handle.Basic(config.Get.Folder)
} else {
handler = handle.Prefix(config.Get.Folder, config.Get.URLPrefix)
}
// Determine whether index files should hidden.
if !config.Get.ShowListing {
handler = handle.IgnoreIndex(handler)
}
return
}
// listenerSelector returns the appropriate listener handler based on
// configuration.
func listenerSelector() (listener handle.ListenerFunc) {
// Serve files over HTTP or HTTPS based on paths to TLS files being
// provided.
if 0 < len(config.Get.TLSCert) {
listener = handle.TLSListening(
config.Get.TLSCert,
config.Get.TLSKey,
)
} else {
listener = handle.Listening()
}
return
}

74
cli/server/server_test.go Normal file
View File

@ -0,0 +1,74 @@
package server
import (
"errors"
"net/http"
"testing"
"github.com/halverneus/static-file-server/config"
"github.com/halverneus/static-file-server/handle"
)
func TestRun(t *testing.T) {
listenerError := errors.New("listener")
selectListener = func() handle.ListenerFunc {
return func(string, http.HandlerFunc) error {
return listenerError
}
}
if err := Run(); listenerError != err {
t.Errorf("Expected %v but got %v", listenerError, err)
}
}
func TestHandlerSelector(t *testing.T) {
// This test only exercises function branches.
testFolder := "/web"
testPrefix := "/url/prefix"
testCases := []struct {
name string
folder string
prefix string
listing bool
}{
{"Basic handler", testFolder, "", true},
{"Prefix handler", testFolder, testPrefix, true},
{"Basic and hide listing handler", testFolder, "", false},
{"Prefix and hide listing handler", testFolder, testPrefix, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config.Get.Folder = tc.folder
config.Get.URLPrefix = tc.prefix
config.Get.ShowListing = tc.listing
handlerSelector()
})
}
}
func TestListenerSelector(t *testing.T) {
// This test only exercises function branches.
testCert := "file.crt"
testKey := "file.key"
testCases := []struct {
name string
cert string
key string
}{
{"HTTP", "", ""},
{"HTTPS", testCert, testKey},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config.Get.TLSCert = tc.cert
config.Get.TLSKey = tc.key
listenerSelector()
})
}
}

35
cli/version/version.go Normal file
View File

@ -0,0 +1,35 @@
package version
import (
"fmt"
"runtime"
)
// Run print operation.
func Run() error {
fmt.Printf("%s\n%s\n", Text, GoVersionText)
return nil
}
var (
// MajorVersion of static-file-server.
MajorVersion = 1
// MinorVersion of static-file-server.
MinorVersion = 3
// FixVersion of static-file-server.
FixVersion = 0
// Text for directly accessing the static-file-server version.
Text = fmt.Sprintf(
"Version %d.%d.%d",
MajorVersion,
MinorVersion,
FixVersion,
)
// GoVersionText for directly accessing the version of the Go runtime
// compiled with the static-file-server.
GoVersionText = runtime.Version()
)

View File

@ -0,0 +1,9 @@
package version
import "testing"
func TestVersion(t *testing.T) {
if err := Run(); nil != err {
t.Errorf("While running version got %v", err)
}
}

197
config/config.go Normal file
View File

@ -0,0 +1,197 @@
package config
import (
"fmt"
"io/ioutil"
"log"
"os"
"strconv"
"strings"
yaml "gopkg.in/yaml.v2"
)
var (
// Get the desired configuration value.
Get struct {
Folder string `yaml:"folder"`
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
ShowListing bool `yaml:"show-listing"`
TLSCert string `yaml:"tls-cert"`
TLSKey string `yaml:"tls-key"`
URLPrefix string `yaml:"url-prefix"`
}
)
const (
folderKey = "FOLDER"
hostKey = "HOST"
portKey = "PORT"
showListingKey = "SHOW_LISTING"
tlsCertKey = "TLS_CERT"
tlsKeyKey = "TLS_KEY"
urlPrefixKey = "URL_PREFIX"
)
const (
defaultFolder = "/web"
defaultHost = ""
defaultPort = uint16(8080)
defaultShowListing = true
defaultTLSCert = ""
defaultTLSKey = ""
defaultURLPrefix = ""
)
func init() {
// init calls setDefaults to better support testing.
setDefaults()
}
func setDefaults() {
Get.Folder = defaultFolder
Get.Host = defaultHost
Get.Port = defaultPort
Get.ShowListing = defaultShowListing
Get.TLSCert = defaultTLSCert
Get.TLSKey = defaultTLSKey
Get.URLPrefix = defaultURLPrefix
}
// Load the configuration file.
func Load(filename string) (err error) {
// If no filename provided, assign envvars.
if "" == filename {
overrideWithEnvVars()
return
}
// Read contents from configuration file.
var contents []byte
if contents, err = ioutil.ReadFile(filename); nil != err {
return
}
// Parse contents into 'Get' configuration.
if err = yaml.Unmarshal(contents, &Get); nil != err {
return
}
overrideWithEnvVars()
return validate()
}
// overrideWithEnvVars the default values and the configuration file values.
func overrideWithEnvVars() {
// Assign envvars, if set.
Get.Folder = envAsStr(folderKey, Get.Folder)
Get.Host = envAsStr(hostKey, Get.Host)
Get.Port = envAsUint16(portKey, Get.Port)
Get.ShowListing = envAsBool(showListingKey, Get.ShowListing)
Get.TLSCert = envAsStr(tlsCertKey, Get.TLSCert)
Get.TLSKey = envAsStr(tlsKeyKey, Get.TLSKey)
Get.URLPrefix = envAsStr(urlPrefixKey, Get.URLPrefix)
}
// validate the configuration.
func validate() error {
// If HTTPS is to be used, verify both TLS_* environment variables are set.
if 0 < len(Get.TLSCert) || 0 < len(Get.TLSKey) {
if 0 == len(Get.TLSCert) || 0 == len(Get.TLSKey) {
msg := "if value for either 'TLS_CERT' or 'TLS_KEY' is set then " +
"then value for the other must also be set (values are " +
"currently '%s' and '%s', respectively)"
return fmt.Errorf(msg, Get.TLSCert, Get.TLSKey)
}
if _, err := os.Stat(Get.TLSCert); nil != err {
msg := "value of TLS_CERT is set with filename '%s' that returns %v"
return fmt.Errorf(msg, err)
}
if _, err := os.Stat(Get.TLSKey); nil != err {
msg := "value of TLS_KEY is set with filename '%s' that returns %v"
return fmt.Errorf(msg, err)
}
}
// If the URL path prefix is to be used, verify it is properly formatted.
if 0 < len(Get.URLPrefix) &&
(!strings.HasPrefix(Get.URLPrefix, "/") || strings.HasSuffix(Get.URLPrefix, "/")) {
msg := "if value for 'URL_PREFIX' is set then the value must start " +
"with '/' and not end with '/' (current value of '%s' vs valid " +
"example of '/my/prefix'"
return fmt.Errorf(msg, Get.URLPrefix)
}
return nil
}
// envAsStr returns the value of the environment variable as a string if set.
func envAsStr(key, fallback string) string {
if value := os.Getenv(key); "" != value {
return value
}
return fallback
}
// envAsUint16 returns the value of the environment variable as a uint16 if set.
func envAsUint16(key string, fallback uint16) uint16 {
// Retrieve the string value of the environment variable. If not set,
// fallback is used.
valueStr := os.Getenv(key)
if "" == valueStr {
return fallback
}
// Parse the string into a uint16.
base := 10
bitSize := 16
valueAsUint64, err := strconv.ParseUint(valueStr, base, bitSize)
if nil != err {
log.Printf(
"Invalid value for '%s': %v\nUsing fallback: %d",
key, err, fallback,
)
return fallback
}
return uint16(valueAsUint64)
}
// envAsBool returns the value for an environment variable or, if not set, a
// fallback value as a boolean.
func envAsBool(key string, fallback bool) bool {
// Retrieve the string value of the environment variable. If not set,
// fallback is used.
valueStr := os.Getenv(key)
if "" == valueStr {
return fallback
}
// Parse the string into a boolean.
value, err := strAsBool(valueStr)
if nil != err {
log.Printf(
"Invalid value for '%s': %v\nUsing fallback: %t",
key, err, fallback,
)
return fallback
}
return value
}
// strAsBool converts the intent of the passed value into a boolean
// representation.
func strAsBool(value string) (result bool, err error) {
lvalue := strings.ToLower(value)
switch lvalue {
case "0", "false", "f", "no", "n":
result = false
case "1", "true", "t", "yes", "y":
result = true
default:
result = false
msg := "Unknown conversion from string to bool for value '%s'"
err = fmt.Errorf(msg, value)
}
return
}

357
config/config_test.go Normal file
View File

@ -0,0 +1,357 @@
package config
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"testing"
)
func TestLoad(t *testing.T) {
// Verify envvars are set.
testFolder := "/my/directory"
os.Setenv(folderKey, testFolder)
if err := Load(""); nil != err {
t.Errorf(
"While loading an empty file name expected no error but got %v",
err,
)
}
if Get.Folder != testFolder {
t.Errorf(
"While loading an empty file name expected folder %s but got %s",
testFolder, Get.Folder,
)
}
// Verify error if file doesn't exist.
if err := Load("/this/file/should/never/exist"); nil == err {
t.Error("While loading non-existing file expected error but got nil")
}
// Verify bad YAML returns an error.
func(t *testing.T) {
filename := "testing.tmp"
contents := []byte("{")
defer os.Remove(filename)
if err := ioutil.WriteFile(filename, contents, 0666); nil != err {
t.Errorf("Failed to save bad YAML file with: %v\n", err)
}
if err := Load(filename); nil == err {
t.Error("While loading bad YAML expected error but got nil")
}
}(t)
// Verify good YAML returns no error and sets value.
func(t *testing.T) {
filename := "testing.tmp"
testFolder := "/test/folder"
contents := []byte(fmt.Sprintf(
`{"folder": "%s"}`, testFolder,
))
defer os.Remove(filename)
if err := ioutil.WriteFile(filename, contents, 0666); nil != err {
t.Errorf("Failed to save good YAML file with: %v\n", err)
}
if err := Load(filename); nil != err {
t.Errorf(
"While loading good YAML expected nil but got %v",
err,
)
}
}(t)
}
func TestOverrideWithEnvvars(t *testing.T) {
// Choose values that are different than defaults.
testFolder := "/my/directory"
testHost := "apets.life"
testPort := uint16(666)
testShowListing := false
testTLSCert := "my.pem"
testTLSKey := "my.key"
testURLPrefix := "/url/prefix"
// Set all environment variables with test values.
os.Setenv(folderKey, testFolder)
os.Setenv(hostKey, testHost)
os.Setenv(portKey, strconv.Itoa(int(testPort)))
os.Setenv(showListingKey, fmt.Sprintf("%t", testShowListing))
os.Setenv(tlsCertKey, testTLSCert)
os.Setenv(tlsKeyKey, testTLSKey)
os.Setenv(urlPrefixKey, testURLPrefix)
// Verification functions.
equalStrings := func(t *testing.T, name, key, expected, result string) {
if expected != result {
t.Errorf(
"While checking %s for '%s' expected '%s' but got '%s'",
name, key, expected, result,
)
}
}
equalUint16 := func(t *testing.T, name, key string, expected, result uint16) {
if expected != result {
t.Errorf(
"While checking %s for '%s' expected %d but got %d",
name, key, expected, result,
)
}
}
equalBool := func(t *testing.T, name, key string, expected, result bool) {
if expected != result {
t.Errorf(
"While checking %s for '%s' expected %t but got %t",
name, key, expected, result,
)
}
}
// Verify defaults.
setDefaults()
phase := "defaults"
equalStrings(t, phase, folderKey, defaultFolder, Get.Folder)
equalStrings(t, phase, hostKey, defaultHost, Get.Host)
equalUint16(t, phase, portKey, defaultPort, Get.Port)
equalBool(t, phase, showListingKey, defaultShowListing, Get.ShowListing)
equalStrings(t, phase, tlsCertKey, defaultTLSCert, Get.TLSCert)
equalStrings(t, phase, tlsKeyKey, defaultTLSKey, Get.TLSKey)
equalStrings(t, phase, urlPrefixKey, defaultURLPrefix, Get.URLPrefix)
// Apply overrides.
overrideWithEnvVars()
// Verify overrides.
phase = "overrides"
equalStrings(t, phase, folderKey, testFolder, Get.Folder)
equalStrings(t, phase, hostKey, testHost, Get.Host)
equalUint16(t, phase, portKey, testPort, Get.Port)
equalBool(t, phase, showListingKey, testShowListing, Get.ShowListing)
equalStrings(t, phase, tlsCertKey, testTLSCert, Get.TLSCert)
equalStrings(t, phase, tlsKeyKey, testTLSKey, Get.TLSKey)
equalStrings(t, phase, urlPrefixKey, testURLPrefix, Get.URLPrefix)
}
func TestValidate(t *testing.T) {
validPath := "config.go"
invalidPath := "should/never/exist.txt"
empty := ""
prefix := "/my/prefix"
testCases := []struct {
name string
cert string
key string
prefix string
isError bool
}{
{"Valid paths w/prefix", validPath, validPath, prefix, false},
{"Valid paths wo/prefix", validPath, validPath, empty, false},
{"Empty paths w/prefix", empty, empty, prefix, false},
{"Empty paths wo/prefix", empty, empty, empty, false},
{"Mixed paths w/prefix", empty, validPath, prefix, true},
{"Alt mixed paths w/prefix", validPath, empty, prefix, true},
{"Mixed paths wo/prefix", empty, validPath, empty, true},
{"Alt mixed paths wo/prefix", validPath, empty, empty, true},
{"Invalid cert w/prefix", invalidPath, validPath, prefix, true},
{"Invalid key w/prefix", validPath, invalidPath, prefix, true},
{"Invalid cert & key w/prefix", invalidPath, invalidPath, prefix, true},
{"Prefix missing leading /", empty, empty, "my/prefix", true},
{"Prefix with trailing /", empty, empty, "/my/prefix/", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
Get.TLSCert = tc.cert
Get.TLSKey = tc.key
Get.URLPrefix = tc.prefix
err := validate()
hasError := nil != err
if hasError && !tc.isError {
t.Errorf("Expected no error but got %v", err)
}
if !hasError && tc.isError {
t.Error("Expected an error but got no error")
}
})
}
}
func TestEnvAsStr(t *testing.T) {
sv := "STRING_VALUE"
fv := "FLOAT_VALUE"
iv := "INT_VALUE"
bv := "BOOL_VALUE"
ev := "EMPTY_VALUE"
uv := "UNSET_VALUE"
sr := "String Cheese" // String result
fr := "123.456" // Float result
ir := "-123" // Int result
br := "true" // Bool result
er := "" // Empty result
fbr := "fallback result" // Fallback result
efbr := "" // Empty fallback result
os.Setenv(sv, sr)
os.Setenv(fv, fr)
os.Setenv(iv, ir)
os.Setenv(bv, br)
os.Setenv(ev, er)
testCases := []struct {
name string
key string
fallback string
result string
}{
{"Good string", sv, fbr, sr},
{"Float string", fv, fbr, fr},
{"Int string", iv, fbr, ir},
{"Bool string", bv, fbr, br},
{"Empty string", ev, fbr, fbr},
{"Unset", uv, fbr, fbr},
{"Good string with empty fallback", sv, efbr, sr},
{"Unset with empty fallback", uv, efbr, efbr},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := envAsStr(tc.key, tc.fallback)
if tc.result != result {
t.Errorf(
"For %s with a '%s' fallback expected '%s' but got '%s'",
tc.key, tc.fallback, tc.result, result,
)
}
})
}
}
func TestEnvAsUint16(t *testing.T) {
ubv := "UPPER_BOUNDS_VALUE"
lbv := "LOWER_BOUNDS_VALUE"
hv := "HIGH_VALUE"
lv := "LOW_VALUE"
bv := "BOOL_VALUE"
sv := "STRING_VALUE"
uv := "UNSET_VALUE"
fbr := uint16(666) // Fallback result
ubr := uint16(65535) // Upper bounds result
lbr := uint16(0) // Lower bounds result
os.Setenv(ubv, "65535")
os.Setenv(lbv, "0")
os.Setenv(hv, "65536")
os.Setenv(lv, "-1")
os.Setenv(bv, "true")
os.Setenv(sv, "Cheese")
testCases := []struct {
name string
key string
fallback uint16
result uint16
}{
{"Upper bounds", ubv, fbr, ubr},
{"Lower bounds", lbv, fbr, lbr},
{"Out-of-bounds high", hv, fbr, fbr},
{"Out-of-bounds low", lv, fbr, fbr},
{"Boolean", bv, fbr, fbr},
{"String", sv, fbr, fbr},
{"Unset", uv, fbr, fbr},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := envAsUint16(tc.key, tc.fallback)
if tc.result != result {
t.Errorf(
"For %s with a %d fallback expected %d but got %d",
tc.key, tc.fallback, tc.result, result,
)
}
})
}
}
func TestEnvAsBool(t *testing.T) {
tv := "TRUE_VALUE"
fv := "FALSE_VALUE"
bv := "BAD_VALUE"
uv := "UNSET_VALUE"
os.Setenv(tv, "True")
os.Setenv(fv, "NO")
os.Setenv(bv, "BAD")
testCases := []struct {
name string
key string
fallback bool
result bool
}{
{"True with true fallback", tv, true, true},
{"True with false fallback", tv, false, true},
{"False with true fallback", fv, true, false},
{"False with false fallback", fv, false, false},
{"Bad with true fallback", bv, true, true},
{"Bad with false fallback", bv, false, false},
{"Unset with true fallback", uv, true, true},
{"Unset with false fallback", uv, false, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := envAsBool(tc.key, tc.fallback)
if tc.result != result {
t.Errorf(
"For %s with a %t fallback expected %t but got %t",
tc.key, tc.fallback, tc.result, result,
)
}
})
}
}
func TestStrAsBool(t *testing.T) {
testCases := []struct {
name string
value string
result bool
isError bool
}{
{"Empty value", "", false, true},
{"False value", "0", false, false},
{"True value", "1", true, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := strAsBool(tc.value)
if result != tc.result {
t.Errorf(
"Expected %t for %s but got %t",
tc.result, tc.value, result,
)
}
if tc.isError && nil == err {
t.Errorf(
"Expected error for %s but got no error",
tc.value,
)
}
if !tc.isError && nil != err {
t.Errorf(
"Expected no error for %s but got %v",
tc.value, err,
)
}
})
}
}

71
handle/handle.go Normal file
View File

@ -0,0 +1,71 @@
package handle
import (
"net/http"
"strings"
)
var (
// These assignments are for unit testing.
listenAndServe = http.ListenAndServe
listenAndServeTLS = http.ListenAndServeTLS
setHandler = http.HandleFunc
)
var (
server http.Server
)
// ListenerFunc accepts the {hostname:port} binding string required by HTTP
// listeners and the handler (router) function and returns any errors that
// occur.
type ListenerFunc func(string, http.HandlerFunc) error
// Basic file handler servers files from the passed folder.
func Basic(folder string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, folder+r.URL.Path)
}
}
// Prefix file handler is an alternative to Basic where a URL prefix is removed
// prior to serving a file (http://my.machine/prefix/file.txt will serve
// file.txt from the root of the folder being served (ignoring 'prefix')).
func Prefix(folder, urlPrefix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, urlPrefix) {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, folder+strings.TrimPrefix(r.URL.Path, urlPrefix))
}
}
// IgnoreIndex wraps an HTTP request. In the event of a folder root request,
// this function will automatically return 'NOT FOUND' as opposed to default
// behavior where the index file for that directory is retrieved.
func IgnoreIndex(serve http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
serve(w, r)
}
}
// Listening function for serving the handler function.
func Listening() ListenerFunc {
return func(binding string, handler http.HandlerFunc) error {
setHandler("/", handler)
return listenAndServe(binding, nil)
}
}
// TLSListening function for serving the handler function with encryption.
func TLSListening(tlsCert, tlsKey string) ListenerFunc {
return func(binding string, handler http.HandlerFunc) error {
setHandler("/", handler)
return listenAndServeTLS(binding, tlsCert, tlsKey, nil)
}
}

351
handle/handle_test.go Normal file
View File

@ -0,0 +1,351 @@
package handle
import (
"errors"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
)
var (
baseDir = "tmp/"
subDir = "sub/"
subDeepDir = "sub/deep/"
tmpIndexName = "index.html"
tmpFileName = "file.txt"
tmpBadName = "bad.txt"
tmpSubIndexName = "sub/index.html"
tmpSubFileName = "sub/file.txt"
tmpSubBadName = "sub/bad.txt"
tmpSubDeepIndexName = "sub/deep/index.html"
tmpSubDeepFileName = "sub/deep/file.txt"
tmpSubDeepBadName = "sub/deep/bad.txt"
tmpIndex = "Space: the final frontier"
tmpFile = "These are the voyages of the starship Enterprise."
tmpSubIndex = "Its continuing mission:"
tmpSubFile = "To explore strange new worlds"
tmpSubDeepIndex = "To seek out new life and new civilizations"
tmpSubDeepFile = "To boldly go where no one has gone before"
nothing = ""
ok = http.StatusOK
missing = http.StatusNotFound
redirect = http.StatusMovedPermanently
notFound = "404 page not found\n"
files = map[string]string{
baseDir + tmpIndexName: tmpIndex,
baseDir + tmpFileName: tmpFile,
baseDir + tmpSubIndexName: tmpSubIndex,
baseDir + tmpSubFileName: tmpSubFile,
baseDir + tmpSubDeepIndexName: tmpSubDeepIndex,
baseDir + tmpSubDeepFileName: tmpSubDeepFile,
}
)
func TestMain(m *testing.M) {
code := func(m *testing.M) int {
if err := setup(); nil != err {
log.Fatalf("While setting up test got: %v\n", err)
}
defer teardown()
return m.Run()
}(m)
os.Exit(code)
}
func setup() (err error) {
for filename, contents := range files {
if err = os.MkdirAll(path.Dir(filename), 0700); nil != err {
return
}
if err = ioutil.WriteFile(
filename,
[]byte(contents),
0600,
); nil != err {
return
}
}
return
}
func teardown() (err error) {
return os.RemoveAll("tmp")
}
func TestBasic(t *testing.T) {
testCases := []struct {
name string
path string
code int
contents string
}{
{"Good base dir", "", ok, tmpIndex},
{"Good base index", tmpIndexName, redirect, nothing},
{"Good base file", tmpFileName, ok, tmpFile},
{"Bad base file", tmpBadName, missing, notFound},
{"Good subdir dir", subDir, ok, tmpSubIndex},
{"Good subdir index", tmpSubIndexName, redirect, nothing},
{"Good subdir file", tmpSubFileName, ok, tmpSubFile},
}
handler := Basic(baseDir)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fullpath := "http://localhost/" + tc.path
req := httptest.NewRequest("GET", fullpath, nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, err := ioutil.ReadAll(resp.Body)
if nil != err {
t.Errorf("While reading body got %v", err)
}
contents := string(body)
if tc.code != resp.StatusCode {
t.Errorf(
"While retrieving %s expected status code of %d but got %d",
fullpath, tc.code, resp.StatusCode,
)
}
if tc.contents != contents {
t.Errorf(
"While retrieving %s expected contents '%s' but got '%s'",
fullpath, tc.contents, contents,
)
}
})
}
}
func TestPrefix(t *testing.T) {
prefix := "/my/prefix/path/"
testCases := []struct {
name string
path string
code int
contents string
}{
{"Good base dir", prefix, ok, tmpIndex},
{"Good base index", prefix + tmpIndexName, redirect, nothing},
{"Good base file", prefix + tmpFileName, ok, tmpFile},
{"Bad base file", prefix + tmpBadName, missing, notFound},
{"Good subdir dir", prefix + subDir, ok, tmpSubIndex},
{"Good subdir index", prefix + tmpSubIndexName, redirect, nothing},
{"Good subdir file", prefix + tmpSubFileName, ok, tmpSubFile},
{"Unknown prefix", tmpFileName, missing, notFound},
}
handler := Prefix(baseDir, prefix)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fullpath := "http://localhost" + tc.path
req := httptest.NewRequest("GET", fullpath, nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, err := ioutil.ReadAll(resp.Body)
if nil != err {
t.Errorf("While reading body got %v", err)
}
contents := string(body)
if tc.code != resp.StatusCode {
t.Errorf(
"While retrieving %s expected status code of %d but got %d",
fullpath, tc.code, resp.StatusCode,
)
}
if tc.contents != contents {
t.Errorf(
"While retrieving %s expected contents '%s' but got '%s'",
fullpath, tc.contents, contents,
)
}
})
}
}
func TestIgnoreIndex(t *testing.T) {
testCases := []struct {
name string
path string
code int
contents string
}{
{"Good base dir", "", missing, notFound},
{"Good base index", tmpIndexName, redirect, nothing},
{"Good base file", tmpFileName, ok, tmpFile},
{"Bad base file", tmpBadName, missing, notFound},
{"Good subdir dir", subDir, missing, notFound},
{"Good subdir index", tmpSubIndexName, redirect, nothing},
{"Good subdir file", tmpSubFileName, ok, tmpSubFile},
}
handler := IgnoreIndex(Basic(baseDir))
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fullpath := "http://localhost/" + tc.path
req := httptest.NewRequest("GET", fullpath, nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, err := ioutil.ReadAll(resp.Body)
if nil != err {
t.Errorf("While reading body got %v", err)
}
contents := string(body)
if tc.code != resp.StatusCode {
t.Errorf(
"While retrieving %s expected status code of %d but got %d",
fullpath, tc.code, resp.StatusCode,
)
}
if tc.contents != contents {
t.Errorf(
"While retrieving %s expected contents '%s' but got '%s'",
fullpath, tc.contents, contents,
)
}
})
}
}
// func TestIgnoreIndex(t *testing.T) {
// handler := IgnoreIndex(Basic("tmp"))
// testCases := []struct {
// name string
// path string
// code int
// contents string
// }{}
// // Build test cases for directories.
// var dirs []string
// for filename, contents := range files {
// dir := path.Dir(filename)
// found := false
// for _, other := range dirs {
// if other == dir {
// found = true
// break
// }
// }
// if !found {
// dirs = append(dirs, dir)
// }
// }
// }
func TestListening(t *testing.T) {
// Choose values for testing.
called := false
testBinding := "host:port"
testError := errors.New("random problem")
// Create an empty placeholder router function.
handler := func(http.ResponseWriter, *http.Request) {}
// Override setHandler so that multiple calls to 'http.HandleFunc' doesn't
// panic.
setHandler = func(string, func(http.ResponseWriter, *http.Request)) {}
// Override listenAndServe with a function with more introspection and
// control than 'http.ListenAndServe'.
listenAndServe = func(
binding string, handler http.Handler,
) error {
if testBinding != binding {
t.Errorf(
"While serving expected binding of %s but got %s",
testBinding, binding,
)
}
called = !called
if called {
return nil
}
return testError
}
// Perform test.
listener := Listening()
if err := listener(testBinding, handler); nil != err {
t.Errorf("While serving first expected nil error but got %v", err)
}
if err := listener(testBinding, handler); nil == err {
t.Errorf(
"While serving second got nil while expecting %v", testError,
)
}
}
func TestTLSListening(t *testing.T) {
// Choose values for testing.
called := false
testBinding := "host:port"
testTLSCert := "test/file.pem"
testTLSKey := "test/file.key"
testError := errors.New("random problem")
// Create an empty placeholder router function.
handler := func(http.ResponseWriter, *http.Request) {}
// Override setHandler so that multiple calls to 'http.HandleFunc' doesn't
// panic.
setHandler = func(string, func(http.ResponseWriter, *http.Request)) {}
// Override listenAndServeTLS with a function with more introspection and
// control than 'http.ListenAndServeTLS'.
listenAndServeTLS = func(
binding, tlsCert, tlsKey string, handler http.Handler,
) error {
if testBinding != binding {
t.Errorf(
"While serving TLS expected binding of %s but got %s",
testBinding, binding,
)
}
if testTLSCert != tlsCert {
t.Errorf(
"While serving TLS expected TLS cert of %s but got %s",
testTLSCert, tlsCert,
)
}
if testTLSKey != tlsKey {
t.Errorf(
"While serving TLS expected TLS key of %s but got %s",
testTLSKey, tlsKey,
)
}
called = !called
if called {
return nil
}
return testError
}
// Perform test.
listener := TLSListening(testTLSCert, testTLSKey)
if err := listener(testBinding, handler); nil != err {
t.Errorf("While serving first TLS expected nil error but got %v", err)
}
if err := listener(testBinding, handler); nil == err {
t.Errorf(
"While serving second TLS got nil while expecting %v", testError,
)
}
}

245
serve.go
View File

@ -1,245 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"strings"
)
var (
version = "Version 1.1"
help = `
NAME
static-file-server
SYNOPSIS
static-file-server
static-file-server [ help | -help | --help ]
static-file-server [ version | -version | --version ]
DESCRIPTION
The Static File Server is intended to be a tiny, fast and simple solution
for serving files over HTTP. The features included are limited to make to
binding to a host name and port, selecting a folder to serve, choosing a
URL path prefix and selecting TLS certificates. If you want really awesome
reverse proxy features, I recommend Nginx.
DEPENDENCIES
None... not even libc!
ENVIRONMENT VARIABLES
FOLDER
The path to the folder containing the contents to be served over
HTTP(s). If not supplied, defaults to '/web' (for Docker reasons).
HOST
The hostname used for binding. If not supplied, contents will be served
to a client without regard for the hostname.
PORT
The port used for binding. If not supplied, defaults to port '8080'.
SHOW_LISTING
Automatically serve the index file for the directory if requested. For
example, if the client requests 'http://127.0.0.1/' the 'index.html'
file in the root of the directory being served is returned. If the value
is set to 'false', the same request will return a 'NOT FOUND'. Default
value is 'true'.
TLS_CERT
Path to the TLS certificate file to serve files using HTTPS. If supplied
then TLS_KEY must also be supplied. If not supplied, contents will be
served via HTTP.
TLS_KEY
Path to the TLS key file to serve files using HTTPS. If supplied then
TLS_CERT must also be supplied. If not supplied, contents will be served
via HTTPS
URL_PREFIX
The prefix to use in the URL path. If supplied, then the prefix must
start with a forward-slash and NOT end with a forward-slash. If not
supplied then no prefix is used.
USAGE
FILE LAYOUT
/var/www/sub/my.file
/var/www/index.html
COMMAND
export FOLDER=/var/www/sub
static-file-server
Retrieve with: wget http://localhost:8080/my.file
wget http://my.machine:8080/my.file
export FOLDER=/var/www
export HOST=my.machine
export PORT=80
static-file-server
Retrieve with: wget http://my.machine/sub/my.file
export FOLDER=/var/www/sub
export HOST=my.machine
export PORT=80
export URL_PREFIX=/my/stuff
static-file-server
Retrieve with: wget http://my.machine/my/stuff/my.file
export FOLDER=/var/www/sub
export TLS_CERT=/etc/server/my.machine.crt
export TLS_KEY=/etc/server/my.machine.key
static-file-server
Retrieve with: wget https://my.machine:8080/my.file
export FOLDER=/var/www/sub
export PORT=443
export TLS_CERT=/etc/server/my.machine.crt
export TLS_KEY=/etc/server/my.machine.key
static-file-server
Retrieve with: wget https://my.machine/my.file
export FOLDER=/var/www
export PORT=80
export SHOW_LISTING=true # Default behavior
static-file-server
Retrieve 'index.html' with: wget http://my.machine/
export FOLDER=/var/www
export PORT=80
export SHOW_LISTING=false
static-file-server
Returns 'NOT FOUND': wget http://my.machine/
`
)
func main() {
// Evaluate and execute subcommand if supplied.
if 1 < len(os.Args) {
arg := os.Args[1]
switch {
case strings.Contains(arg, "help"):
fmt.Println(help)
case strings.Contains(arg, "version"):
fmt.Println(version)
default:
name := os.Args[0]
log.Fatalf("Unknown argument: %s. Try '%s help'.", arg, name)
}
return
}
// Collect environment variables.
folder := env("FOLDER", "/web") + "/"
host := env("HOST", "")
port := env("PORT", "8080")
showListing := envAsBool("SHOW_LISTING", true)
tlsCert := env("TLS_CERT", "")
tlsKey := env("TLS_KEY", "")
urlPrefix := env("URL_PREFIX", "")
// If HTTPS is to be used, verify both TLS_* environment variables are set.
if 0 < len(tlsCert) || 0 < len(tlsKey) {
if 0 == len(tlsCert) || 0 == len(tlsKey) {
log.Fatalln(
"If value for environment variable 'TLS_CERT' or 'TLS_KEY' is set " +
"then value for environment variable 'TLS_KEY' or 'TLS_CERT' must " +
"also be set.",
)
}
}
// If the URL path prefix is to be used, verify it is properly formatted.
if 0 < len(urlPrefix) &&
(!strings.HasPrefix(urlPrefix, "/") || strings.HasSuffix(urlPrefix, "/")) {
log.Fatalln(
"Value for environment variable 'URL_PREFIX' must start " +
"with '/' and not end with '/'. Example: '/my/prefix'",
)
}
// Choose and set the appropriate, optimized static file serving function.
var handler http.HandlerFunc
if 0 == len(urlPrefix) {
handler = handleListing(showListing, basicHandler(folder))
} else {
handler = handleListing(showListing, prefixHandler(folder, urlPrefix))
}
http.HandleFunc("/", handler)
// Serve files over HTTP or HTTPS based on paths to TLS files being provided.
if 0 == len(tlsCert) {
log.Fatalln(http.ListenAndServe(host+":"+port, nil))
} else {
log.Fatalln(http.ListenAndServeTLS(host+":"+port, tlsCert, tlsKey, nil))
}
}
// handleListing wraps an HTTP request. In the event of a folder root request,
// setting 'show' to false will automatically return 'NOT FOUND' whereas true
// will attempt to retrieve the index file of that directory.
func handleListing(show bool, serve http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !show && strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
serve(w, r)
}
}
// basicHandler serves files from the folder passed.
func basicHandler(folder string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, folder+r.URL.Path)
}
}
// prefixHandler removes the URL path prefix before serving files from the
// folder passed.
func prefixHandler(folder, urlPrefix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, urlPrefix) {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, folder+strings.TrimPrefix(r.URL.Path, urlPrefix))
}
}
// env returns the value for an environment variable or, if not set, a fallback
// value.
func env(key, fallback string) string {
if value := os.Getenv(key); 0 < len(value) {
return value
}
return fallback
}
// strAsBool converts the intent of the passed value into a boolean
// representation.
func strAsBool(value string) (result bool, err error) {
lvalue := strings.ToLower(value)
switch lvalue {
case "0", "false", "f", "no", "n":
result = false
case "1", "true", "t", "yes", "y":
result = true
default:
result = false
msg := "Unknown conversion from string to bool for value '%s'"
err = fmt.Errorf(msg, value)
}
return
}
// envAsBool returns the value for an environment variable or, if not set, a
// fallback value as a boolean.
func envAsBool(key string, fallback bool) bool {
value := env(key, fmt.Sprintf("%t", fallback))
result, err := strAsBool(value)
if nil != err {
log.Printf(
"Invalid value for '%s': %v\nUsing fallback: %t",
key, err, fallback,
)
return fallback
}
return result
}