Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically Create Webhook #8

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ It assumes the URL `%(ROOT_URL)s/static/assets/` loads from
Gitea's `%(GITEA_CUSTOM)/public/`; it is **not** compatible
with configuring Gitea's `%(STATIC_URL_PREFIX)` so that
static files are hosted on a different server or CDN.

`%(GITEA_TOKEN)` must be from an admin account with the "all" scope for two reasons:

1. To install the webhook that notifies on pushes
2. To be able to post status icons on any repo without being a member of all repos

Perhaps in the future Gitea will offer even more finely-grained scopes, but today is not that day.
163 changes: 150 additions & 13 deletions bids-hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"net"
"net/http"
"net/url"
"os"
Expand All @@ -38,8 +41,7 @@ var (
bidsHookUrl *url.URL

// secret used to authenticate api calls from Gitea to bids-hook
// read from environment variable BIDS_HOOK_SECRET
// this should be entered as-in in Gitea to configure the webhook
// generated fresh on each startup
bidsHookSecret []byte

// the base URL to reach Gitea
Expand Down Expand Up @@ -111,8 +113,20 @@ func main() {
WriteTimeout: 3 * time.Second,
MaxHeaderBytes: 4 << 10,
}
addr := server.Addr
if addr == "" {
addr = ":http"
}
sock, err := net.Listen("tcp", addr)
if err != nil {
log.Fatal(err)
}
log.Printf("main: listening on %q", bidsHookUrl)
log.Fatal(server.ListenAndServe())
err = installWebhook()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
err = installWebhook()
// to avoid breaking a pre-existing bids-hook only tell Gitea about us once we know we own addr
err = installWebhook()

if err != nil {
log.Fatalf("error installing webhook: %v", err)
}
log.Fatal(server.Serve(sock))
Comment on lines +116 to +129
Copy link
Member Author

@kousu kousu Feb 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for all this is so that we don't installWebhook until we know we own addr. Otherwise we might break an existing webhook.

}

// router checks the host, method and target of the request,
Expand Down Expand Up @@ -156,6 +170,130 @@ func router(w http.ResponseWriter, r *http.Request) {
return
}

type Hook struct {
ID int64 `json:"id"`
Type string `json:"type"`
URL string `json:"-"`
Config map[string]string `json:"config"`
Events []string `json:"events"`
AuthorizationHeader string `json:"authorization_header"`
Active bool `json:"active"`
IsSystemWebhook bool `json:"is_system_webhook"`
Updated time.Time `json:"updated_at"`
Created time.Time `json:"created_at"`
}

// set up the webhook
func installWebhook() error {
url := giteaRootUrl.JoinPath("api", "v1", "admin", "hooks")

_bidsHookSecret := make([]byte, 32)
_, err := rand.Read(_bidsHookSecret)
if err != nil {
return err
}
// hex-encode, to avoid any trouble with unusual characters
bidsHookSecret = make([]byte, hex.EncodedLen(len(_bidsHookSecret)))
hex.Encode(bidsHookSecret, _bidsHookSecret)

// This is the hook we want to exist
// Note: Gitea internally uses a CreateHookOption or a EditHookOption for these
// which are two different subsets of the Hook type. So we're not being 100% correct here.
log.Printf("SECERT: %s", string(bidsHookSecret))
hook := Hook{
Type: "gitea",
Config: map[string]string{
"content_type": "json",
"url": bidsHookUrl.String(),
// notice: we *regenerate* the secret *each time*
// it is entirely ephemeral, and thus we must always create/patch the hook
"secret": string(bidsHookSecret),
},
Events: []string{"push"},
Active: true,
IsSystemWebhook: true,
}

hookJSON, err := json.Marshal(hook)
if err != nil {
return err
}

// Check if the hook already exists
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
if err != nil {
return err
}
req.Header.Add("Authorization", fmt.Sprintf("token %s", giteaToken))
req.Header.Add("Accept", "application/json")
log.Printf("Calling %s %s\n", req.Method, url.String()) // DEBUG

resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return errors.New(fmt.Sprintf("got http status code %d", resp.StatusCode))
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
var hooks []Hook
err = json.Unmarshal(body, &hooks)
if err != nil {
return err
}
fmt.Printf("Read this: |%s|\n", body)
// search the result for pre-existing webhook
found := false
var id int64
fmt.Printf("hook: %#v\n", hook) // DEBUG
for _, _hook := range hooks {
fmt.Printf("hook: %#v\n", _hook) // DEBUG
if _hook.URL == hook.URL {
fmt.Printf("found at id: %#v\n", _hook.ID) // DEBUG
found = true
id = _hook.ID
break
}
}

// depending on whether we found, either use POST or PATCH
method := http.MethodPost
if found {
method = http.MethodPatch
url = url.JoinPath(fmt.Sprintf("%d", id))
}
log.Printf("Calling %s %s\n", method, url.String()) // DEBUG

req, err = http.NewRequest(method, url.String(), bytes.NewReader(hookJSON))
if err != nil {
return err
}
req.Header.Add("Authorization", fmt.Sprintf("token %s", giteaToken))
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")

resp, err = http.DefaultClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode != 200 && resp.StatusCode != 201 {
log.Printf("Bailing\n")
return errors.New(fmt.Sprintf("got http status code %d", resp.StatusCode))
}
body, err = ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Printf("Read: |%s|\n", body)
defer resp.Body.Close()

return nil
}

