Added command-line utility packages and integrated.

This commit is contained in:
Jeromy Streets 2018-07-28 17:05:24 -07:00
parent 755e0114f1
commit 055f1621cb
10 changed files with 571 additions and 172 deletions

View File

@ -1,182 +1,13 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"github.com/halverneus/static-file-server/config"
"github.com/halverneus/static-file-server/handle"
)
var (
version = "Version 1.3"
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/
`
"github.com/halverneus/static-file-server/cli"
)
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.
if err := config.Load(""); nil != err {
log.Fatalf("While loading configuration got %v", err)
}
// If HTTPS is to be used, verify both TLS_* environment variables are set.
if 0 < len(config.Get.TLSCert) || 0 < len(config.Get.TLSKey) {
if 0 == len(config.Get.TLSCert) || 0 == len(config.Get.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 err := cli.Execute(); nil != err {
log.Fatalf("Error: %v\n", err)
}
}
// If the URL path prefix is to be used, verify it is properly formatted.
if 0 < len(config.Get.URLPrefix) &&
(!strings.HasPrefix(config.Get.URLPrefix, "/") || strings.HasSuffix(config.Get.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(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)
}
// Serve files over HTTP or HTTPS based on paths to TLS files being provided.
var listener handle.ListenerFunc
if 0 < len(config.Get.TLSCert) {
listener = handle.TLSListening(
config.Get.TLSCert,
config.Get.TLSKey,
)
} else {
listener = handle.Listening()
}
binding := fmt.Sprintf("%s:%d", config.Get.Host, config.Get.Port)
log.Fatalln(listener(binding, handler))
}

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)
}
})
}
}

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

@ -0,0 +1,111 @@
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 [ 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/
`
)

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)
}
}

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

@ -0,0 +1,39 @@
package server
import (
"fmt"
"net/http"
"github.com/halverneus/static-file-server/config"
"github.com/halverneus/static-file-server/handle"
)
// Run server.
func Run() error {
// Choose and set the appropriate, optimized static file serving function.
var handler http.HandlerFunc
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)
}
// Serve files over HTTP or HTTPS based on paths to TLS files being provided.
var listener handle.ListenerFunc
if 0 < len(config.Get.TLSCert) {
listener = handle.TLSListening(
config.Get.TLSCert,
config.Get.TLSKey,
)
} else {
listener = handle.Listening()
}
binding := fmt.Sprintf("%s:%d", config.Get.Host, config.Get.Port)
return listener(binding, handler)
}

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)
}
}