1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
package server

import (
	"io/fs"
	"net/http"
	"rss-kakapo/shared"
	"rss-kakapo/www"
	"strconv"
	"strings"
	"time"
)

const assetsDir = "/assets"
const faviconName = "/favicon.ico"

// startTime is the Last-Modified of every asset: they are compiled into the
// binary, so they can only have changed when it was started.
var startTime = time.Now()

const strCacheControlHdr = "Cache-Control"

// NewHTTPServer returns the server for router on the configured port. It
// does not start it; main listens, serves and shuts it down.
func NewHTTPServer(cfg *shared.Config,
	router *http.ServeMux,
) *http.Server {
	addStr := ":" + strconv.FormatUint(uint64(cfg.ServicePort), 10)
	srv := &http.Server{Addr: addStr, Handler: trimSlashHandler(router)}
	return srv
}

func trimSlashHandler(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/" && !strings.HasPrefix(r.URL.Path, assetsDir) {
			r.URL.Path = strings.TrimSuffix(r.URL.Path, "/")
		}
		next.ServeHTTP(w, r)
	})
}

// NewMux routes each handler definition by method and path, as
// "METHOD <prefix><pattern>". A GET route also answers HEAD; a known path
// asked with another method gets 405. Everything else that is a GET is a
// static asset or the 404 page.
func NewMux(groups []IHandlerGroup, logger shared.ILogger) *http.ServeMux {

	var notFoundHandler func(w http.ResponseWriter, r *http.Request) = nil

	router := http.NewServeMux()
	for _, group := range groups {
		prefix := strings.TrimRight(group.Prefix(), "/")
		authMW := group.AuthMW()
		for _, def := range group.GroupDefs() {
			if def.pattern == rootPlacholder {
				router.HandleFunc(def.method+" /{$}", def.handler)
			} else if def.pattern == notFoundPlacholder {
				notFoundHandler = def.handler
			} else {
				router.Handle(def.method+" "+prefix+def.pattern,
					noCacheMW(authMW(http.HandlerFunc(def.handler))))
			}
		}
	}
	router.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		handleStatic(logger, notFoundHandler, w, r)
	})
	return router
}

func noCacheMW(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set(strCacheControlHdr, "no-cache, no-store, must-revalidate")
		w.Header().Set("Pragma", "no-cache")
		w.Header().Set("Expires", "0")
		next.ServeHTTP(w, r)
	})
}

func handleStatic(logger shared.ILogger,
	notFoundHandler func(w http.ResponseWriter, r *http.Request),
	w http.ResponseWriter, r *http.Request,
) {

	return404 := func() {
		logger.Infof("%s %s returns 404", r.Method, r.URL.Path)
		if notFoundHandler == nil {
			http.Error(w, notFoundStr, http.StatusNotFound)
		} else {
			notFoundHandler(w, r)
		}
	}

	w.Header().Set(strCacheControlHdr, "max-age=31536000, immutable")

	// We serve everything from /assets folder, EXCEPT favicon.ico, which gets special treatment
	if r.URL.Path != faviconName && !strings.HasPrefix(r.URL.Path, assetsDir) {
		return404()
		return
	}

	fn := strings.TrimPrefix(r.URL.Path, "/")
	if r.URL.Path == faviconName {
		fn = strings.TrimPrefix(assetsDir+faviconName, "/")
	}
	// fs.ValidPath rejects "..", so nothing outside assets/ can be named.
	if !fs.ValidPath(fn) {
		return404()
		return
	}
	content, err := fs.ReadFile(www.FS, fn)
	if err != nil {
		return404()
		return
	}

	w.Header().Set("Content-Length", strconv.Itoa(len(content)))
	w.Header().Set("Last-Modified", startTime.UTC().Format(http.TimeFormat))
	if strings.HasSuffix(r.URL.Path, ".svg") {
		w.Header().Set("Content-Type", "image/svg+xml")
	} else if strings.HasSuffix(r.URL.Path, ".css") {
		w.Header().Set("Content-Type", "text/css; charset=utf-8")
	}
	if _, err = w.Write(content); err != nil {
		logger.Errorf("Error writing response: %v", err)
	}
}