diff --git a/cmd/listen.go b/cmd/listen.go new file mode 100644 index 0000000..be275b5 --- /dev/null +++ b/cmd/listen.go @@ -0,0 +1,100 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "git.ronmi.tw/ronmi/forgejo-pages/lib" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// listenCmd represents the listen command +var listenCmd = &cobra.Command{ + Use: "listen", + Short: "Start webhook listener", + Run: func(cmd *cobra.Command, args []string) { + viper.BindPFlags(cmd.Flags()) + // check flags + bind := viper.GetString("bind") + server := viper.GetString("server") + user := viper.GetString("user") + token := viper.GetString("token") + dir := viper.GetString("dir") + branch := viper.GetString("branch") + if bind == "" || server == "" || user == "" || token == "" || branch == "" || dir == "" { + fmt.Println("bind, server, user, token, branch and dir are required") + fmt.Println("dumping flags:") + fmt.Println(" bind: ", bind) + fmt.Println(" server: ", server) + fmt.Println(" user: ", user) + fmt.Println(" token: ", token) + fmt.Println(" branch: ", branch) + fmt.Println(" dir: ", dir) + return + } + serverUrl, err := url.Parse(server) + if err != nil { + fmt.Println("invalid server url: ", err) + return + } + + cfg := &lib.WebhookCFG{ + Forgejo: lib.Forgejo{ + Server: *serverUrl, + Token: token, + Branch: branch, + }, + GitDir: filepath.Join(dir, "git"), + PageDir: filepath.Join(dir, "pages"), + GitUser: user, + GitPass: token, + } + + s, err := lib.UseWebhook(bind, cfg) + if err != nil { + fmt.Println("cannot create server: ", err) + return + } + + ctx, stop := signal.NotifyContext( + context.TODO(), + os.Interrupt, + syscall.SIGTERM, + os.Kill, + ) + defer stop() + + fmt.Println("starting server") + go func() { + <-ctx.Done() + stop() + ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second) + defer cancel() + s.Shutdown(ctx) + }() + s.ListenAndServe() + }, +} + +func init() { + rootCmd.AddCommand(listenCmd) + + f := listenCmd.Flags() + f.StringP("bind", "a", ":8080", "bind address") + f.StringP("server", "s", "", "Forgejo server address") + f.StringP("user", "u", "", "Forgejo user") + f.StringP("token", "k", "", "Forgejo api token or password") + f.StringP("branch", "b", "static-pages", "branch to use") + f.StringP("dir", "d", "", "directory to store data, must be writable") +} diff --git a/cmd/serve.go b/cmd/serve.go index f6f6810..2c9ce68 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -23,6 +23,7 @@ var serveCmd = &cobra.Command{ Use: "serve", Short: "Start the static page server.", Run: func(cmd *cobra.Command, args []string) { + viper.BindPFlags(cmd.Flags()) // check flags bind := viper.GetString("bind") server := viper.GetString("server") @@ -83,5 +84,4 @@ func init() { f.StringP("token", "k", "", "Forgejo api token") f.StringP("branch", "b", "static-pages", "branch to use") f.StringP("well-known", "w", "/.well-known", "well-known path, used by LetsEncrypt") - viper.BindPFlags(f) } diff --git a/lib/hook.go b/lib/hook.go new file mode 100644 index 0000000..30cfc98 --- /dev/null +++ b/lib/hook.go @@ -0,0 +1,189 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package lib + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" +) + +type WebhookCFG struct { + Forgejo + PageDir string + GitDir string + GitUser string + GitPass string +} + +func (c *WebhookCFG) pageDir(user, repo string) string { + return filepath.Join(c.PageDir, user, repo) +} + +func (c *WebhookCFG) gitDir(user, repo string) string { + return filepath.Join(c.GitDir, user, repo) +} + +func (c *WebhookCFG) checkout(user, repo string) (err error) { + pageDir := c.pageDir(user, repo) + gitDir := c.gitDir(user, repo) + + git := exec.Command( + "git", + "--git-dir", gitDir, + "--work-tree", pageDir, + "checkout", "origin/"+c.Branch, + ) + output, err := git.CombinedOutput() + if err != nil { + fmt.Println("git checkout failed: ", err) + fmt.Println("=========== Dump output: ============") + fmt.Println(string(output)) + fmt.Println("=====================================") + } + + return +} + +func (c *WebhookCFG) fetch(user, repo string) (err error) { + gitDir := c.gitDir(user, repo) + git := exec.Command( + "git", + "--git-dir", gitDir, + "fetch", "origin", c.Branch, + ) + output, err := git.CombinedOutput() + if err != nil { + fmt.Println("git fetch failed: ", err) + fmt.Println("=========== Dump output: ============") + fmt.Println(string(output)) + fmt.Println("=====================================") + } + + return +} + +func (c *WebhookCFG) clone(user, repo string) (err error) { + pageDir := c.pageDir(user, repo) + err = os.MkdirAll(filepath.Dir(pageDir), 0755) + if err != nil { + return + } + + gitDir := c.gitDir(user, repo) + err = os.MkdirAll(filepath.Dir(gitDir), 0700) + if err != nil { + return + } + + uri := c.Server + uri.Path = "/" + path.Join(user, repo) + ".git" + uri.User = url.UserPassword(c.GitUser, c.GitPass) + + git := exec.Command( + "git", + "clone", + "-b", c.Branch, + "--single-branch", + "--no-tags", + "--separate-git-dir", gitDir, + uri.String(), + pageDir, + ) + output, err := git.CombinedOutput() + if err != nil { + fmt.Println("git clone failed: ", err) + fmt.Println("=========== Dump output: ============") + fmt.Println(string(output)) + fmt.Println("=====================================") + } + + os.Remove(filepath.Join(pageDir, ".git")) + + return +} + +func (c *WebhookCFG) download(user, repo string) (err error) { + gitDir := c.gitDir(user, repo) + if _, err = os.Stat(gitDir); os.IsNotExist(err) { + err = c.clone(user, repo) + } else { + err = c.fetch(user, repo) + } + + if err != nil { + return + } + + err = c.checkout(user, repo) + return +} + +type webhookPayload struct { + Ref string `json:"ref"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` +} + +func (c *WebhookCFG) handle(w http.ResponseWriter, r *http.Request) { + fmt.Println("Received webhook event") + ev := r.Header.Get("X-GitHub-Event") + if ev != "push" { + // skip non-push events + fmt.Println("Skip non-push event: ", ev) + return + } + defer r.Body.Close() + + data, err := io.ReadAll(r.Body) + if err != nil { + fmt.Println("Failed to read request body: ", err) + return + } + + var payload webhookPayload + err = json.Unmarshal(data, &payload) + if err != nil { + fmt.Println("Failed to parse request body: ", err) + fmt.Println("=========== Dump body: ============") + fmt.Println(string(data)) + fmt.Println("===================================") + return + } + + if payload.Ref != "refs/heads/"+c.Branch { + // skip non-branch events + fmt.Println("Skip different branch: ", payload.Ref) + return + } + + user, repo := path.Split(payload.Repository.FullName) + user = user[:len(user)-1] + fmt.Println("Pulling ", user, repo) + err = c.download(user, repo) + if err != nil { + fmt.Println("Failed to download repository: ", err) + return + } +} + +func UseWebhook(bind string, cfg *WebhookCFG) (*http.Server, error) { + if cfg == nil { + return nil, errors.New("webhook config is nil") + } + s := &http.Server{ + Addr: bind, + Handler: http.HandlerFunc(cfg.handle), + } + return s, nil +}