Skip to content

Commit 7f34f85

Browse files
committed
chore: move github sponsors webhook to its own service
Signed-off-by: Xe Iaso <me@xeiaso.net>
1 parent dce2f92 commit 7f34f85

9 files changed

Lines changed: 201 additions & 38 deletions

File tree

.github/workflows/earthly.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ name: Docker
22

33
on:
44
push:
5-
branches: [ "main" ]
5+
branches: ["main"]
66
# Publish semver tags as releases.
7-
tags: [ 'v*.*.*' ]
7+
tags: ["v*.*.*"]
88
pull_request:
9-
branches: [ "main" ]
9+
branches: ["main"]
1010

1111
jobs:
1212
build:
@@ -21,7 +21,7 @@ jobs:
2121
- name: Set up Docker Buildx
2222
uses: docker/setup-buildx-action@v3
2323

24-
- name: Log into registry
24+
- name: Log into registry
2525
if: github.event_name != 'pull_request'
2626
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
2727
with:
@@ -37,6 +37,7 @@ jobs:
3737
source: .
3838
push: true
3939
set: |
40+
github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest
4041
patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest
4142
xesite.tags=ghcr.io/xe/site/bin:latest
4243
@@ -47,7 +48,6 @@ jobs:
4748
source: .
4849
push: false
4950
set: |
51+
github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest
5052
patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest
5153
xesite.tags=ghcr.io/xe/site/bin:latest
52-
53-

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ cw.tar
1111
node_modules
1212
/xesite
1313
/xesitectl
14+
/github-sponsor-webhook

cmd/github-sponsor-webhook/main.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"log"
7+
"log/slog"
8+
"net"
9+
"net/http"
10+
"os"
11+
12+
"github.com/donatj/hmacsig"
13+
"github.com/facebookgo/flagenv"
14+
_ "github.com/joho/godotenv/autoload"
15+
"github.com/prometheus/client_golang/prometheus/promhttp"
16+
"xeiaso.net/v4/internal"
17+
)
18+
19+
var (
20+
bind = flag.String("bind", ":8080", "Port to listen on")
21+
dataDir = flag.String("data-dir", "./var", "Directory to store data in")
22+
githubSponsorsSecret = flag.String("github-sponsors-secret", "", "GitHub Sponsors secret to use for webhooks")
23+
logLevel = flag.String("log-level", "info", "Log level (debug, info, warn, error)")
24+
)
25+
26+
func main() {
27+
flagenv.Parse()
28+
flag.Parse()
29+
internal.Slog()
30+
31+
_ = context.Background()
32+
33+
ln, err := net.Listen("tcp", *bind)
34+
if err != nil {
35+
log.Fatal(err)
36+
}
37+
38+
os.MkdirAll(*dataDir, 0700)
39+
40+
if *githubSponsorsSecret == "" {
41+
slog.Error("github-sponsors-secret is required")
42+
os.Exit(1)
43+
}
44+
45+
slog.Info("starting GitHub Sponsors webhook service",
46+
"bind", *bind,
47+
"data_dir", *dataDir,
48+
)
49+
50+
gsh := &GitHubSponsorsWebhook{}
51+
s := hmacsig.Handler256(gsh, *githubSponsorsSecret)
52+
53+
mux := http.NewServeMux()
54+
mux.Handle("/webhook", s)
55+
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
56+
w.Header().Set("Content-Type", "application/json")
57+
w.WriteHeader(http.StatusOK)
58+
w.Write([]byte(`{"status":"ok","service":"github-sponsor-webhook"}`))
59+
})
60+
61+
// Expose Prometheus metrics at /metrics for observability
62+
mux.Handle("/metrics", promhttp.Handler())
63+
64+
var h http.Handler = mux
65+
h = internal.CacheHeader(h)
66+
h = internal.AcceptEncodingMiddleware(h)
67+
h = internal.RefererMiddleware(h)
68+
69+
slog.Info("GitHub Sponsors webhook service ready", "bind", *bind)
70+
log.Fatal(http.Serve(ln, h))
71+
}
Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,65 @@ package main
22

33
import (
44
"encoding/json"
5-
"expvar"
65
"fmt"
76
"log/slog"
87
"net/http"
98

10-
"tailscale.com/metrics"
9+
"github.com/prometheus/client_golang/prometheus"
1110
"xeiaso.net/v4/internal/github"
12-
"xeiaso.net/v4/internal/lume"
1311
)
1412

