htpack/cmd/packserver/main.go

243 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Packserver is a standalone HTTP server that serves up one or more pack files.
*/
package main
import (
"bufio"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"src.lwithers.me.uk/go/htpack"
)
var rootCmd = &cobra.Command{
Use: "packserver",
Short: "packserver is an HTTP server which serves .htpack files",
Long: `packserver can efficiently serve a pre-packed file tree over HTTP(S).
The files must first have been prepared using the htpacker tool.
In order to use HTTPS, specify the --key (or -k) flag. This should name a
PEM-encoded key file. This file may also contain the certificate; if not, then
pass the --cert (or -c) flag in addition.
Pack files may be specified as "/prefix=file", or just as "file" (which implies
"/=file"). Any /prefix present in the request URL will be stripped off before
searching the .htpack for the named file. Only one .htpack file can be served
at a particular prefix, and serving matches the longest (most specific)
prefixes first.`,
RunE: run,
}
func main() {
rootCmd.Flags().StringP("bind", "b", ":8080",
"Address to listen on / bind to")
rootCmd.Flags().StringP("key", "k", "",
"Path to PEM-encoded HTTPS key")
rootCmd.Flags().StringP("cert", "c", "",
"Path to PEM-encoded HTTPS cert")
rootCmd.Flags().StringSliceP("header", "H", nil,
"Extra headers; use flag once for each, in form -H header=value")
rootCmd.Flags().String("header-file", "",
"Path to text file containing one line for each header=value to add")
rootCmd.Flags().String("index-file", "",
"Name of index file (index.html or similar)")
rootCmd.Flags().Duration("expiry", 0,
"Tell client how long it can cache data for; 0 means no caching")
rootCmd.Flags().String("fallback-404", "",
"Name of file to return if response would be 404 (spa.html or similar)")
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(c *cobra.Command, args []string) error {
bindAddr, err := c.Flags().GetString("bind")
if err != nil {
return err
}
// parse TLS arguments
keyFile, err := c.Flags().GetString("key")
if err != nil {
return err
}
certFile, err := c.Flags().GetString("cert")
if err != nil {
return err
}
switch {
case keyFile == "" && certFile == "":
// nothing to do
case keyFile == "":
return errors.New("cannot specify --cert without --key")
case certFile == "":
certFile = keyFile
}
// parse extra headers
extraHeaders := make(http.Header)
hdrs, err := c.Flags().GetStringSlice("header")
if err != nil {
return err
}
for _, hdr := range hdrs {
pos := strings.IndexRune(hdr, '=')
if pos == -1 {
return fmt.Errorf("header %q must be in form "+
"name=value", hdr)
}
extraHeaders.Add(hdr[:pos], hdr[pos+1:])
}
hdrfile, err := c.Flags().GetString("header-file")
if err != nil {
return err
}
if err := loadHeaderFile(hdrfile, extraHeaders); err != nil {
fmt.Fprintln(os.Stderr, "--header-file:", err)
os.Exit(1)
}
// parse expiry time
// NB: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
expiry, err := c.Flags().GetDuration("expiry")
if err != nil {
return err
}
if expiry <= 0 {
extraHeaders.Set("Cache-Control", "no-cache")
} else {
extraHeaders.Set("Cache-Control",
fmt.Sprintf("public, max-age=%d", expiry/1e9))
}
// optional index file
// NB: this is set below, as the handlers are instantiated
indexFile, err := c.Flags().GetString("index-file")
if err != nil {
return err
}
// optional 404 fallback file
fallback404File, err := c.Flags().GetString("fallback-404")
if err != nil {
return err
}
// verify .htpack specifications
if len(args) == 0 {
return errors.New("must specify one or more .htpack files")
}
packPaths := make(map[string]string)
for _, arg := range args {
prefix, packfile := "/", arg
if pos := strings.IndexRune(arg, '='); pos != -1 {
prefix, packfile = arg[:pos], arg[pos+1:]
}
prefix = filepath.Clean(prefix)
if prefix[0] != '/' {
return fmt.Errorf("%s: prefix must start with '/'", arg)
}
if other, used := packPaths[prefix]; used {
return fmt.Errorf("%s: prefix %q already used by %s",
arg, prefix, other)
}
packPaths[prefix] = packfile
}
// load packfiles, registering handlers as we go
for prefix, packfile := range packPaths {
packHandler, err := htpack.New(packfile)
if err != nil {
return err
}
if indexFile != "" {
packHandler.SetIndex(indexFile)
}
if err = packHandler.SetNotFound(fallback404File); err != nil {
return fmt.Errorf("%s: fallback-404 resource %q "+
"not found in packfile", prefix, fallback404File)
}
handler := &addHeaders{
extraHeaders: extraHeaders,
handler: packHandler,
}
if prefix != "/" {
http.Handle(prefix+"/",
http.StripPrefix(prefix, handler))
} else {
http.Handle("/", handler)
}
}
// main server loop
if keyFile == "" {
err = http.ListenAndServe(bindAddr, nil)
} else {
err = http.ListenAndServeTLS(bindAddr, certFile, keyFile, nil)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return nil
}
func loadHeaderFile(hdrfile string, extraHeaders http.Header) error {
if hdrfile == "" {
return nil
}
f, err := os.Open(hdrfile)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var lineNum int
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
lineNum++
if line == "" {
continue
}
pos := strings.IndexRune(line, '=')
if pos == -1 {
return fmt.Errorf("%s: line %d: not in form "+
"header=value", hdrfile, lineNum)
}
extraHeaders.Add(line[:pos], line[pos+1:])
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("%s: %v", hdrfile, err)
}
return nil
}
type addHeaders struct {
extraHeaders http.Header
handler http.Handler
}
func (ah *addHeaders) ServeHTTP(w http.ResponseWriter, r *http.Request) {
for name, values := range ah.extraHeaders {
w.Header()[name] = append(w.Header()[name], values...)
}
ah.handler.ServeHTTP(w, r)
}