mirror of
https://github.com/1f349/site-hosting.git
synced 2025-01-21 22:56:27 +00:00
179 lines
4.7 KiB
Go
179 lines
4.7 KiB
Go
package serve
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"github.com/1f349/bluebell/database"
|
|
"github.com/1f349/bluebell/logger"
|
|
"github.com/spf13/afero"
|
|
"html/template"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
//go:embed missing-branch.go.html
|
|
missingBranchHtml string
|
|
missingBranchTemplate = template.Must(template.New("missingBranchHtml").Parse(missingBranchHtml))
|
|
|
|
indexFiles = []func(p string) string{
|
|
func(p string) string { return p },
|
|
func(p string) string { return p + ".html" },
|
|
func(p string) string { return path.Join(p, "index.html") },
|
|
}
|
|
)
|
|
|
|
func isInvalidIndexPath(p string) bool {
|
|
switch p {
|
|
case ".", ".html":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const (
|
|
BetaCookieName = "__bluebell-site-beta"
|
|
BetaSwitchPath = "/__bluebell-switch-beta"
|
|
BetaExpiry = 24 * time.Hour
|
|
|
|
NoCacheQuery = "/?__bluebell-no-cache="
|
|
)
|
|
|
|
type sitesQueries interface {
|
|
GetLastUpdatedByDomainBranch(ctx context.Context, params database.GetLastUpdatedByDomainBranchParams) (time.Time, error)
|
|
}
|
|
|
|
func New(storage afero.Fs, db sitesQueries) *Handler {
|
|
return &Handler{storage, db}
|
|
}
|
|
|
|
type Handler struct {
|
|
storageFs afero.Fs
|
|
db sitesQueries
|
|
}
|
|
|
|
func cacheBuster(rw http.ResponseWriter, req *http.Request) {
|
|
header := rw.Header()
|
|
header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
header.Set("Pragma", "no-cache")
|
|
header.Set("Expires", "0")
|
|
http.Redirect(rw, req, NoCacheQuery+strconv.FormatInt(time.Now().Unix(), 16), http.StatusFound)
|
|
}
|
|
|
|
func (h *Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|
host, _, err := net.SplitHostPort(req.Host)
|
|
if err != nil {
|
|
host = req.Host
|
|
}
|
|
|
|
// detect beta switch path
|
|
if req.URL.Path == BetaSwitchPath {
|
|
q := req.URL.Query()
|
|
|
|
// init cookie
|
|
baseCookie := &http.Cookie{
|
|
Name: BetaCookieName,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
|
|
// reset beta
|
|
if q.Has("reset") {
|
|
baseCookie.MaxAge = -1
|
|
http.SetCookie(rw, baseCookie)
|
|
cacheBuster(rw, req)
|
|
return
|
|
}
|
|
|
|
// set beta branch
|
|
baseCookie.Value = q.Get("branch")
|
|
baseCookie.Expires = time.Now().Add(BetaExpiry)
|
|
http.SetCookie(rw, baseCookie)
|
|
cacheBuster(rw, req)
|
|
return
|
|
}
|
|
|
|
// read the beta cookie
|
|
branchCookie, _ := req.Cookie(BetaCookieName)
|
|
var branch = "@"
|
|
if branchCookie != nil {
|
|
branch += branchCookie.Value
|
|
}
|
|
|
|
updated, err := h.db.GetLastUpdatedByDomainBranch(req.Context(), database.GetLastUpdatedByDomainBranchParams{Domain: host, Branch: branch})
|
|
if err != nil {
|
|
rw.WriteHeader(http.StatusMisdirectedRequest)
|
|
_ = missingBranchTemplate.Execute(rw, struct{ Host string }{host})
|
|
logger.Logger.Debug("Branch is not available", "host", host, "branch", branch, "err", err)
|
|
return
|
|
}
|
|
|
|
if h.tryServePath(rw, req, host, branch, updated, req.URL.Path) {
|
|
return // page has been served
|
|
}
|
|
|
|
// tryServePath found no matching files
|
|
http.Error(rw, "404 Not Found", http.StatusNotFound)
|
|
logger.Logger.Debug("No matching file was found")
|
|
}
|
|
|
|
// tryServePath attempts to find a valid path from the indexFiles list
|
|
func (h *Handler) tryServePath(rw http.ResponseWriter, req *http.Request, site, branch string, updated time.Time, p string) bool {
|
|
for _, i := range indexFiles {
|
|
// skip invalid paths "." and ".html"
|
|
p2 := path.Clean(i(p))
|
|
if isInvalidIndexPath(p2) {
|
|
continue
|
|
}
|
|
|
|
if h.tryServeFile(rw, req, site, branch, updated, p2) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// tryServeFile attempts to serve the content of a file if the file can be found
|
|
//
|
|
// If a matching file can be found or an internal error has occurred then the return value is true to prevent further changes to the response.
|
|
//
|
|
// If branch == "@" then time based caching is enabled for subsequent page loads. Otherwise, time based caching is disabled to prevent stale beta content from being cached.
|
|
func (h *Handler) tryServeFile(rw http.ResponseWriter, req *http.Request, site, branch string, updated time.Time, p string) bool {
|
|
// prevent path traversal
|
|
if strings.Contains(site, "..") || strings.Contains(branch, "..") || strings.Contains(p, "..") {
|
|
http.Error(rw, "400 Bad Request", http.StatusBadRequest)
|
|
return true
|
|
}
|
|
|
|
servePath := filepath.Join(site, branch, p)
|
|
logger.Logger.Debug("Serving file", "full", servePath, "site", site, "branch", branch, "file", p)
|
|
open, err := h.storageFs.Open(servePath)
|
|
switch {
|
|
case err == nil:
|
|
// ignore directories
|
|
stat, err := open.Stat()
|
|
if err != nil || stat.IsDir() {
|
|
return false
|
|
}
|
|
|
|
// disable timed cache for non-main branches
|
|
if branch != "@" {
|
|
updated = time.Time{}
|
|
}
|
|
http.ServeContent(rw, req, p, updated, open)
|
|
case os.IsNotExist(err):
|
|
// check next path
|
|
return false
|
|
default:
|
|
http.Error(rw, "500 Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
return true
|
|
}
|