1513
var (
16-
sponsorsWebhookCount = metrics.LabelMap{Label: "action"}
17-
sponsorsErrorCount = metrics.LabelMap{Label: "error_type"}
14+
sponsorsWebhookCount = prometheus.NewCounterVec(
15+
prometheus.CounterOpts{
16+
Name: "github_sponsors_webhook_total",
17+
Help: "Total number of GitHub Sponsors webhook events processed, by action",
18+
},
19+
[]string{"action"},
20+
)
21+
22+
sponsorsErrorCount = prometheus.NewCounterVec(
23+
prometheus.CounterOpts{
24+
Name: "github_sponsors_webhook_errors_total",
25+
Help: "Total number of GitHub Sponsors webhook errors, by error type",
26+
},
27+
[]string{"error_type"},
28+
)
1829
)
1930

2031
func init() {
21-
expvar.Publish("gauge_xesite_sponsors_webhook_count", &sponsorsWebhookCount)
22-
expvar.Publish("gauge_xesite_sponsors_error_count", &sponsorsErrorCount)
32+
prometheus.MustRegister(sponsorsWebhookCount)
33+
prometheus.MustRegister(sponsorsErrorCount)
2334
}
2435

2536
// GitHubSponsorsWebhook handles GitHub Sponsors webhook events.
2637
type GitHubSponsorsWebhook struct {
27-
fs *lume.FS
38+
// No lume.FS dependency for the standalone service
2839
}
2940