// postHandler deals with requests that have successfully passed
// through the router based on their host, method and target.
func postHandler(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -279,13 +417,13 @@ type job struct {
// web link to the results page for this job
// see also j.resultPath()
func (j job) resultUrl() string {
return giteaRootUrl.JoinPath("assets", fmt.Sprintf("%s.html", j.uuid)).String()
return giteaRootUrl.JoinPath("assets", "bids-validator", j.uuid[:2], j.uuid[2:4], fmt.Sprintf("%s.html", j.uuid)).String()
}

// file path to the results page for this job
// see also j.resultUrl()
func (j job) resultPath() string {
return filepath.Join(giteaCustom, "public", fmt.Sprintf("%s.html", j.uuid))
return filepath.Join(giteaCustom, "public", "bids-validator", j.uuid[:2], j.uuid[2:4], fmt.Sprintf("%s.html", j.uuid))
}

// file path to the log file for this job
Expand Down Expand Up @@ -360,7 +498,12 @@ func (j job) run() (state string, _ error) {
)

// redirect stdout to the result file
stdout, err := os.OpenFile(j.resultPath(), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
resultPath := j.resultPath()
err := os.MkdirAll(filepath.Dir(resultPath), 0750)
if err != nil {
return stateError, err
}
stdout, err := os.OpenFile(resultPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0640)
if err != nil {
return stateError, err
}
Expand Down Expand Up @@ -435,12 +578,6 @@ func readConfig() {
log.Fatalf("error parsing BIDS_HOOK_URL: %v", err)
}

val, ok = os.LookupEnv("BIDS_HOOK_SECRET")
if !ok {
log.Fatal("missing environment variable BIDS_HOOK_SECRET")
}
bidsHookSecret = []byte(val)

val, ok = os.LookupEnv("GITEA_ROOT_URL")
if !ok {
log.Fatal("missing environment variable GITEA_ROOT_URL")
Expand All @@ -464,7 +601,7 @@ func readConfig() {
if err != nil {
log.Fatalf("invalid GITEA_CUSTOM: %v", err)
}
err = os.MkdirAll(filepath.Join(giteaCustom, "public"), 0750)
err = os.MkdirAll(filepath.Join(giteaCustom, "public", "bids-validator"), 0750)
if err != nil {
log.Fatalf("error creating output folder: %v", err)
}
Expand Down
1 change: 0 additions & 1 deletion start
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export GITEA_CUSTOM

# 127.0.0.1 is localhost, and 2845 is 0xB1D
export BIDS_HOOK_URL='http://127.0.0.1:2845/bids-hook'
export BIDS_HOOK_SECRET='blabla'

export GITEA_ROOT_URL='http://127.0.0.1:3000'
export GITEA_TOKEN='69e45fa9cfa75a7497633c6be8dd2347226e2f62'
Expand Down