216 lines
7.6 KiB
Go
216 lines
7.6 KiB
Go
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 <slug>.<domain>.
|
|
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(`<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex"><title>Previews</title>
|
|
<style>
|
|
:root{color-scheme:light dark;--bg:#fbfaf7;--fg:#1d2430;--muted:#5d6573;--line:#e4e1da;--card:#fff;--bad:#b42318}
|
|
@media (prefers-color-scheme:dark){:root{--bg:#12161c;--fg:#e9e6e0;--muted:#a2a8b3;--line:#2a313c;--card:#1a2029;--bad:#f97066}}
|
|
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif}
|
|
main{width:min(880px,100% - 32px);margin:48px auto}
|
|
h1{margin:0 0 4px}p.sub{color:var(--muted);margin:0 0 28px}
|
|
ul{list-style:none;padding:0;display:grid;gap:12px}
|
|
li{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px}
|
|
a{color:inherit;font-weight:650}code{font-size:.9em}.meta{color:var(--muted);font-size:.9rem}
|
|
.err{color:var(--bad);white-space:pre-wrap;font-size:.88rem;margin:8px 0 0}.bad{color:var(--bad)}
|
|
</style></head><body><main>
|
|
<h1>Previews</h1>
|
|
<p class="sub">Each branch, built exactly as it would be published. Nothing here is live.</p>
|
|
{{ if not . }}<p>No branches yet.</p>{{ end }}
|
|
<ul>{{ range . }}<li>
|
|
{{ if .Commit }}<a href="{{ .URL }}">{{ .Branch }}</a>{{ else }}<strong>{{ .Branch }}</strong>{{ end }}
|
|
<div class="meta">{{ if .Commit }}<code>{{ printf "%.10s" .Commit }}</code> · {{ .Pages }} pages · built {{ .Built.Format "2 Jan 15:04:05" }}{{ else }}no good build yet{{ end }}</div>
|
|
{{ if .Commit }}<div class="meta">{{ if .Errors }}<strong class="bad">{{ .Errors }} check error(s): publishing is blocked until they're fixed</strong>{{ else }}Passes the checks{{ end }}{{ with .Warnings }} · {{ . }} warning(s){{ end }}</div>{{ end }}
|
|
{{ with .Error }}<p class="err">The last build failed, so the previous build (if any) is still shown.
|
|
{{ . }}</p>{{ end }}
|
|
</li>{{ end }}</ul>
|
|
</main></body></html>`))
|
|
|
|
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
|
|
}
|