diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..d2db36e6
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+# Test environment variables for xesitectl
+GITHUB_SPONSORS_SECRET=test-secret-from-env
+WEBHOOK_URL=http://localhost:3000/.within/hook/github-sponsors
+TEST_SPONSOR=envtestsponsor
+TEST_TIER=Env Tier
+TEST_PRICE=25
\ No newline at end of file
diff --git a/.github/workflows/earthly.yml b/.github/workflows/earthly.yml
index f40b2831..4559240b 100644
--- a/.github/workflows/earthly.yml
+++ b/.github/workflows/earthly.yml
@@ -2,11 +2,11 @@ name: Docker
on:
push:
- branches: [ "main" ]
+ branches: ["main"]
# Publish semver tags as releases.
- tags: [ 'v*.*.*' ]
+ tags: ["v*.*.*"]
pull_request:
- branches: [ "main" ]
+ branches: ["main"]
jobs:
build:
@@ -21,7 +21,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Log into registry
+ - name: Log into registry
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
@@ -37,6 +37,7 @@ jobs:
source: .
push: true
set: |
+ github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest
patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest
xesite.tags=ghcr.io/xe/site/bin:latest
@@ -47,7 +48,6 @@ jobs:
source: .
push: false
set: |
+ github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest
patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest
xesite.tags=ghcr.io/xe/site/bin:latest
-
-
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index de25341b..90c9cdcb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,6 @@ cw.tar
.patreon.json
.direnv
node_modules
+/xesite
+/xesitectl
+/github-sponsor-webhook
diff --git a/AGENTS.md b/AGENTS.md
index 5cb1d931..964fb564 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,6 +18,7 @@
## Coding Style & Naming Conventions
- Go code follows `gofmt`; run `go fmt ./...` before committing.
+- Use `goimports` to organize imports: `go get -tool golang.org/x/tools/cmd/goimports@latest` then `find . -name "*.go" -exec goimports -w {} \;`.
- Use `camelCase` for variables/functions, `PascalCase` for exported types.
- Indentation: tabs (default `go fmt`).
- Dhall files use kebab‑case filenames (e.g., `my-config.dhall`).
diff --git a/cmd/github-sponsor-webhook/main.go b/cmd/github-sponsor-webhook/main.go
new file mode 100644
index 00000000..f3d8736e
--- /dev/null
+++ b/cmd/github-sponsor-webhook/main.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+
+ "github.com/donatj/hmacsig"
+ "github.com/facebookgo/flagenv"
+ _ "github.com/joho/godotenv/autoload"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "xeiaso.net/v4/internal"
+ "xeiaso.net/v4/internal/models"
+)
+
+var (
+ bind = flag.String("bind", ":4823", "Port to listen on")
+ databaseURL = flag.String("database-url", "", "Database URL")
+ githubSponsorsSecret = flag.String("github-sponsors-secret", "", "GitHub Sponsors secret to use for webhooks")
+)
+
+func main() {
+ flagenv.Parse()
+ flag.Parse()
+ internal.Slog()
+
+ _ = context.Background()
+
+ ln, err := net.Listen("tcp", *bind)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if *databaseURL == "" {
+ slog.Error("database-url is required")
+ os.Exit(1)
+ }
+
+ if *githubSponsorsSecret == "" {
+ slog.Error("github-sponsors-secret is required")
+ os.Exit(1)
+ }
+
+ slog.Info("starting GitHub Sponsors webhook service",
+ "bind", *bind,
+ )
+
+ db, err := gorm.Open(postgres.Open(*databaseURL), &gorm.Config{})
+ if err != nil {
+ slog.Error("can't connect to database", "err", err)
+ os.Exit(1)
+ }
+
+ if err := db.Exec("SELECT 1 + 1").Error; err != nil {
+ slog.Error("can't ping database", "err", err)
+ os.Exit(1)
+ }
+
+ if err := models.SetupDatabase(db); err != nil {
+ slog.Error("database setup error", "err", err)
+ os.Exit(1)
+ }
+
+ gsh := &GitHubSponsorsWebhook{DB: db}
+ s := hmacsig.Handler256(gsh, *githubSponsorsSecret)
+
+ mux := http.NewServeMux()
+ mux.Handle("/.within/hook/github-sponsors", s)
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"status":"ok","service":"github-sponsor-webhook"}`))
+ })
+
+ // Expose Prometheus metrics at /metrics for observability
+ mux.Handle("/metrics", promhttp.Handler())
+
+ var h http.Handler = mux
+ h = internal.CacheHeader(h)
+ h = internal.AcceptEncodingMiddleware(h)
+ h = internal.RefererMiddleware(h)
+
+ slog.Info("GitHub Sponsors webhook service ready", "bind", *bind)
+ log.Fatal(http.Serve(ln, h))
+}
diff --git a/cmd/github-sponsor-webhook/webhook.go b/cmd/github-sponsor-webhook/webhook.go
new file mode 100644
index 00000000..977086fd
--- /dev/null
+++ b/cmd/github-sponsor-webhook/webhook.go
@@ -0,0 +1,449 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "gorm.io/gorm"
+ "xeiaso.net/v4/internal/github"
+ "xeiaso.net/v4/internal/models"
+)
+
+var (
+ sponsorsWebhookCount = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "github_sponsors_webhook_total",
+ Help: "Total number of GitHub Sponsors webhook events processed, by action",
+ },
+ []string{"action"},
+ )
+
+ sponsorsErrorCount = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "github_sponsors_webhook_errors_total",
+ Help: "Total number of GitHub Sponsors webhook errors, by error type",
+ },
+ []string{"error_type"},
+ )
+)
+
+func init() {
+ prometheus.MustRegister(sponsorsWebhookCount)
+ prometheus.MustRegister(sponsorsErrorCount)
+}
+
+// GitHubSponsorsWebhook handles GitHub Sponsors webhook events.
+type GitHubSponsorsWebhook struct {
+ DB *gorm.DB // Database connection for persisting webhook data
+}
+
+// ServeHTTP processes incoming GitHub Sponsors webhook events.
+func (gsh *GitHubSponsorsWebhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Set CORS headers for web requests
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-GitHub-Event, X-Hub-Signature-256")
+
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ if r.Method != "POST" {
+ slog.Info("method not allowed", "method", r.Method)
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Check for GitHub Sponsors event header
+ eventType := r.Header.Get("X-GitHub-Event")
+ if eventType != "sponsorship" {
+ slog.Info("not a sponsorship event", "event", eventType)
+ sponsorsErrorCount.WithLabelValues("invalid_event_type").Inc()
+ http.Error(w, "Invalid event type", http.StatusBadRequest)
+ return
+ }
+
+ // Decode the webhook payload
+ var event github.SponsorsEvent
+ if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
+ slog.Error("error decoding GitHub Sponsors event", "error", err)
+ sponsorsErrorCount.WithLabelValues("decode_error").Inc()
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // Create webhook event record for tracking
+ webhookEvent := &models.WebhookEvent{
+ GitHubID: r.Header.Get("X-GitHub-Delivery"),
+ Action: event.Action,
+ EventType: eventType,
+ ProcessedAt: time.Now(),
+ RemoteAddr: r.RemoteAddr,
+ UserAgent: r.UserAgent(),
+ Timestamp: time.Now(),
+ }
+
+ // Store the raw payload for debugging
+ eventBytes, _ := json.Marshal(event)
+ webhookEvent.SetPayload(map[string]interface{}{
+ "raw_payload": string(eventBytes),
+ "action": event.Action,
+ })
+
+ // Increment the specific action counter
+ sponsorsWebhookCount.WithLabelValues(event.Action).Inc()
+
+ // Log the sponsorship event
+ slog.Info("GitHub Sponsors webhook received",
+ "action", event.Action,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "sponsorable", event.Sponsorship.Sponsorable.Login,
+ "tier", event.Sponsorship.Tier.Name,
+ "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
+ "sender", event.Sender.Login,
+ )
+
+ // Handle different sponsorship event types with database operations
+ var err error
+ switch event.Action {
+ case github.SponsorsEventCreated:
+ err = gsh.handleSponsorshipCreated(event, webhookEvent)
+ case github.SponsorsEventEdited:
+ err = gsh.handleSponsorshipEdited(event, webhookEvent)
+ case github.SponsorsEventCancelled:
+ err = gsh.handleSponsorshipCancelled(event, webhookEvent)
+ case github.SponsorsEventPendingTierChange:
+ err = gsh.handlePendingTierChange(event, webhookEvent)
+ case github.SponsorsEventPendingCancellation:
+ err = gsh.handlePendingCancellation(event, webhookEvent)
+ default:
+ err = fmt.Errorf("unhandled GitHub Sponsors event type: %s", event.Action)
+ sponsorsErrorCount.WithLabelValues("unhandled_action").Inc()
+ }
+
+ // Record webhook event processing result
+ if err != nil {
+ slog.Error("error processing webhook", "error", err, "action", event.Action)
+ webhookEvent.Success = false
+ webhookEvent.ErrorMessage = err.Error()
+ sponsorsErrorCount.WithLabelValues("processing_error").Inc()
+ } else {
+ webhookEvent.Success = true
+ }
+
+ // Save webhook event to database
+ if dbErr := gsh.DB.Create(webhookEvent).Error; dbErr != nil {
+ slog.Error("failed to save webhook event", "error", dbErr)
+ // Don't fail the request if we can't save the webhook event
+ }
+
+ // Respond with success
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprintln(w, "OK")
+}
+
+// handleSponsorshipCreated processes new sponsorships
+func (gsh *GitHubSponsorsWebhook) handleSponsorshipCreated(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error {
+ slog.Info("New sponsorship created",
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "sponsor_id", event.Sponsorship.Sponsor.ID,
+ "tier", event.Sponsorship.Tier.Name,
+ "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
+ "privacy_level", event.Sponsorship.PrivacyLevel,
+ )
+
+ // Create or find the sponsor account
+ sponsor := models.Account{
+ GitHubID: int64(event.Sponsorship.Sponsor.ID),
+ NodeID: event.Sponsorship.Sponsor.NodeID,
+ Login: event.Sponsorship.Sponsor.Login,
+ AvatarURL: event.Sponsorship.Sponsor.AvatarURL,
+ URL: event.Sponsorship.Sponsor.HTMLURL,
+ Type: event.Sponsorship.Sponsor.Type,
+ }
+
+ if err := gsh.DB.Where("github_id = ?", sponsor.GitHubID).FirstOrCreate(&sponsor).Error; err != nil {
+ return fmt.Errorf("failed to create/find sponsor: %w", err)
+ }
+
+ // Create or find the sponsoree account
+ sponsoree := models.Account{
+ GitHubID: int64(event.Sponsorship.Sponsorable.ID),
+ NodeID: event.Sponsorship.Sponsorable.NodeID,
+ Login: event.Sponsorship.Sponsorable.Login,
+ AvatarURL: event.Sponsorship.Sponsorable.AvatarURL,
+ URL: event.Sponsorship.Sponsorable.HTMLURL,
+ Type: event.Sponsorship.Sponsorable.Type,
+ }
+
+ if err := gsh.DB.Where("github_id = ?", sponsoree.GitHubID).FirstOrCreate(&sponsoree).Error; err != nil {
+ return fmt.Errorf("failed to create/find sponsoree: %w", err)
+ }
+
+ // Create or find the tier
+ tier := models.Tier{
+ GitHubID: int64(event.Sponsorship.Tier.ID),
+ Name: event.Sponsorship.Tier.Name,
+ MonthlyPriceInCents: event.Sponsorship.Tier.MonthlyPriceInDollars * 100, // Convert to cents
+ Description: event.Sponsorship.Tier.Description,
+ SelectedTier: event.Sponsorship.Tier.SelectedTier,
+ SponsorshipCount: event.Sponsorship.Tier.SponsorshipCount,
+ }
+
+ if err := gsh.DB.Where("github_id = ?", tier.GitHubID).FirstOrCreate(&tier).Error; err != nil {
+ return fmt.Errorf("failed to create/find tier: %w", err)
+ }
+
+ // Convert GitHub timestamps
+ var createdAt *time.Time
+ if event.Sponsorship.CreatedAt.Time.Year() > 1 {
+ createdAt = &event.Sponsorship.CreatedAt.Time
+ }
+
+ // Create the sponsorship
+ sponsorship := models.Sponsorship{
+ GitHubID: int64(event.Sponsorship.ID),
+ PrivacyLevel: event.Sponsorship.PrivacyLevel,
+ Variant: event.Sponsorship.Variant,
+ SponsorshipType: event.Sponsorship.SponsorshipType,
+ TierID: tier.ID,
+ SponsorID: sponsor.ID,
+ SponsoreeID: sponsoree.ID,
+ GitHubCreatedAt: createdAt,
+ }
+
+ // Set metadata
+ sponsorship.SetMetadata(map[string]interface{}{
+ "github_id": event.Sponsorship.ID,
+ "node_id": event.Sponsorship.NodeID,
+ })
+
+ if err := gsh.DB.Create(&sponsorship).Error; err != nil {
+ return fmt.Errorf("failed to create sponsorship: %w", err)
+ }
+
+ // Update webhook event with sponsorship ID
+ webhookEvent.SponsorshipID = &sponsorship.ID
+
+ slog.Info("Successfully created sponsorship in database",
+ "sponsorship_id", sponsorship.ID,
+ "sponsor", sponsor.Login,
+ "sponsoree", sponsoree.Login,
+ "tier", tier.Name,
+ )
+
+ return nil
+}
+
+// handleSponsorshipEdited processes sponsorship changes
+func (gsh *GitHubSponsorsWebhook) handleSponsorshipEdited(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error {
+ slog.Info("Sponsorship edited",
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "sponsor_id", event.Sponsorship.Sponsor.ID,
+ "tier", event.Sponsorship.Tier.Name,
+ "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
+ "privacy_level", event.Sponsorship.PrivacyLevel,
+ )
+
+ // Find existing sponsorship
+ var sponsorship models.Sponsorship
+ if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ // If sponsorship doesn't exist, treat it as a new sponsorship
+ return gsh.handleSponsorshipCreated(event, webhookEvent)
+ }
+ return fmt.Errorf("failed to find sponsorship: %w", err)
+ }
+
+ // Update tier if changed
+ if event.Sponsorship.Tier.ID != 0 {
+ var tier models.Tier
+ if err := gsh.DB.Where("github_id = ?", event.Sponsorship.Tier.ID).First(&tier).Error; err != nil {
+ return fmt.Errorf("failed to find tier: %w", err)
+ }
+ sponsorship.TierID = tier.ID
+ }
+
+ // Update other fields
+ sponsorship.PrivacyLevel = event.Sponsorship.PrivacyLevel
+ sponsorship.Variant = event.Sponsorship.Variant
+ sponsorship.SponsorshipType = event.Sponsorship.SponsorshipType
+
+ // Update GitHub timestamps
+ if event.Sponsorship.CreatedAt.Time.Year() > 1 {
+ sponsorship.GitHubCreatedAt = &event.Sponsorship.CreatedAt.Time
+ }
+ // Note: UpdatedAt is not available in the GitHub Sponsors webhook payload
+ now := time.Now()
+ sponsorship.GitHubUpdatedAt = &now
+
+ // Update metadata
+ sponsorship.SetMetadata(map[string]interface{}{
+ "github_id": event.Sponsorship.ID,
+ "node_id": event.Sponsorship.NodeID,
+ "edited_at": time.Now(),
+ })
+
+ if err := gsh.DB.Save(&sponsorship).Error; err != nil {
+ return fmt.Errorf("failed to update sponsorship: %w", err)
+ }
+
+ // Update webhook event with sponsorship ID
+ webhookEvent.SponsorshipID = &sponsorship.ID
+
+ slog.Info("Successfully updated sponsorship in database",
+ "sponsorship_id", sponsorship.ID,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ )
+
+ return nil
+}
+
+// handleSponsorshipCancelled processes cancelled sponsorships
+func (gsh *GitHubSponsorsWebhook) handleSponsorshipCancelled(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error {
+ slog.Info("Sponsorship cancelled",
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "sponsor_id", event.Sponsorship.Sponsor.ID,
+ "tier", event.Sponsorship.Tier.Name,
+ )
+
+ // Find existing sponsorship
+ var sponsorship models.Sponsorship
+ if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ slog.Info("Sponsorship not found for cancellation, possibly already cancelled",
+ "github_id", event.Sponsorship.ID,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ )
+ return nil // Don't treat as error, just log and continue
+ }
+ return fmt.Errorf("failed to find sponsorship: %w", err)
+ }
+
+ // Mark as cancelled (soft delete)
+ now := time.Now()
+ sponsorship.GitHubCancelledAt = &now
+
+ // Update metadata to reflect cancellation
+ metadata := sponsorship.Metadata()
+ metadata["cancelled_at"] = now
+ metadata["cancellation_reason"] = "github_sponsors_webhook"
+ sponsorship.SetMetadata(metadata)
+
+ if err := gsh.DB.Save(&sponsorship).Error; err != nil {
+ return fmt.Errorf("failed to cancel sponsorship: %w", err)
+ }
+
+ // Update webhook event with sponsorship ID
+ webhookEvent.SponsorshipID = &sponsorship.ID
+
+ slog.Info("Successfully cancelled sponsorship in database",
+ "sponsorship_id", sponsorship.ID,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "cancelled_at", now,
+ )
+
+ return nil
+}
+
+// handlePendingTierChange processes pending tier changes
+func (gsh *GitHubSponsorsWebhook) handlePendingTierChange(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error {
+ slog.Info("Pending sponsorship tier change",
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "sponsor_id", event.Sponsorship.Sponsor.ID,
+ "tier", event.Sponsorship.Tier.Name,
+ "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars,
+ )
+
+ // Find existing sponsorship
+ var sponsorship models.Sponsorship
+ if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ // If sponsorship doesn't exist, treat it as a new sponsorship
+ return gsh.handleSponsorshipCreated(event, webhookEvent)
+ }
+ return fmt.Errorf("failed to find sponsorship: %w", err)
+ }
+
+ // Update tier if changed
+ if event.Sponsorship.Tier.ID != 0 {
+ var tier models.Tier
+ if err := gsh.DB.Where("github_id = ?", event.Sponsorship.Tier.ID).First(&tier).Error; err != nil {
+ return fmt.Errorf("failed to find new tier: %w", err)
+ }
+ sponsorship.TierID = tier.ID
+ }
+
+ // Update metadata to reflect pending tier change
+ metadata := sponsorship.Metadata()
+ metadata["pending_tier_change"] = map[string]interface{}{
+ "new_tier_id": event.Sponsorship.Tier.ID,
+ "new_tier_name": event.Sponsorship.Tier.Name,
+ "notified_at": time.Now(),
+ }
+ sponsorship.SetMetadata(metadata)
+
+ if err := gsh.DB.Save(&sponsorship).Error; err != nil {
+ return fmt.Errorf("failed to update sponsorship for pending tier change: %w", err)
+ }
+
+ // Update webhook event with sponsorship ID
+ webhookEvent.SponsorshipID = &sponsorship.ID
+
+ slog.Info("Successfully recorded pending tier change in database",
+ "sponsorship_id", sponsorship.ID,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "new_tier", event.Sponsorship.Tier.Name,
+ )
+
+ return nil
+}
+
+// handlePendingCancellation processes pending cancellations
+func (gsh *GitHubSponsorsWebhook) handlePendingCancellation(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error {
+ slog.Info("Pending sponsorship cancellation",
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "sponsor_id", event.Sponsorship.Sponsor.ID,
+ "tier", event.Sponsorship.Tier.Name,
+ )
+
+ // Find existing sponsorship
+ var sponsorship models.Sponsorship
+ if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ slog.Info("Sponsorship not found for pending cancellation",
+ "github_id", event.Sponsorship.ID,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ )
+ return nil // Don't treat as error, just log and continue
+ }
+ return fmt.Errorf("failed to find sponsorship: %w", err)
+ }
+
+ // Update metadata to reflect pending cancellation
+ metadata := sponsorship.Metadata()
+ metadata["pending_cancellation"] = map[string]interface{}{
+ "notified_at": time.Now(),
+ "tier": event.Sponsorship.Tier.Name,
+ }
+ sponsorship.SetMetadata(metadata)
+
+ if err := gsh.DB.Save(&sponsorship).Error; err != nil {
+ return fmt.Errorf("failed to update sponsorship for pending cancellation: %w", err)
+ }
+
+ // Update webhook event with sponsorship ID
+ webhookEvent.SponsorshipID = &sponsorship.ID
+
+ slog.Info("Successfully recorded pending cancellation in database",
+ "sponsorship_id", sponsorship.ID,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ )
+
+ return nil
+}
\ No newline at end of file
diff --git a/cmd/xesite/main.go b/cmd/xesite/main.go
index bef58e78..21ffb32f 100644
--- a/cmd/xesite/main.go
+++ b/cmd/xesite/main.go
@@ -83,7 +83,6 @@ func main() {
mux := http.NewServeMux()
mux.Handle("/", http.FileServerFS(fs))
- //mux.Handle("/", http.FileServer(http.FS(fs)))
mux.Handle("/api/defs/", http.StripPrefix("/api/defs/", http.FileServer(http.FS(pb.Proto))))
ms := pb.NewMetaServer(&MetaServer{fs}, twirp.WithServerPathPrefix("/api"))
@@ -113,7 +112,6 @@ func main() {
gh := &GitHubWebhook{fs: fs}
s := hmacsig.Handler256(gh, *githubSecret)
mux.Handle("/.within/hook/github", s)
-
mux.Handle("/.within/hook/patreon", &PatreonWebhook{fs: fs})
mux.HandleFunc("/static/talks/irc-why-it-failed.pdf", func(w http.ResponseWriter, r *http.Request) {
diff --git a/cmd/xesitectl/commands/test-webhook.go b/cmd/xesitectl/commands/test-webhook.go
new file mode 100644
index 00000000..efb02ff9
--- /dev/null
+++ b/cmd/xesitectl/commands/test-webhook.go
@@ -0,0 +1,281 @@
+package commands
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/google/subcommands"
+ "xeiaso.net/v4/internal/github"
+)
+
+const (
+ defaultWebhookURL = "http://localhost:4823/.within/hook/github-sponsors"
+ defaultSecret = "test-secret"
+)
+
+// TestWebhookCmd implements the test webhook command using subcommands
+type TestWebhookCmd struct {
+ action string
+ sponsor string
+ sponsorable string
+ tier string
+ price int
+ webhookURL string
+ secret string
+ verbose bool
+}
+
+// Name returns the command name
+func (*TestWebhookCmd) Name() string { return "test-webhook" }
+
+// Synopsis returns a short command description
+func (*TestWebhookCmd) Synopsis() string { return "Test GitHub Sponsors webhook handler" }
+
+// Usage returns detailed command usage
+func (*TestWebhookCmd) Usage() string {
+ return `test-webhook [-action] [-sponsor] [-sponsorable] [-tier] [-price] [-url] [-secret] [-verbose]
+
+Test the GitHub Sponsors webhook handler by sending mock sponsorship events.
+
+Examples:
+ xesitectl test-webhook -action created -sponsor testsponsor -tier "Pro Tier" -price 10
+ xesitectl test-webhook -action cancelled -sponsor oldsponsor -url https://myapp.com/hook
+ xesitectl test-webhook -action edited -sponsor testsponsor -price 20 -verbose
+
+Flags:
+ -action Event action (created, edited, cancelled, pending_tier_change, pending_cancellation)
+ -sponsor Sponsor login name (default: "testsponsor")
+ -sponsorable Sponsorable login name (default: "xeiaso")
+ -tier Tier name (default: "Test Tier")
+ -price Monthly price in dollars (default: 10)
+ -url Webhook URL (default: http://localhost:3000/.within/hook/github-sponsors)
+ -secret Webhook secret for HMAC signature (default: "test-secret")
+ -verbose Enable verbose logging
+`
+}
+
+// SetFlags defines the command-line flags
+func (cmd *TestWebhookCmd) SetFlags(f *flag.FlagSet) {
+ // Get defaults from environment variables if available
+ defaultSecret := os.Getenv("GITHUB_SPONSORS_SECRET")
+ if defaultSecret == "" {
+ defaultSecret = "test-secret"
+ }
+
+ defaultURL := os.Getenv("WEBHOOK_URL")
+ if defaultURL == "" {
+ defaultURL = defaultWebhookURL
+ }
+
+ defaultSponsor := os.Getenv("TEST_SPONSOR")
+ if defaultSponsor == "" {
+ defaultSponsor = "testsponsor"
+ }
+
+ defaultTier := os.Getenv("TEST_TIER")
+ if defaultTier == "" {
+ defaultTier = "Test Tier"
+ }
+
+ defaultPrice := 10
+ if priceStr := os.Getenv("TEST_PRICE"); priceStr != "" {
+ if price, err := strconv.Atoi(priceStr); err == nil {
+ defaultPrice = price
+ }
+ }
+
+ f.StringVar(&cmd.action, "action", "created", "Sponsorship event action")
+ f.StringVar(&cmd.sponsor, "sponsor", defaultSponsor, "Sponsor login name")
+ f.StringVar(&cmd.sponsorable, "sponsorable", "xeiaso", "Sponsorable login name")
+ f.StringVar(&cmd.tier, "tier", defaultTier, "Tier name")
+ f.IntVar(&cmd.price, "price", defaultPrice, "Monthly price in dollars")
+ f.StringVar(&cmd.webhookURL, "url", defaultURL, "Webhook URL")
+ f.StringVar(&cmd.secret, "secret", defaultSecret, "Webhook secret")
+ f.BoolVar(&cmd.verbose, "verbose", false, "Enable verbose logging")
+}
+
+// Execute runs the command
+func (cmd *TestWebhookCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+ if f.NArg() != 0 {
+ fmt.Fprintf(os.Stderr, "Unexpected arguments: %v\n", f.Args())
+ return subcommands.ExitUsageError
+ }
+
+ // Configure logging
+ logLevel := slog.LevelInfo
+ if cmd.verbose {
+ logLevel = slog.LevelDebug
+ }
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ Level: logLevel,
+ })))
+
+ // Validate action
+ validActions := map[string]bool{
+ github.SponsorsEventCreated: true,
+ github.SponsorsEventEdited: true,
+ github.SponsorsEventCancelled: true,
+ github.SponsorsEventPendingTierChange: true,
+ github.SponsorsEventPendingCancellation: true,
+ }
+
+ if !validActions[cmd.action] {
+ fmt.Fprintf(os.Stderr, "Invalid action: %s\nValid actions: created, edited, cancelled, pending_tier_change, pending_cancellation\n", cmd.action)
+ return subcommands.ExitUsageError
+ }
+
+ slog.Info("Testing GitHub Sponsors webhook",
+ "action", cmd.action,
+ "sponsor", cmd.sponsor,
+ "sponsorable", cmd.sponsorable,
+ "tier", cmd.tier,
+ "price", cmd.price,
+ "url", cmd.webhookURL,
+ )
+
+ // Create test sponsorship event
+ event := cmd.createTestEvent()
+
+ // Send webhook request
+ if err := cmd.sendWebhook(ctx, event); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to send webhook: %v\n", err)
+ return subcommands.ExitFailure
+ }
+
+ fmt.Printf("✅ Successfully sent %s webhook event for sponsor %s\n", cmd.action, cmd.sponsor)
+ return subcommands.ExitSuccess
+}
+
+// createTestEvent creates a mock GitHub Sponsors event
+func (cmd *TestWebhookCmd) createTestEvent() github.SponsorsEvent {
+ now := time.Now()
+
+ event := github.SponsorsEvent{
+ Action: cmd.action,
+ Sponsorship: github.Sponsorship{
+ ID: 12345,
+ NodeID: "SPONSORSHIP_12345",
+ CreatedAt: github.Time{Time: now},
+ Sponsor: github.Sponsor{
+ Login: cmd.sponsor,
+ ID: 67890,
+ NodeID: "USER_67890",
+ AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/67890?v=4"),
+ HTMLURL: fmt.Sprintf("https://github.com/%s", cmd.sponsor),
+ Type: "User",
+ },
+ Sponsorable: github.Sponsor{
+ Login: cmd.sponsorable,
+ ID: 54321,
+ NodeID: "USER_54321",
+ AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/54321?v=4"),
+ HTMLURL: fmt.Sprintf("https://github.com/%s", cmd.sponsorable),
+ Type: "User",
+ },
+ Tier: github.Tier{
+ ID: 98765,
+ NodeID: "TIER_98765",
+ CreatedAt: github.Time{Time: now},
+ Description: fmt.Sprintf("Test sponsorship tier for %s", cmd.tier),
+ MonthlyPriceInDollars: cmd.price,
+ IsOneTime: false,
+ IsCustomAmount: false,
+ Name: cmd.tier,
+ Published: true,
+ SelectedTier: true,
+ SponsorshipCount: 1,
+ },
+ PrivacyLevel: "public",
+ Variant: "recurring",
+ SponsorshipType: "user",
+ },
+ Sender: github.User{
+ Login: cmd.sponsor,
+ ID: 67890,
+ AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/67890?v=4"),
+ HTMLURL: fmt.Sprintf("https://github.com/%s", cmd.sponsor),
+ Type: "User",
+ },
+ }
+
+ return event
+}
+
+// sendWebhook sends the event as an HTTP request to the webhook endpoint
+func (cmd *TestWebhookCmd) sendWebhook(ctx context.Context, event github.SponsorsEvent) error {
+ // Marshal the event to JSON
+ eventJSON, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("failed to marshal event: %w", err)
+ }
+
+ if cmd.verbose {
+ slog.Debug("Webhook payload", "json", string(eventJSON))
+ }
+
+ // Create HTTP request with context
+ req, err := http.NewRequestWithContext(ctx, "POST", cmd.webhookURL, bytes.NewBuffer(eventJSON))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Set headers
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-GitHub-Event", "sponsorship")
+ req.Header.Set("X-GitHub-Delivery", fmt.Sprintf("%d", time.Now().UnixNano()))
+ req.Header.Set("User-Agent", "Xesitectl-Webhook-Test/1.0")
+
+ // Add HMAC signature
+ if cmd.secret != "" {
+ signature := cmd.calculateHMACSignature(eventJSON)
+ req.Header.Set("X-Hub-Signature-256", signature)
+ slog.Debug("Added HMAC signature", "signature", signature)
+ }
+
+ slog.Info("Sending webhook request",
+ "url", cmd.webhookURL,
+ "action", event.Action,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "content_length", len(eventJSON),
+ )
+
+ // Send request with timeout
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Check response
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("webhook returned status %d %s", resp.StatusCode, resp.Status)
+ }
+
+ slog.Info("Webhook test completed successfully",
+ "action", event.Action,
+ "sponsor", event.Sponsorship.Sponsor.Login,
+ "status", resp.StatusCode,
+ )
+
+ return nil
+}
+
+// calculateHMACSignature calculates the HMAC-SHA256 signature for the webhook payload
+func (cmd *TestWebhookCmd) calculateHMACSignature(payload []byte) string {
+ mac := hmac.New(sha256.New, []byte(cmd.secret))
+ mac.Write(payload)
+ signature := mac.Sum(nil)
+ return "sha256=" + hex.EncodeToString(signature)
+}
diff --git a/cmd/xesitectl/main.go b/cmd/xesitectl/main.go
new file mode 100644
index 00000000..8264fbb5
--- /dev/null
+++ b/cmd/xesitectl/main.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "os"
+
+ _ "github.com/joho/godotenv/autoload"
+ "github.com/google/subcommands"
+ "xeiaso.net/v4/cmd/xesitectl/commands"
+)
+
+func main() {
+ subcommands.Register(subcommands.HelpCommand(), "")
+ subcommands.Register(subcommands.FlagsCommand(), "")
+ subcommands.Register(subcommands.CommandsCommand(), "")
+ subcommands.Register(&commands.TestWebhookCmd{}, "")
+
+ flag.Parse()
+
+ ctx := context.Background()
+ os.Exit(int(subcommands.Execute(ctx)))
+}
diff --git a/docker-bake.hcl b/docker-bake.hcl
index 255bcb8d..8e957a4c 100644
--- a/docker-bake.hcl
+++ b/docker-bake.hcl
@@ -12,7 +12,7 @@ variable "TYPST_VERSION" { default = "0.13.1" }
variable "UBUNTU_VERSION" { default = "24.04" }
group "default" {
- targets = [ "patreon-saasproxy", "xesite" ]
+ targets = [ "patreon-saasproxy", "xesite", "github-sponsor-webhook" ]
}
target "patreon-saasproxy" {
@@ -50,4 +50,18 @@ target "xesite" {
tags = [
"registry.int.xeserv.us/xe/site/bin:main"
]
+}
+
+target "github-sponsor-webhook" {
+ args = {
+ ALPINE_VERSION = null
+ GO_VERSION = null
+ }
+ context = "."
+ dockerfile = "./docker/github-sponsor-webhook.Dockerfile"
+ platforms = [ "linux/amd64", "linux/arm64" ]
+ pull = true
+ tags = [
+ "registry.int.xeserv.us/xe/site/github-sponsor-webhook:main"
+ ]
}
\ No newline at end of file
diff --git a/docker/github-sponsor-webhook.Dockerfile b/docker/github-sponsor-webhook.Dockerfile
new file mode 100644
index 00000000..793ecddf
--- /dev/null
+++ b/docker/github-sponsor-webhook.Dockerfile
@@ -0,0 +1,30 @@
+ARG GO_VERSION=1.25
+ARG ALPINE_VERSION=edge
+
+FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine AS build
+
+ARG TARGETOS
+ARG TARGETARCH
+
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+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
+
+FROM alpine:${ALPINE_VERSION} AS run
+WORKDIR /app
+
+RUN apk add --no-cache ca-certificates
+
+COPY --from=build /app/bin/github-sponsor-webhook /app/bin/github-sponsor-webhook
+
+EXPOSE 8080
+
+CMD ["/app/bin/github-sponsor-webhook"]
+
+LABEL org.opencontainers.image.source="https://github.com/Xe/site"
+LABEL org.opencontainers.image.title="GitHub Sponsors Webhook Service"
+LABEL org.opencontainers.image.description="Standalone webhook service for processing GitHub Sponsors events"
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 79040765..adc39c4d 100644
--- a/go.mod
+++ b/go.mod
@@ -43,6 +43,8 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/aws/smithy-go v1.23.1 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@@ -60,8 +62,15 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
+ github.com/google/subcommands v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.6.0 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
@@ -72,6 +81,10 @@ require (
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_golang v1.23.2 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
@@ -80,16 +93,22 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
- golang.org/x/crypto v0.39.0 // indirect
- golang.org/x/net v0.41.0 // indirect
- golang.org/x/sys v0.33.0 // indirect
- golang.org/x/term v0.32.0 // indirect
- golang.org/x/text v0.29.0 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/mod v0.29.0 // indirect
+ golang.org/x/net v0.46.0 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
+ golang.org/x/term v0.36.0 // indirect
+ golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.11.0 // indirect
+ golang.org/x/tools v0.38.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ gorm.io/driver/postgres v1.6.0 // indirect
+ gorm.io/gorm v1.31.0 // indirect
k8s.io/api v0.34.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
@@ -99,3 +118,5 @@ require (
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+
+tool golang.org/x/tools/cmd/goimports
diff --git a/go.sum b/go.sum
index cf4b334d..8e543a20 100644
--- a/go.sum
+++ b/go.sum
@@ -45,8 +45,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/Xv
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
@@ -110,10 +114,24 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
+github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -152,6 +170,14 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -167,6 +193,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
@@ -189,8 +216,12 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -198,11 +229,15 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -214,14 +249,22 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
+golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -230,6 +273,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -252,8 +297,13 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
+gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
diff --git a/internal/adminpb/internal.pb.go b/internal/adminpb/internal.pb.go
index 202daba3..f9dea9a4 100644
--- a/internal/adminpb/internal.pb.go
+++ b/internal/adminpb/internal.pb.go
@@ -7,13 +7,14 @@
package adminpb
import (
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
- reflect "reflect"
- sync "sync"
- unsafe "unsafe"
pb "xeiaso.net/v4/pb"
)
diff --git a/internal/adminpb/internal.twirp.go b/internal/adminpb/internal.twirp.go
index 3ad32810..c6e5e8d2 100644
--- a/internal/adminpb/internal.twirp.go
+++ b/internal/adminpb/internal.twirp.go
@@ -3,26 +3,40 @@
package adminpb
-import context "context"
-import fmt "fmt"
-import http "net/http"
-import io "io"
-import json "encoding/json"
-import strconv "strconv"
-import strings "strings"
-
-import protojson "google.golang.org/protobuf/encoding/protojson"
-import proto "google.golang.org/protobuf/proto"
-import twirp "github.com/twitchtv/twirp"
-import ctxsetters "github.com/twitchtv/twirp/ctxsetters"
-
-import google_protobuf "google.golang.org/protobuf/types/known/emptypb"
-import xeiaso_net "xeiaso.net/v4/pb"
-
-import bytes "bytes"
-import errors "errors"
-import path "path"
-import url "net/url"
+import (
+ context "context"
+ fmt "fmt"
+
+ http "net/http"
+
+ io "io"
+
+ json "encoding/json"
+
+ strconv "strconv"
+
+ strings "strings"
+
+ protojson "google.golang.org/protobuf/encoding/protojson"
+
+ proto "google.golang.org/protobuf/proto"
+
+ twirp "github.com/twitchtv/twirp"
+
+ ctxsetters "github.com/twitchtv/twirp/ctxsetters"
+
+ google_protobuf "google.golang.org/protobuf/types/known/emptypb"
+
+ xeiaso_net "xeiaso.net/v4/pb"
+
+ bytes "bytes"
+
+ errors "errors"
+
+ path "path"
+
+ url "net/url"
+)
// Version compatibility assertion.
// If the constant is not defined in the package, that likely means
diff --git a/internal/github/sponsors.go b/internal/github/sponsors.go
new file mode 100644
index 00000000..162fc7cf
--- /dev/null
+++ b/internal/github/sponsors.go
@@ -0,0 +1,69 @@
+package github
+
+// Sponsor represents a GitHub sponsor.
+type Sponsor struct {
+ Login string `json:"login"`
+ ID int `json:"id"`
+ NodeID string `json:"node_id"`
+ AvatarURL string `json:"avatar_url"`
+ GravatarID string `json:"gravatar_id"`
+ URL string `json:"url"`
+ HTMLURL string `json:"html_url"`
+ FollowersURL string `json:"followers_url"`
+ FollowingURL string `json:"following_url"`
+ GistsURL string `json:"gists_url"`
+ StarredURL string `json:"starred_url"`
+ SubscriptionsURL string `json:"subscriptions_url"`
+ OrganizationsURL string `json:"organizations_url"`
+ ReposURL string `json:"repos_url"`
+ EventsURL string `json:"events_url"`
+ ReceivedEventsURL string `json:"received_events_url"`
+ Type string `json:"type"`
+ SiteAdmin bool `json:"site_admin"`
+}
+
+// Sponsorship represents a GitHub sponsorship.
+type Sponsorship struct {
+ ID int `json:"id"`
+ NodeID string `json:"node_id"`
+ CreatedAt Time `json:"created_at"`
+ Sponsor Sponsor `json:"sponsor"`
+ Sponsorable Sponsor `json:"sponsorable"`
+ Tier Tier `json:"tier"`
+ PrivacyLevel string `json:"privacy_level"`
+ Variant string `json:"variant"`
+ SponsorshipType string `json:"sponsorship_type"`
+}
+
+// Tier represents the sponsorship tier.
+type Tier struct {
+ ID int `json:"id"`
+ NodeID string `json:"node_id"`
+ CreatedAt Time `json:"created_at"`
+ Description string `json:"description"`
+ MonthlyPriceInDollars int `json:"monthly_price_in_dollars"`
+ IsOneTime bool `json:"is_one_time"`
+ IsCustomAmount bool `json:"is_custom_amount"`
+ Name string `json:"name"`
+ Published bool `json:"published"`
+ SelectedTier bool `json:"selected_tier"`
+ SponsorshipCount int `json:"sponsorship_count"`
+}
+
+// SponsorsEvent represents a GitHub Sponsors webhook event.
+type SponsorsEvent struct {
+ Action string `json:"action"`
+ Sponsorship Sponsorship `json:"sponsorship"`
+ Sender User `json:"sender"`
+ Repository Repository `json:"repository,omitempty"`
+ Organization User `json:"organization,omitempty"`
+}
+
+// GithubSponsorsWebhookEventTypes contains the possible GitHub Sponsors webhook event types.
+const (
+ SponsorsEventCreated = "created"
+ SponsorsEventEdited = "edited"
+ SponsorsEventCancelled = "cancelled"
+ SponsorsEventPendingTierChange = "pending_tier_change"
+ SponsorsEventPendingCancellation = "pending_cancellation"
+)
diff --git a/internal/models/README.md b/internal/models/README.md
new file mode 100644
index 00000000..859f4d50
--- /dev/null
+++ b/internal/models/README.md
@@ -0,0 +1,184 @@
+# Models Package
+
+This package contains GORM models for the GitHub Sponsors webhook processing system, based on the requirements defined in [SITE-10](https://linear.app/xeiaso/issue/SITE-10/github-sponsors-webhooks).
+
+## Models
+
+### Account
+Represents a GitHub account (user or organization) that can be either a sponsor or sponsoree.
+
+**Fields:**
+- `GitHubID` - GitHub's internal ID (unique)
+- `NodeID` - GitHub's global node ID
+- `Login` - GitHub username
+- `AvatarURL` - Profile avatar URL
+- `URL` - GitHub profile URL
+- `Type` - "User" or "Organization"
+- `SiteAdmin` - Whether the account is a site admin
+
+### Tier
+Represents a sponsorship tier with pricing and benefits.
+
+**Fields:**
+- `GitHubID` - GitHub's internal ID (unique)
+- `Name` - Tier name
+- `MonthlyPriceInCents` - Price in cents
+- `Description` - Tier description
+- `Benefits` - JSON array of tier benefits
+- `IsOneTime` - Whether this is a one-time payment
+- `IsCustomAmount` - Whether this accepts custom amounts
+- `Published` - Whether this tier is publicly visible
+- `SelectedTier` - Whether this is the selected tier for the sponsor
+- `SponsorshipCount` - Number of active sponsorships
+
+### Sponsorship
+Represents a sponsorship relationship between a sponsor and sponsoree.
+
+**Fields:**
+- `GitHubID` - GitHub's internal ID (unique)
+- `PrivacyLevel` - "public" or "private"
+- `Variant` - "recurring" or "one_time"
+- `SponsorshipType` - "user" or "organization"
+- `TierID` - Foreign key to Tier
+- `SponsorID` - Foreign key to Account (sponsor)
+- `SponsoreeID` - Foreign key to Account (sponsoree)
+- `GitHubCreatedAt` - Creation timestamp from GitHub
+- `GitHubUpdatedAt` - Last update timestamp from GitHub
+- `GitHubCancelledAt` - Cancellation timestamp from GitHub
+- `Metadata` - JSON metadata map
+
+### WebhookEvent
+Tracks incoming GitHub Sponsors webhook events for auditing and debugging.
+
+**Fields:**
+- `GitHubID` - GitHub's delivery ID (unique)
+- `Action` - Event action ("created", "edited", "cancelled", etc.)
+- `SenderID` - Account that triggered the event
+- `SponsorshipID` - Related sponsorship
+- `EventType` - Always "sponsorship" for our use case
+- `ProcessedAt` - When we processed the event
+- `Success` - Whether processing was successful
+- `ErrorMessage` - Error message if processing failed
+- `Payload` - Raw webhook payload (JSON)
+- `RemoteAddr` - Client IP address
+- `UserAgent` - Client user agent
+
+## Usage
+
+### Database Setup
+
+```go
+import (
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "xeiaso.net/v4/internal/models"
+)
+
+func main() {
+ dsn := "host=localhost user=gorm dbname=gorm port=9920 sslmode=disable TimeZone=UTC"
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+ if err != nil {
+ panic("failed to connect database")
+ }
+
+ // Auto-migrate all models
+ err = models.SetupDatabase(db)
+ if err != nil {
+ panic("failed to migrate database")
+ }
+}
+```
+
+### Creating Records
+
+```go
+// Create a sponsor
+sponsor := models.Account{
+ GitHubID: 12345,
+ NodeID: "U_kgDOBQ...",
+ Login: "example_sponsor",
+ AvatarURL: "https://avatars.githubusercontent.com/u/12345?v=4",
+ URL: "https://github.com/example_sponsor",
+ Type: models.AccountTypeUser,
+}
+
+db.Create(&sponsor)
+
+// Create a tier
+tier := models.Tier{
+ GitHubID: 67890,
+ Name: "Gold Sponsor",
+ MonthlyPriceInCents: 1000, // $10.00
+ Description: "Gold level sponsorship with benefits",
+}
+
+tier.SetBenefits([]string{
+ "Logo on website",
+ "Discord role",
+ "Monthly shoutout",
+})
+
+db.Create(&tier)
+
+// Create a sponsorship
+sponsorship := models.Sponsorship{
+ GitHubID: 11111,
+ PrivacyLevel: models.PrivacyLevelPublic,
+ Variant: models.VariantRecurring,
+ SponsorshipType: models.SponsorshipTypeUser,
+ TierID: tier.ID,
+ SponsorID: sponsor.ID,
+ SponsoreeID: sponsoree.ID,
+}
+
+db.Create(&sponsorship)
+```
+
+### Querying Records
+
+```go
+// Find active sponsorships for a specific sponsor
+var sponsorships []models.Sponsorship
+db.Where("sponsor_id = ? AND github_cancelled_at IS NULL", sponsorID).
+ Preload("Tier").
+ Preload("Sponsor").
+ Preload("Sponsoree").
+ Find(&sponsorships)
+
+// Count webhooks by action type
+var eventCounts []struct {
+ Action string
+ Count int64
+}
+
+db.Model(&models.WebhookEvent{}).
+ Select("action, count(*) as count").
+ Group("action").
+ Scan(&eventCounts)
+```
+
+## Validation
+
+The package provides validation functions for common fields:
+
+```go
+models.IsValidSponsorshipAction("created") // true
+models.IsValidPrivacyLevel("public") // true
+models.IsValidVariant("recurring") // true
+models.IsValidSponsorshipType("user") // true
+models.IsValidAccountType("Organization") // true
+```
+
+## Constraints
+
+- `MaxLoginLength`: 255 characters
+- `MaxNameLength`: 255 characters
+- `MaxNodeIDLength`: 255 characters
+- `MaxAvatarURLLength`: 500 characters
+- `MaxURLLength`: 500 characters
+- `MaxDescriptionLength`: 1000 characters
+- `MaxMetadataSize`: 10000 bytes
+
+## Webhook Integration
+
+The models are designed to work with GitHub Sponsors webhooks. See the [github-sponsor-webhook](../../cmd/github-sponsor-webhook) service for the webhook processing implementation.
\ No newline at end of file
diff --git a/internal/models/account.go b/internal/models/account.go
new file mode 100644
index 00000000..7296a5f1
--- /dev/null
+++ b/internal/models/account.go
@@ -0,0 +1,43 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// Account represents a sponsoring or sponsored account (user or org).
+// Based on the Account type from SITE-10.
+type Account struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ GitHubID int64 `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's internal ID
+ NodeID string `json:"node_id" gorm:"not null"` // GitHub's global node ID
+ Login string `json:"login" gorm:"not null"` // GitHub username
+ AvatarURL string `json:"avatar_url"` // Avatar URL
+ URL string `json:"url"` // GitHub profile URL
+ Type string `json:"type" gorm:"not null"` // "User" or "Organization"
+ SiteAdmin bool `json:"site_admin"` // Whether the account is a site admin
+
+ // Timestamps
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+
+ // Relationships
+ SponsorshipsAsSponsor []Sponsorship `json:"-" gorm:"foreignKey:SponsorID"`
+ SponsorshipsAsSponsoree []Sponsorship `json:"-" gorm:"foreignKey:SponsoreeID"`
+}
+
+// TableName specifies the table name for the Account model.
+func (Account) TableName() string {
+ return "accounts"
+}
+
+// BeforeCreate ensures data consistency before creating records.
+func (a *Account) BeforeCreate(tx *gorm.DB) error {
+ // Ensure login is not empty
+ if a.Login == "" {
+ return gorm.ErrInvalidField
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/internal/models/models.go b/internal/models/models.go
new file mode 100644
index 00000000..70c5be5c
--- /dev/null
+++ b/internal/models/models.go
@@ -0,0 +1,115 @@
+package models
+
+import (
+ "gorm.io/gorm"
+)
+
+// AllModels returns all models that should be auto-migrated.
+func AllModels() []interface{} {
+ return []interface{}{
+ &Account{},
+ &Tier{},
+ &Sponsorship{},
+ &WebhookEvent{},
+ }
+}
+
+// AutoMigrate runs database auto-migration for all models.
+func AutoMigrate(db *gorm.DB) error {
+ return db.AutoMigrate(AllModels()...)
+}
+
+// SetupDatabase creates all necessary tables and sets up constraints.
+func SetupDatabase(db *gorm.DB) error {
+ // Run auto-migration
+ if err := AutoMigrate(db); err != nil {
+ return err
+ }
+
+ // Add any additional constraints or indexes here
+ // For example, we might want to add indexes for common queries
+
+ return nil
+}
+
+// Common validation constraints
+const (
+ MaxLoginLength = 255
+ MaxNameLength = 255
+ MaxNodeIDLength = 255
+ MaxAvatarURLLength = 500
+ MaxURLLength = 500
+ MaxDescriptionLength = 1000
+ MaxMetadataSize = 10000 // Maximum size for JSON metadata in bytes
+)
+
+// Sponsorship actions from GitHub Sponsors webhooks
+const (
+ SponsorshipActionCreated = "created"
+ SponsorshipActionEdited = "edited"
+ SponsorshipActionCancelled = "cancelled"
+ SponsorshipActionPendingTierChange = "pending_tier_change"
+ SponsorshipActionPendingCancellation = "pending_cancellation"
+)
+
+// Privacy levels for sponsorships
+const (
+ PrivacyLevelPublic = "public"
+ PrivacyLevelPrivate = "private"
+)
+
+// Sponsorship variants
+const (
+ VariantRecurring = "recurring"
+ VariantOneTime = "one_time"
+)
+
+// Sponsorship types
+const (
+ SponsorshipTypeUser = "user"
+ SponsorshipTypeOrganization = "organization"
+)
+
+// Account types
+const (
+ AccountTypeUser = "User"
+ AccountTypeOrganization = "Organization"
+)
+
+// IsValidSponsorshipAction checks if the action is valid.
+func IsValidSponsorshipAction(action string) bool {
+ validActions := []string{
+ SponsorshipActionCreated,
+ SponsorshipActionEdited,
+ SponsorshipActionCancelled,
+ SponsorshipActionPendingTierChange,
+ SponsorshipActionPendingCancellation,
+ }
+
+ for _, validAction := range validActions {
+ if action == validAction {
+ return true
+ }
+ }
+ return false
+}
+
+// IsValidPrivacyLevel checks if the privacy level is valid.
+func IsValidPrivacyLevel(level string) bool {
+ return level == PrivacyLevelPublic || level == PrivacyLevelPrivate
+}
+
+// IsValidVariant checks if the variant is valid.
+func IsValidVariant(variant string) bool {
+ return variant == VariantRecurring || variant == VariantOneTime
+}
+
+// IsValidSponsorshipType checks if the sponsorship type is valid.
+func IsValidSponsorshipType(sponsorshipType string) bool {
+ return sponsorshipType == SponsorshipTypeUser || sponsorshipType == SponsorshipTypeOrganization
+}
+
+// IsValidAccountType checks if the account type is valid.
+func IsValidAccountType(accountType string) bool {
+ return accountType == AccountTypeUser || accountType == AccountTypeOrganization
+}
\ No newline at end of file
diff --git a/internal/models/sponsorship.go b/internal/models/sponsorship.go
new file mode 100644
index 00000000..2fbb1b77
--- /dev/null
+++ b/internal/models/sponsorship.go
@@ -0,0 +1,104 @@
+package models
+
+import (
+ "encoding/json"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// Sponsorship represents the sponsorship object.
+// Based on the Sponsorship type from SITE-10.
+type Sponsorship struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ GitHubID int64 `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's internal ID
+ PrivacyLevel string `json:"privacy_level" gorm:"not null"` // "public" or "private"
+ Variant string `json:"variant" gorm:"not null"` // "recurring" or "one_time"
+ SponsorshipType string `json:"sponsorship_type" gorm:"not null"` // "user" or "organization"
+
+ // Pricing and tier information
+ TierID uint `json:"tier_id" gorm:"not null"` // Foreign key to Tier
+ Tier Tier `json:"tier" gorm:"foreignKey:TierID"` // Relationship to Tier
+ SponsorID uint `json:"sponsor_id" gorm:"not null"` // Foreign key to Account (sponsor)
+ Sponsor Account `json:"sponsor" gorm:"foreignKey:SponsorID"`
+ SponsoreeID uint `json:"sponsoree_id" gorm:"not null"` // Foreign key to Account (sponsoree)
+ Sponsoree Account `json:"sponsoree" gorm:"foreignKey:SponsoreeID"`
+
+ // Timestamps from GitHub
+ GitHubCreatedAt *time.Time `json:"github_created_at"`
+ GitHubUpdatedAt *time.Time `json:"github_updated_at"`
+ GitHubCancelledAt *time.Time `json:"github_cancelled_at"`
+
+ // Metadata stored as JSON
+ MetadataJSON string `json:"-" gorm:"type:text"` // JSON encoded metadata
+
+ // Local timestamps
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+
+ // Relationships for webhook events
+ WebhookEvents []WebhookEvent `json:"-" gorm:"foreignKey:SponsorshipID"`
+}
+
+// TableName specifies the table name for the Sponsorship model.
+func (Sponsorship) TableName() string {
+ return "sponsorships"
+}
+
+// Metadata returns the metadata as a map.
+func (s *Sponsorship) Metadata() map[string]interface{} {
+ if s.MetadataJSON == "" {
+ return make(map[string]interface{})
+ }
+
+ var metadata map[string]interface{}
+ if err := json.Unmarshal([]byte(s.MetadataJSON), &metadata); err != nil {
+ return make(map[string]interface{})
+ }
+ return metadata
+}
+
+// SetMetadata sets the metadata from a map.
+func (s *Sponsorship) SetMetadata(metadata map[string]interface{}) error {
+ if metadata == nil {
+ s.MetadataJSON = ""
+ return nil
+ }
+
+ data, err := json.Marshal(metadata)
+ if err != nil {
+ return err
+ }
+ s.MetadataJSON = string(data)
+ return nil
+}
+
+// BeforeSave marshals the metadata before saving.
+func (s *Sponsorship) BeforeSave(tx *gorm.DB) error {
+ return s.SetMetadata(s.Metadata())
+}
+
+// BeforeCreate ensures data consistency before creating records.
+func (s *Sponsorship) BeforeCreate(tx *gorm.DB) error {
+ if s.SponsorID == 0 || s.SponsoreeID == 0 {
+ return gorm.ErrInvalidField
+ }
+ if s.TierID == 0 {
+ return gorm.ErrInvalidField
+ }
+ return nil
+}
+
+// IsActive returns true if the sponsorship is active (not cancelled).
+func (s *Sponsorship) IsActive() bool {
+ return s.GitHubCancelledAt == nil && s.DeletedAt.Time.IsZero()
+}
+
+// GetMonthlyPriceInDollars returns the monthly price in dollars.
+func (s *Sponsorship) GetMonthlyPriceInDollars() float64 {
+ if s.Tier.MonthlyPriceInCents == 0 {
+ return 0
+ }
+ return float64(s.Tier.MonthlyPriceInCents) / 100.0
+}
\ No newline at end of file
diff --git a/internal/models/tier.go b/internal/models/tier.go
new file mode 100644
index 00000000..750fc1d4
--- /dev/null
+++ b/internal/models/tier.go
@@ -0,0 +1,83 @@
+package models
+
+import (
+ "encoding/json"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// Tier represents a sponsorship tier.
+// Based on the Tier type from SITE-10.
+type Tier struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ GitHubID int64 `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's internal ID
+ Name string `json:"name" gorm:"not null"` // Tier name
+ MonthlyPriceInCents int `json:"monthly_price_in_cents" gorm:"not null"` // Price in cents
+ Description string `json:"description"` // Tier description
+ IsOneTime bool `json:"is_one_time" gorm:"default:false"` // Whether this is a one-time payment
+ IsCustomAmount bool `json:"is_custom_amount" gorm:"default:false"` // Whether this accepts custom amounts
+ Published bool `json:"published" gorm:"default:false"` // Whether this tier is publicly visible
+ SelectedTier bool `json:"selected_tier" gorm:"default:false"` // Whether this is the selected tier for the sponsor
+ SponsorshipCount int `json:"sponsorship_count" gorm:"default:0"` // Number of active sponsorships
+
+ // Benefits stored as JSON array
+ BenefitsJSON string `json:"-" gorm:"type:text"` // JSON encoded benefits
+
+ // Timestamps
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+
+ // Relationships
+ Sponsorships []Sponsorship `json:"-" gorm:"foreignKey:TierID"`
+}
+
+// TableName specifies the table name for the Tier model.
+func (Tier) TableName() string {
+ return "tiers"
+}
+
+// Benefits returns the benefits as a slice of strings.
+func (t *Tier) Benefits() []string {
+ if t.BenefitsJSON == "" {
+ return []string{}
+ }
+
+ var benefits []string
+ if err := json.Unmarshal([]byte(t.BenefitsJSON), &benefits); err != nil {
+ return []string{}
+ }
+ return benefits
+}
+
+// SetBenefits sets the benefits from a slice of strings.
+func (t *Tier) SetBenefits(benefits []string) error {
+ if benefits == nil || len(benefits) == 0 {
+ t.BenefitsJSON = ""
+ return nil
+ }
+
+ data, err := json.Marshal(benefits)
+ if err != nil {
+ return err
+ }
+ t.BenefitsJSON = string(data)
+ return nil
+}
+
+// BeforeSave marshals the benefits before saving.
+func (t *Tier) BeforeSave(tx *gorm.DB) error {
+ return t.SetBenefits(t.Benefits())
+}
+
+// BeforeCreate ensures data consistency before creating records.
+func (t *Tier) BeforeCreate(tx *gorm.DB) error {
+ if t.Name == "" {
+ return gorm.ErrInvalidField
+ }
+ if t.MonthlyPriceInCents < 0 {
+ return gorm.ErrInvalidField
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/internal/models/webhook_event.go b/internal/models/webhook_event.go
new file mode 100644
index 00000000..004afd06
--- /dev/null
+++ b/internal/models/webhook_event.go
@@ -0,0 +1,107 @@
+package models
+
+import (
+ "encoding/json"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// WebhookEvent represents an incoming GitHub Sponsors webhook event.
+// This helps with auditing and debugging webhook processing.
+type WebhookEvent struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ GitHubID string `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's delivery ID
+ Action string `json:"action" gorm:"not null"` // "created", "edited", "cancelled", etc.
+ SenderID uint `json:"sender_id"` // The account that triggered the event
+ Sender Account `json:"sender" gorm:"foreignKey:SenderID"`
+ SponsorshipID *uint `json:"sponsorship_id"` // The sponsorship this event relates to
+ Sponsorship *Sponsorship `json:"sponsorship" gorm:"foreignKey:SponsorshipID"`
+
+ // Event details
+ EventType string `json:"event_type" gorm:"not null"` // Always "sponsorship" for our use case
+ ProcessedAt time.Time `json:"processed_at"` // When we processed the event
+ Success bool `json:"success" gorm:"default:true"` // Whether processing was successful
+ ErrorMessage string `json:"error_message"` // Error message if processing failed
+
+ // Raw webhook payload for debugging/reprocessing
+ PayloadJSON string `json:"-" gorm:"type:text"` // JSON encoded webhook payload
+ PayloadSize int `json:"payload_size"` // Size of the payload in bytes
+
+ // Request details
+ RemoteAddr string `json:"remote_addr"` // Client IP address
+ UserAgent string `json:"user_agent"` // Client user agent
+ Timestamp time.Time `json:"timestamp"` // When the webhook was received
+
+ // Local timestamps
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+// TableName specifies the table name for the WebhookEvent model.
+func (WebhookEvent) TableName() string {
+ return "webhook_events"
+}
+
+// Payload returns the parsed webhook payload.
+func (w *WebhookEvent) Payload() map[string]interface{} {
+ if w.PayloadJSON == "" {
+ return make(map[string]interface{})
+ }
+
+ var payload map[string]interface{}
+ if err := json.Unmarshal([]byte(w.PayloadJSON), &payload); err != nil {
+ return make(map[string]interface{})
+ }
+ return payload
+}
+
+// SetPayload sets the webhook payload from a map.
+func (w *WebhookEvent) SetPayload(payload map[string]interface{}) error {
+ if payload == nil {
+ w.PayloadJSON = ""
+ w.PayloadSize = 0
+ return nil
+ }
+
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ w.PayloadJSON = string(data)
+ w.PayloadSize = len(data)
+ return nil
+}
+
+// BeforeSave marshals the payload before saving.
+func (w *WebhookEvent) BeforeSave(tx *gorm.DB) error {
+ return w.SetPayload(w.Payload())
+}
+
+// BeforeCreate ensures data consistency before creating records.
+func (w *WebhookEvent) BeforeCreate(tx *gorm.DB) error {
+ if w.GitHubID == "" {
+ return gorm.ErrInvalidField
+ }
+ if w.Action == "" {
+ return gorm.ErrInvalidField
+ }
+ if w.Timestamp.IsZero() {
+ w.Timestamp = time.Now()
+ }
+ if w.ProcessedAt.IsZero() {
+ w.ProcessedAt = time.Now()
+ }
+ return nil
+}
+
+// IsRecent returns true if the webhook was received within the last hour.
+func (w *WebhookEvent) IsRecent() bool {
+ return time.Since(w.Timestamp) < time.Hour
+}
+
+// GetProcessingDuration returns how long it took to process the webhook.
+func (w *WebhookEvent) GetProcessingDuration() time.Duration {
+ return w.ProcessedAt.Sub(w.Timestamp)
+}
\ No newline at end of file
diff --git a/lume/src/static/blog/maybedoer.go b/lume/src/static/blog/maybedoer.go
index 05227993..88942857 100644
--- a/lume/src/static/blog/maybedoer.go
+++ b/lume/src/static/blog/maybedoer.go
@@ -1,7 +1,7 @@
// Package maybedoer contains a pipeline of actions that might fail. If any action
// in the chain fails, no further actions take place and the error becomes the pipeline
// error.
-//
+//
// MIT License
package maybedoer
diff --git a/manifest/github-sponsor-webhook/app.yaml b/manifest/github-sponsor-webhook/app.yaml
new file mode 100644
index 00000000..8b38dda9
--- /dev/null
+++ b/manifest/github-sponsor-webhook/app.yaml
@@ -0,0 +1,21 @@
+apiVersion: x.within.website/v1
+kind: App
+metadata:
+ name: spons-webhook
+
+spec:
+ image: reg.xeiaso.net/xe/site/github-sponsor-webhook:main
+ autoUpdate: true
+
+ healthcheck:
+ enabled: true
+ path: /health
+
+ ingress:
+ enabled: true
+ host: webhook.sponsors.xeiaso.net
+
+ secrets:
+ - name: env
+ itemPath: vaults/lc5zo4zjz3if3mkeuhufjmgmui/items/ykvytf776gf5pt53a2u5u4eeie
+ environment: true
diff --git a/manifest/github-sponsor-webhook/database.yaml b/manifest/github-sponsor-webhook/database.yaml
new file mode 100644
index 00000000..14ab9bbf
--- /dev/null
+++ b/manifest/github-sponsor-webhook/database.yaml
@@ -0,0 +1,10 @@
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+ name: github-sponsor-webhook
+spec:
+ instances: 2
+ enableSuperuserAccess: true
+
+ storage:
+ size: 16Gi
diff --git a/manifest/github-sponsor-webhook/kustomization.yaml b/manifest/github-sponsor-webhook/kustomization.yaml
new file mode 100644
index 00000000..372d0bd3
--- /dev/null
+++ b/manifest/github-sponsor-webhook/kustomization.yaml
@@ -0,0 +1,4 @@
+resources:
+ - app.yaml
+ - database.yaml
+ - valkey.yaml
diff --git a/manifest/github-sponsor-webhook/valkey.yaml b/manifest/github-sponsor-webhook/valkey.yaml
new file mode 100644
index 00000000..eab4f184
--- /dev/null
+++ b/manifest/github-sponsor-webhook/valkey.yaml
@@ -0,0 +1,12 @@
+apiVersion: db.x.within.website/v1
+kind: Valkey
+metadata:
+ name: anubis
+ namespace: gitea
+spec:
+ env:
+ - name: ALLOW_EMPTY_PASSWORD
+ value: "true"
+ storage:
+ enabled: true
+ size: 5Gi
diff --git a/pb/external/mi/mi.pb.go b/pb/external/mi/mi.pb.go
index 60b4802b..808c263c 100644
--- a/pb/external/mi/mi.pb.go
+++ b/pb/external/mi/mi.pb.go
@@ -9,13 +9,14 @@
package mi
import (
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
- reflect "reflect"
- sync "sync"
- unsafe "unsafe"
)
const (
diff --git a/pb/external/mi/mi.twirp.go b/pb/external/mi/mi.twirp.go
index d650b2d4..52c8f564 100644
--- a/pb/external/mi/mi.twirp.go
+++ b/pb/external/mi/mi.twirp.go
@@ -3,25 +3,38 @@
package mi
-import context "context"
-import fmt "fmt"
-import http "net/http"
-import io "io"
-import json "encoding/json"
-import strconv "strconv"
-import strings "strings"
-
-import protojson "google.golang.org/protobuf/encoding/protojson"
-import proto "google.golang.org/protobuf/proto"
-import twirp "github.com/twitchtv/twirp"
-import ctxsetters "github.com/twitchtv/twirp/ctxsetters"
-
-import google_protobuf "google.golang.org/protobuf/types/known/emptypb"
-
-import bytes "bytes"
-import errors "errors"
-import path "path"
-import url "net/url"
+import (
+ context "context"
+ fmt "fmt"
+
+ http "net/http"
+
+ io "io"
+
+ json "encoding/json"
+
+ strconv "strconv"
+
+ strings "strings"
+
+ protojson "google.golang.org/protobuf/encoding/protojson"
+
+ proto "google.golang.org/protobuf/proto"
+
+ twirp "github.com/twitchtv/twirp"
+
+ ctxsetters "github.com/twitchtv/twirp/ctxsetters"
+
+ google_protobuf "google.golang.org/protobuf/types/known/emptypb"
+
+ bytes "bytes"
+
+ errors "errors"
+
+ path "path"
+
+ url "net/url"
+)
// Version compatibility assertion.
// If the constant is not defined in the package, that likely means
diff --git a/pb/external/mimi/announce/mimi-announce.pb.go b/pb/external/mimi/announce/mimi-announce.pb.go
index 14d60521..f06a07e9 100644
--- a/pb/external/mimi/announce/mimi-announce.pb.go
+++ b/pb/external/mimi/announce/mimi-announce.pb.go
@@ -7,11 +7,12 @@
package announce
import (
+ reflect "reflect"
+ unsafe "unsafe"
+
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
- reflect "reflect"
- unsafe "unsafe"
protofeed "xeiaso.net/v4/pb/external/protofeed"
)
diff --git a/pb/external/mimi/announce/mimi-announce.twirp.go b/pb/external/mimi/announce/mimi-announce.twirp.go
index 6a02143c..89d4c42d 100644
--- a/pb/external/mimi/announce/mimi-announce.twirp.go
+++ b/pb/external/mimi/announce/mimi-announce.twirp.go
@@ -3,26 +3,40 @@
package announce
-import context "context"
-import fmt "fmt"
-import http "net/http"
-import io "io"
-import json "encoding/json"
-import strconv "strconv"
-import strings "strings"
-
-import protojson "google.golang.org/protobuf/encoding/protojson"
-import proto "google.golang.org/protobuf/proto"
-import twirp "github.com/twitchtv/twirp"
-import ctxsetters "github.com/twitchtv/twirp/ctxsetters"
-
-import google_protobuf "google.golang.org/protobuf/types/known/emptypb"
-import protofeed "xeiaso.net/v4/pb/external/protofeed"
-
-import bytes "bytes"
-import errors "errors"
-import path "path"
-import url "net/url"
+import (
+ context "context"
+ fmt "fmt"
+
+ http "net/http"
+
+ io "io"
+
+ json "encoding/json"
+
+ strconv "strconv"
+
+ strings "strings"
+
+ protojson "google.golang.org/protobuf/encoding/protojson"
+
+ proto "google.golang.org/protobuf/proto"
+
+ twirp "github.com/twitchtv/twirp"
+
+ ctxsetters "github.com/twitchtv/twirp/ctxsetters"
+
+ google_protobuf "google.golang.org/protobuf/types/known/emptypb"
+
+ protofeed "xeiaso.net/v4/pb/external/protofeed"
+
+ bytes "bytes"
+
+ errors "errors"
+
+ path "path"
+
+ url "net/url"
+)
// Version compatibility assertion.
// If the constant is not defined in the package, that likely means
diff --git a/pb/external/protofeed/protofeed.pb.go b/pb/external/protofeed/protofeed.pb.go
index a5e3b03b..b3e3e9b2 100644
--- a/pb/external/protofeed/protofeed.pb.go
+++ b/pb/external/protofeed/protofeed.pb.go
@@ -7,12 +7,13 @@
package protofeed
import (
- protoreflect "google.golang.org/protobuf/reflect/protoreflect"
- protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
+
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ timestamppb "google.golang.org/protobuf/types/known/timestamppb"
)
const (
diff --git a/pb/xesite.pb.go b/pb/xesite.pb.go
index e3148880..91ee3284 100644
--- a/pb/xesite.pb.go
+++ b/pb/xesite.pb.go
@@ -7,13 +7,14 @@
package pb
import (
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
- reflect "reflect"
- sync "sync"
- unsafe "unsafe"
_ "xeiaso.net/v4/pb/external/mi"
protofeed "xeiaso.net/v4/pb/external/protofeed"
)
diff --git a/pb/xesite.twirp.go b/pb/xesite.twirp.go
index ac67c622..8b7defae 100644
--- a/pb/xesite.twirp.go
+++ b/pb/xesite.twirp.go
@@ -3,26 +3,40 @@
package pb
-import context "context"
-import fmt "fmt"
-import http "net/http"
-import io "io"
-import json "encoding/json"
-import strconv "strconv"
-import strings "strings"
-
-import protojson "google.golang.org/protobuf/encoding/protojson"
-import proto "google.golang.org/protobuf/proto"
-import twirp "github.com/twitchtv/twirp"
-import ctxsetters "github.com/twitchtv/twirp/ctxsetters"
-
-import google_protobuf "google.golang.org/protobuf/types/known/emptypb"
-import protofeed "xeiaso.net/v4/pb/external/protofeed"
-
-import bytes "bytes"
-import errors "errors"
-import path "path"
-import url "net/url"
+import (
+ context "context"
+ fmt "fmt"
+
+ http "net/http"
+
+ io "io"
+
+ json "encoding/json"
+
+ strconv "strconv"
+
+ strings "strings"
+
+ protojson "google.golang.org/protobuf/encoding/protojson"
+
+ proto "google.golang.org/protobuf/proto"
+
+ twirp "github.com/twitchtv/twirp"
+
+ ctxsetters "github.com/twitchtv/twirp/ctxsetters"
+
+ google_protobuf "google.golang.org/protobuf/types/known/emptypb"
+
+ protofeed "xeiaso.net/v4/pb/external/protofeed"
+
+ bytes "bytes"
+
+ errors "errors"
+
+ path "path"
+
+ url "net/url"
+)
// Version compatibility assertion.
// If the constant is not defined in the package, that likely means
diff --git a/scripts/check-mdx-tags.go b/scripts/check-mdx-tags.go
index 618c95b3..0771ee74 100755
--- a/scripts/check-mdx-tags.go
+++ b/scripts/check-mdx-tags.go
@@ -10,8 +10,8 @@ import (
)
type TagInfo struct {
- Name string
- Line int
+ Name string
+ Line int
IsClosing bool
}
@@ -23,7 +23,7 @@ func main() {
}
path := os.Args[1]
-
+
stat, err := os.Stat(path)
if err != nil {
fmt.Printf("Error: %v\n", err)
@@ -35,13 +35,13 @@ func main() {
if err != nil {
return err
}
-
+
if strings.HasSuffix(path, ".mdx") || strings.HasSuffix(path, ".jsx") || strings.HasSuffix(path, ".tsx") {
checkFile(path)
}
return nil
})
-
+
if err != nil {
fmt.Printf("Error walking directory: %v\n", err)
os.Exit(1)
@@ -63,34 +63,34 @@ func checkFile(filename string) {
// Matches: , , , ,
// More robust pattern to handle all self-closing variations
tagRegex := regexp.MustCompile(`<(/?)([A-Z][a-zA-Z0-9]*)((?:[^>]*?)?)(/?)>`)
-
+
var tagStack []TagInfo
var hasErrors bool
-
+
scanner := bufio.NewScanner(file)
lineNum := 0
-
+
for scanner.Scan() {
lineNum++
line := scanner.Text()
-
+
// Find all tags in this line
matches := tagRegex.FindAllStringSubmatch(line, -1)
-
+
for _, match := range matches {
- isClosingSlash := match[1] == "/" //
+ isClosingSlash := match[1] == "/" //
tagName := match[2]
innerContent := strings.TrimSpace(match[3])
trailingSlash := match[4] == "/"
-
+
// Check if it's self-closing: either ends with /> or has / at the end of content
isSelfClosing := trailingSlash || strings.HasSuffix(innerContent, "/")
-
+
if isSelfClosing {
// Self-closing tags don't need to be tracked
continue
}
-
+
if isClosingSlash {
// This is a closing tag
if len(tagStack) == 0 {
@@ -98,11 +98,11 @@ func checkFile(filename string) {
hasErrors = true
continue
}
-
+
// Check if it matches the most recent opening tag
lastTag := tagStack[len(tagStack)-1]
if lastTag.Name != tagName {
- fmt.Printf("%s:%d: Mismatched closing tag %s>, expected %s> (opened at line %d)\n",
+ fmt.Printf("%s:%d: Mismatched closing tag %s>, expected %s> (opened at line %d)\n",
filename, lineNum, tagName, lastTag.Name, lastTag.Line)
hasErrors = true
} else {
@@ -112,25 +112,25 @@ func checkFile(filename string) {
} else {
// This is an opening tag
tagStack = append(tagStack, TagInfo{
- Name: tagName,
- Line: lineNum,
+ Name: tagName,
+ Line: lineNum,
IsClosing: false,
})
}
}
}
-
+
if err := scanner.Err(); err != nil {
fmt.Printf("Error reading %s: %v\n", filename, err)
return
}
-
+
// Check for unclosed tags
for _, tag := range tagStack {
fmt.Printf("%s:%d: Unclosed opening tag <%s>\n", filename, tag.Line, tag.Name)
hasErrors = true
}
-
+
if !hasErrors {
fmt.Printf("%s: ✓ All tags properly closed\n", filename)
}