package publish import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net/http" "os" "path/filepath" "time" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/check" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/gitx" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/isolate" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site" ) // PullOptions configure the pull agent: hotdog-cms running on the web server // itself, fetching the site's repository and rebuilding when the branch moves. // Nothing pushes to the server, so the server needs no deploy access, and the // repository can be read with a read-only deploy key or token. type PullOptions struct { Repo string // anything git can clone Branch string // default main Subdir string // the site's folder inside the repository, if not the root Out string // the web root to publish into Cache string // where the clone lives; default under the user cache dir Every time.Duration // 0 with no hook: check once and exit (systemd timer) Git gitx.Auth Log io.Writer OnBuild func(*isolate.Summary) // IndexNow tells search engines which pages changed after each publish. IndexNow bool // Optional: listen for the platform's push webhook and rebuild at once. // This opens a port on the server, so it is off unless asked for, and it // only answers deliveries signed with the secret. HookAddr string HookSecret string HookKind forge.Kind } // Pull checks the branch and rebuilds when it has moved; with Every or a // hook set, it keeps doing so. func Pull(o PullOptions) error { if err := gitx.Available(); err != nil { return fmt.Errorf("the pull agent needs git: %w", err) } if o.Branch == "" { o.Branch = "main" } if o.Cache == "" { base, err := os.UserCacheDir() if err != nil { return err } sum := sha256.Sum256([]byte(o.Repo + "\x00" + o.Branch)) o.Cache = filepath.Join(base, "hotdog-cms", "pull-"+hex.EncodeToString(sum[:8])) } kick := make(chan struct{}, 1) if o.HookAddr != "" { if o.HookSecret == "" { return errors.New("a webhook needs a secret (HOTDOG_HOOK_SECRET)") } go func() { srv := &http.Server{Addr: o.HookAddr, Handler: hookHandler(o, kick), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} fmt.Fprintf(o.Log, "listening for %s webhooks on %s/_hotdog/hook\n", o.HookKind, o.HookAddr) if err := srv.ListenAndServe(); err != nil { fmt.Fprintf(o.Log, "webhook listener: %v\n", err) } }() } var tick <-chan time.Time if o.Every > 0 { t := time.NewTicker(o.Every) defer t.Stop() tick = t.C } last := "" var due time.Time // a scheduled page's publish_at: rebuild then, commit or not for { head, err := o.sync() dueNow := !due.IsZero() && !time.Now().Before(due) switch { case err != nil: fmt.Fprintf(o.Log, "pull: %v\n", err) case head != last || dueNow: var res *isolate.Summary res, err = buildChecked(filepath.Join(o.Cache, o.Subdir), o.Out) if err != nil { err = fmt.Errorf("build of %s failed, the live site is unchanged: %w", short(head), err) fmt.Fprintln(o.Log, err) } else { fmt.Fprintf(o.Log, "published %s: %d pages to %s in %s\n", short(head), res.Pages, res.Out, res.Duration.Round(time.Millisecond)) if o.IndexNow { indexNow("pull", Target{Type: "dir", Path: o.Out}, o.Out, o.Log) } last = head due = res.NextScheduled if !due.IsZero() { fmt.Fprintf(o.Log, "next scheduled page goes live at %s\n", due.Format(time.RFC3339)) } if o.OnBuild != nil { o.OnBuild(res) } } } if o.Every == 0 && o.HookAddr == "" { return err } var wake <-chan time.Time if !due.IsZero() { wake = time.After(time.Until(due) + time.Second) } select { case <-tick: case <-kick: case <-wake: } } } // buildChecked builds into a staging folder, runs the site's checks on it, // and only then puts it live, so a commit that fails a check never reaches // the web root. func buildChecked(siteDir, out string) (*isolate.Summary, error) { staging := out + ".hotdog-cms-check" res, err := isolate.Build(build.Options{SiteDir: siteDir, Out: staging}) if err != nil { return nil, err } defer os.RemoveAll(staging) cfg, err := site.LoadConfig(siteDir) if err != nil { return nil, err } rep, err := check.Run(siteDir, staging, cfg) if err != nil { return nil, err } if n := rep.Errors(); n > 0 { first := "" for _, p := range rep.Problems { if p.Level == check.Error { first = p.String() break } } return nil, fmt.Errorf("%d check error(s), first: %s", n, first) } if err := build.Install(staging, out); err != nil { return nil, err } res.Out = out return res, nil } // sync brings the private clone to the branch's tip and returns its commit. func (o PullOptions) sync() (string, error) { if err := os.MkdirAll(filepath.Dir(o.Cache), 0o755); err != nil { return "", err } return gitx.Checkout(o.Git, o.Repo, o.Branch, o.Cache) } func hookHandler(o PullOptions, kick chan struct{}) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/_hotdog/hook" || r.Method != http.MethodPost { http.NotFound(w, r) return } body, err := io.ReadAll(io.LimitReader(r.Body, 5<<20)) if err != nil { http.Error(w, "unreadable body", http.StatusBadRequest) return } if err := forge.Verify(o.HookKind, r.Header, body, o.HookSecret); err != nil { fmt.Fprintf(o.Log, "webhook refused: %v\n", err) http.Error(w, "signature check failed", http.StatusUnauthorized) return } push, err := forge.ParsePush(o.HookKind, r.Header, body) if err != nil { w.WriteHeader(http.StatusNoContent) return } if push.Branch == o.Branch && !push.Deleted { select { case kick <- struct{}{}: default: } } w.WriteHeader(http.StatusAccepted) }) } func short(h string) string { if len(h) > 10 { return h[:10] } return h }