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 serveQueries interface { GetLastUpdatedByDomainBranch(ctx context.Context, params database.GetLastUpdatedByDomainBranchParams) (time.Time, error) } func New(storage afero.Fs, db serveQueries) *Handler { return &Handler{storage, db} } type Handler struct { storageFs afero.Fs db serveQueries } 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 }