207 lines
6.0 KiB
Go
207 lines
6.0 KiB
Go
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
|
|
}
|