package preview import ( "crypto/subtle" "errors" "fmt" "html/template" "io" "net" "net/http" "os" "path/filepath" "strings" "time" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge" ) // Auth is the one set of credentials a preview server asks for. type Auth struct{ User, Pass string } // Handler serves the index of previews at the bare domain and each preview // at .. func (b *Builder) Handler(auth *Auth) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() // Previews are unpublished by definition: never indexed, never cached, // never framed by another site. h.Set("X-Robots-Tag", "noindex, nofollow, noarchive") h.Set("Cache-Control", "no-store") if len(b.o.FrameAncestors) > 0 { h.Set("Content-Security-Policy", "frame-ancestors 'self' "+strings.Join(b.o.FrameAncestors, " ")) } else { h.Set("X-Frame-Options", "SAMEORIGIN") } h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") // The webhook proves itself with the platform's signature instead of // the preview credentials, which a platform can't send. if r.URL.Path == "/_hotdog/hook" { b.hook(w, r) return } if auth != nil { u, p, ok := r.BasicAuth() if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(auth.User)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(auth.Pass)) != 1 { h.Set("WWW-Authenticate", `Basic realm="hotdog-cms previews", charset="UTF-8"`) http.Error(w, "Sign in to see previews.", http.StatusUnauthorized) return } } if r.URL.Path == "/robots.txt" { h.Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprint(w, "User-agent: *\nDisallow: /\n") return } host := r.Host if hh, _, err := net.SplitHostPort(host); err == nil { host = hh } host = strings.ToLower(strings.TrimSuffix(host, ".")) if host == b.o.Domain { b.index(w) return } slug, ok := strings.CutSuffix(host, "."+b.o.Domain) if !ok || strings.Contains(slug, ".") { http.Error(w, "No preview at this address.", http.StatusNotFound) return } dir, ok := b.dirFor(slug) if !ok { http.Error(w, "No preview at this address. It may still be building, or its branch may be gone.", http.StatusNotFound) return } serveFiles(w, r, dir) }) } // serveFiles answers the way the production web server config does. func serveFiles(w http.ResponseWriter, r *http.Request, root string) { clean := filepath.Clean("/" + r.URL.Path) if strings.HasPrefix(filepath.Base(clean), ".hotdog-cms") { clean = "/nonexistent" // hotdog-cms's own bookkeeping, as in production } p := filepath.Join(root, filepath.FromSlash(clean)) if fi, err := os.Stat(p); err == nil && fi.IsDir() { if !strings.HasSuffix(r.URL.Path, "/") { http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) return } p = filepath.Join(p, "index.html") } if fi, err := os.Stat(p); err != nil || fi.IsDir() { data, err := os.ReadFile(filepath.Join(root, "404.html")) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) _, _ = w.Write(data) return } http.ServeFile(w, r, p) } var indexTpl = template.Must(template.New("index").Parse(` Previews

Previews

Each branch, built exactly as it would be published. Nothing here is live.

{{ if not . }}

No branches yet.

{{ end }}
    {{ range . }}
  • {{ if .Commit }}{{ .Branch }}{{ else }}{{ .Branch }}{{ end }}
    {{ if .Commit }}{{ printf "%.10s" .Commit }} · {{ .Pages }} pages · built {{ .Built.Format "2 Jan 15:04:05" }}{{ else }}no good build yet{{ end }}
    {{ if .Commit }}
    {{ if .Errors }}{{ .Errors }} check error(s): publishing is blocked until they're fixed{{ else }}Passes the checks{{ end }}{{ with .Warnings }} · {{ . }} warning(s){{ end }}
    {{ end }} {{ with .Error }}

    The last build failed, so the previous build (if any) is still shown. {{ . }}

    {{ end }}
  • {{ end }}
`)) func (b *Builder) index(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = indexTpl.Execute(w, b.List()) } // Serve listens on addr. Off loopback it insists on credentials: previews // show what hasn't been published, so they are private unless set otherwise. func (b *Builder) Serve(addr string, auth *Auth, every time.Duration) error { host, _, err := net.SplitHostPort(addr) if err != nil { return err } ip := net.ParseIP(host) loopback := host == "localhost" || (ip != nil && ip.IsLoopback()) if !loopback && auth == nil { return fmt.Errorf("previews show unpublished work: to listen on %s, set credentials (HOTDOG_PREVIEW_AUTH=user:password) and put TLS in front", addr) } go func() { t := time.NewTicker(every) defer t.Stop() for { if err := b.Sync(); err != nil { fmt.Fprintf(b.o.Log, "previews: %v\n", err) } select { case <-t.C: case <-b.kick: } } }() srv := &http.Server{Addr: addr, Handler: b.Handler(auth), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 120 * time.Second, MaxHeaderBytes: 16 << 10} fmt.Fprintf(b.o.Log, "previews of %s at %s://%s%s/\n", b.o.Repo, b.o.Scheme, b.o.Domain, portSuffix(b.o.Port)) return srv.ListenAndServe() } // hook takes a platform's push webhook and syncs at once. func (b *Builder) hook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } if b.o.HookSecret == "" { http.Error(w, "No webhook is set up here.", http.StatusNotFound) return } body, err := io.ReadAll(io.LimitReader(r.Body, 5<<20)) if err != nil { http.Error(w, "unreadable body", http.StatusBadRequest) return } kind := forge.Git if b.o.Forge != nil { kind = b.o.Forge.Repo.Kind } if err := forge.Verify(kind, r.Header, body, b.o.HookSecret); err != nil { fmt.Fprintf(b.o.Log, "webhook refused: %v\n", err) http.Error(w, "signature check failed", http.StatusUnauthorized) return } push, err := forge.ParsePush(kind, r.Header, body) if errors.Is(err, forge.ErrNotPush) { w.WriteHeader(http.StatusNoContent) return } if err != nil { http.Error(w, "unreadable push event", http.StatusBadRequest) return } if b.wanted(push.Branch) { fmt.Fprintf(b.o.Log, "webhook: %s moved to %s\n", push.Branch, short(push.Commit)) b.Kick() } w.WriteHeader(http.StatusAccepted) } func portSuffix(p string) string { if p == "" { return "" } return ":" + p }