3041
// ServeHTTP processes incoming GitHub Sponsors webhook events.
3142
func (gsh *GitHubSponsorsWebhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
43+
// Set CORS headers for web requests
44+
w.Header().Set("Access-Control-Allow-Origin", "*")
45+
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
46+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-GitHub-Event, X-Hub-Signature-256")
47+
48+
if r.Method == "OPTIONS" {
49+
w.WriteHeader(http.StatusOK)
50+
return
51+
}
52+
53+
if r.Method != "POST" {
54+
slog.Info("method not allowed", "method", r.Method)
55+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
56+
return
57+
}
58+
3259
// Check for GitHub Sponsors event header
3360
eventType := r.Header.Get("X-GitHub-Event")
3461
if eventType != "sponsorship" {
3562
slog.Info("not a sponsorship event", "event", eventType)
36-
sponsorsErrorCount.Add("invalid_event_type", 1)
63+
sponsorsErrorCount.WithLabelValues("invalid_event_type").Inc()
3764
http.Error(w, "Invalid event type", http.StatusBadRequest)
3865
return
3966
}
@@ -42,13 +69,13 @@ func (gsh *GitHubSponsorsWebhook) ServeHTTP(w http.ResponseWriter, r *http.Reque
4269
var event github.SponsorsEvent
4370
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
4471
slog.Error("error decoding GitHub Sponsors event", "error", err)
45-
sponsorsErrorCount.Add("decode_error", 1)
72+
sponsorsErrorCount.WithLabelValues("decode_error").Inc()
4673
http.Error(w, err.Error(), http.StatusBadRequest)
4774
return
4875
}
4976

5077
// Increment the specific action counter
51-
sponsorsWebhookCount.Add(event.Action, 1)
78+
sponsorsWebhookCount.WithLabelValues(event.Action).Inc()
5279

5380
// Log the sponsorship event
5481
slog.Info("GitHub Sponsors webhook received",
@@ -57,6 +84,7 @@ func (gsh *GitHubSponsorsWebhook) ServeHTTP(w http.ResponseWriter, r *http.Reque
5784
"sponsorable", event.Sponsorship.Sponsorable.Login,
5885
"tier", event.Sponsorship.Tier.Name,
5986
"monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
87+
"sender", event.Sender.Login,
6088
)
6189

6290
// Handle different sponsorship event types
@@ -73,19 +101,22 @@ func (gsh *GitHubSponsorsWebhook) ServeHTTP(w http.ResponseWriter, r *http.Reque
73101
gsh.handlePendingCancellation(event)
74102
default:
75103
slog.Info("unhandled GitHub Sponsors event type", "action", event.Action)
76-
sponsorsErrorCount.Add("unhandled_action", 1)
104+
sponsorsErrorCount.WithLabelValues("unhandled_action").Inc()
77105
}
78106

79107
// Respond with success
108+
w.Header().Set("Content-Type", "text/plain")
80109
fmt.Fprintln(w, "OK")
81110
}
82111

83112
// handleSponsorshipCreated processes new sponsorships
84113
func (gsh *GitHubSponsorsWebhook) handleSponsorshipCreated(event github.SponsorsEvent) {
85114
slog.Info("New sponsorship created",
86115
"sponsor", event.Sponsorship.Sponsor.Login,
116+
"sponsor_id", event.Sponsorship.Sponsor.ID,
87117
"tier", event.Sponsorship.Tier.Name,
88118
"monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
119+
"privacy_level", event.Sponsorship.PrivacyLevel,
89120
)
90121
// TODO: Add sponsorship creation logic here
91122
// This could include updating database records, sending notifications, etc.
@@ -95,8 +126,10 @@ func (gsh *GitHubSponsorsWebhook) handleSponsorshipCreated(event github.Sponsors
95126
func (gsh *GitHubSponsorsWebhook) handleSponsorshipEdited(event github.SponsorsEvent) {
96127
slog.Info("Sponsorship edited",
97128
"sponsor", event.Sponsorship.Sponsor.Login,
129+
"sponsor_id", event.Sponsorship.Sponsor.ID,
98130
"tier", event.Sponsorship.Tier.Name,
99131
"monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
132+
"privacy_level", event.Sponsorship.PrivacyLevel,
100133
)
101134
// TODO: Add sponsorship edit logic here
102135
// This could include updating records, changing access levels, etc.
@@ -106,6 +139,7 @@ func (gsh *GitHubSponsorsWebhook) handleSponsorshipEdited(event github.SponsorsE
106139
func (gsh *GitHubSponsorsWebhook) handleSponsorshipCancelled(event github.SponsorsEvent) {
107140
slog.Info("Sponsorship cancelled",
108141
"sponsor", event.Sponsorship.Sponsor.Login,
142+
"sponsor_id", event.Sponsorship.Sponsor.ID,
109143
"tier", event.Sponsorship.Tier.Name,
110144
)
111145
// TODO: Add sponsorship cancellation logic here
@@ -116,6 +150,7 @@ func (gsh *GitHubSponsorsWebhook) handleSponsorshipCancelled(event github.Sponso
116150
func (gsh *GitHubSponsorsWebhook) handlePendingTierChange(event github.SponsorsEvent) {
117151
slog.Info("Pending sponsorship tier change",
118152
"sponsor", event.Sponsorship.Sponsor.Login,
153+
"sponsor_id", event.Sponsorship.Sponsor.ID,
119154
"tier", event.Sponsorship.Tier.Name,
120155
"monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
121156
)
@@ -126,7 +161,8 @@ func (gsh *GitHubSponsorsWebhook) handlePendingTierChange(event github.SponsorsE
126161
func (gsh *GitHubSponsorsWebhook) handlePendingCancellation(event github.SponsorsEvent) {
127162
slog.Info("Pending sponsorship cancellation",
128163
"sponsor", event.Sponsorship.Sponsor.Login,
164+
"sponsor_id", event.Sponsorship.Sponsor.ID,
129165
"tier", event.Sponsorship.Tier.Name,
130166
)
131167
// TODO: Add pending cancellation logic here
132-
}
168+
}

cmd/xesite/main.go

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,17 @@ import (
2121
)
2222

2323
var (
24-
bind = flag.String("bind", ":3000", "Port to listen on")
25-
devel = flag.Bool("devel", false, "Enable development mode")
26-
dataDir = flag.String("data-dir", "./var", "Directory to store data in")
27-
futureSightURL = flag.String("future-sight-url", "", "URL to use for future sight preview deploys")
28-
gitBranch = flag.String("git-branch", "main", "Git branch to clone")
29-
gitRepo = flag.String("git-repo", "https://github.com/Xe/site", "Git repository to clone")
30-
githubSecret = flag.String("github-secret", "", "GitHub secret to use for webhooks")
31-
githubSponsorsSecret = flag.String("github-sponsors-secret", "", "GitHub Sponsors secret to use for webhooks")
32-
internalAPIBind = flag.String("internal-api-bind", ":3001", "Port to listen on for the internal API")
33-
miURL = flag.String("mimi-announce-url", "", "Mi url (named mimi-announce-url for historical reasons)")
34-
patreonSaasProxyURL = flag.String("patreon-saasproxy-url", "http://xesite-patreon-saasproxy.flycast", "URL to use for the patreon saasproxy")
35-
siteURL = flag.String("site-url", "https://xeiaso.net/", "URL to use for the site")
24+
bind = flag.String("bind", ":3000", "Port to listen on")
25+
devel = flag.Bool("devel", false, "Enable development mode")
26+
dataDir = flag.String("data-dir", "./var", "Directory to store data in")
27+
futureSightURL = flag.String("future-sight-url", "", "URL to use for future sight preview deploys")
28+
gitBranch = flag.String("git-branch", "main", "Git branch to clone")
29+
gitRepo = flag.String("git-repo", "https://github.com/Xe/site", "Git repository to clone")
30+
githubSecret = flag.String("github-secret", "", "GitHub secret to use for webhooks")
31+
internalAPIBind = flag.String("internal-api-bind", ":3001", "Port to listen on for the internal API")
32+
miURL = flag.String("mimi-announce-url", "", "Mi url (named mimi-announce-url for historical reasons)")
33+
patreonSaasProxyURL = flag.String("patreon-saasproxy-url", "http://xesite-patreon-saasproxy.flycast", "URL to use for the patreon saasproxy")
34+
siteURL = flag.String("site-url", "https://xeiaso.net/", "URL to use for the site")
3635
)
3736

3837
func main() {
@@ -84,7 +83,6 @@ func main() {
8483

8584
mux := http.NewServeMux()
8685
mux.Handle("/", http.FileServerFS(fs))
87-
//mux.Handle("/", http.FileServer(http.FS(fs)))
8886
mux.Handle("/api/defs/", http.StripPrefix("/api/defs/", http.FileServer(http.FS(pb.Proto))))
8987

9088
ms := pb.NewMetaServer(&MetaServer{fs}, twirp.WithServerPathPrefix("/api"))
@@ -114,11 +112,6 @@ func main() {
114112
gh := &GitHubWebhook{fs: fs}
115113
s := hmacsig.Handler256(gh, *githubSecret)
116114
mux.Handle("/.within/hook/github", s)
117-
118-
gsh := &GitHubSponsorsWebhook{fs: fs}
119-
ss := hmacsig.Handler256(gsh, *githubSponsorsSecret)
120-
mux.Handle("/.within/hook/github-sponsors", ss)
121-
122115
mux.Handle("/.within/hook/patreon", &PatreonWebhook{fs: fs})
123116

124117
mux.HandleFunc("/static/talks/irc-why-it-failed.pdf", func(w http.ResponseWriter, r *http.Request) {

docker-bake.hcl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ variable "TYPST_VERSION" { default = "0.13.1" }
1212
variable "UBUNTU_VERSION" { default = "24.04" }
1313

1414
group "default" {
15-
targets = [ "patreon-saasproxy", "xesite" ]
15+
targets = [ "patreon-saasproxy", "xesite", "github-sponsor-webhook" ]
1616
}
1717

1818
target "patreon-saasproxy" {
@@ -50,4 +50,18 @@ target "xesite" {
5050
tags = [
5151
"registry.int.xeserv.us/xe/site/bin:main"
5252
]
53+
}
54+
55+
target "github-sponsor-webhook" {
56+
args = {
57+
ALPINE_VERSION = null
58+
GO_VERSION = null
59+
}
60+
context = "."
61+
dockerfile = "./docker/github-sponsor-webhook.Dockerfile"
62+
platforms = [ "linux/amd64", "linux/arm64" ]
63+
pull = true
64+
tags = [
65+
"registry.int.xeserv.us/xe/site/github-sponsor-webhook:main"
66+
]
5367
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
ARG GO_VERSION=1.25
2+
ARG ALPINE_VERSION=edge
3+
4+
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine AS build
5+
6+
ARG TARGETOS
7+
ARG TARGETARCH
8+
9+
WORKDIR /app
10+
11+
COPY go.mod go.sum ./
12+
RUN go mod download
13+
14+
COPY . .
15+
RUN --mount=type=cache,target=/root/.cache GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /app/bin/github-sponsor-webhook ./cmd/github-sponsor-webhook
16+
17+
FROM alpine:${ALPINE_VERSION} AS run
18+
WORKDIR /app
19+
20+
RUN apk add --no-cache ca-certificates
21+
22+
COPY --from=build /app/bin/github-sponsor-webhook /app/bin/github-sponsor-webhook
23+
24+
EXPOSE 8080
25+
26+
CMD ["/app/bin/github-sponsor-webhook"]
27+
28+
LABEL org.opencontainers.image.source="https://github.com/Xe/site"
29+
LABEL org.opencontainers.image.title="GitHub Sponsors Webhook Service"
30+
LABEL org.opencontainers.image.description="Standalone webhook service for processing GitHub Sponsors events"

0 commit comments

Comments
 (0)