From 0933293ff44dbfc61ddf2b672145ec770ab8158a Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Sat, 1 Nov 2025 15:49:17 -0400 Subject: [PATCH] feat: github sponsors webhook ingress (#1061) * feat(github): add github sponsors webhook metadata Signed-off-by: Xe Iaso * chore(pb): format generated code Signed-off-by: Xe Iaso * chore: go fmt ./... Signed-off-by: Xe Iaso * docs(AGENTS): tell AI agents to format code before committing it Signed-off-by: Xe Iaso * feat(xesite): expose github sponsors webhook Signed-off-by: Xe Iaso * chore: add xesitectl binary to gitignore * deps: add github.com/google/subcommands dependency * feat: add xesitectl command for testing GitHub Sponsors webhooks - Create xesitectl command using github.com/google/subcommands framework - Implement test-webhook subcommand with comprehensive options - Support all GitHub Sponsors event types (created, edited, cancelled, etc.) - Load configuration from environment variables with godotenv/autoload - Generate realistic mock sponsorship events with proper HMAC signatures - Support custom webhook URLs, secrets, and sponsorship parameters - Add comprehensive help documentation and examples - Enable verbose logging for debugging webhook requests Usage: xesitectl test-webhook -action created -sponsor testsponsor -tier "Pro Tier" xesitectl test-webhook -action cancelled -sponsor oldsponsor -url https://myapp.com/hook * docs: add .env.example for xesitectl configuration Example environment variables for xesitectl test-webhook command: - GITHUB_SPONSORS_SECRET: Webhook secret for HMAC signatures - WEBHOOK_URL: Custom webhook endpoint URL - TEST_SPONSOR: Default sponsor login name - TEST_TIER: Default sponsorship tier name - TEST_PRICE: Default monthly price in dollars * chore: move github sponsors webhook to its own service Signed-off-by: Xe Iaso * feat: add GORM models Signed-off-by: Xe Iaso * chore(manifest): introduce github sponsor webhook manifests Signed-off-by: Xe Iaso * feat(sponsor-webhook): setup database connection Signed-off-by: Xe Iaso * feat(github-webhook): kinda terrible automatic writing to the database on ingress Signed-off-by: Xe Iaso --------- Signed-off-by: Xe Iaso --- .env.example | 6 + .github/workflows/earthly.yml | 12 +- .gitignore | 3 + AGENTS.md | 1 + cmd/github-sponsor-webhook/main.go | 91 ++++ cmd/github-sponsor-webhook/webhook.go | 449 ++++++++++++++++++ cmd/xesite/main.go | 2 - cmd/xesitectl/commands/test-webhook.go | 281 +++++++++++ cmd/xesitectl/main.go | 23 + docker-bake.hcl | 16 +- docker/github-sponsor-webhook.Dockerfile | 30 ++ go.mod | 31 +- go.sum | 50 ++ internal/adminpb/internal.pb.go | 7 +- internal/adminpb/internal.twirp.go | 54 ++- internal/github/sponsors.go | 69 +++ internal/models/README.md | 184 +++++++ internal/models/account.go | 43 ++ internal/models/models.go | 115 +++++ internal/models/sponsorship.go | 104 ++++ internal/models/tier.go | 83 ++++ internal/models/webhook_event.go | 107 +++++ lume/src/static/blog/maybedoer.go | 2 +- manifest/github-sponsor-webhook/app.yaml | 21 + manifest/github-sponsor-webhook/database.yaml | 10 + .../github-sponsor-webhook/kustomization.yaml | 4 + manifest/github-sponsor-webhook/valkey.yaml | 12 + pb/external/mi/mi.pb.go | 7 +- pb/external/mi/mi.twirp.go | 51 +- pb/external/mimi/announce/mimi-announce.pb.go | 5 +- .../mimi/announce/mimi-announce.twirp.go | 54 ++- pb/external/protofeed/protofeed.pb.go | 7 +- pb/xesite.pb.go | 7 +- pb/xesite.twirp.go | 54 ++- scripts/check-mdx-tags.go | 42 +- 35 files changed, 1908 insertions(+), 129 deletions(-) create mode 100644 .env.example create mode 100644 cmd/github-sponsor-webhook/main.go create mode 100644 cmd/github-sponsor-webhook/webhook.go create mode 100644 cmd/xesitectl/commands/test-webhook.go create mode 100644 cmd/xesitectl/main.go create mode 100644 docker/github-sponsor-webhook.Dockerfile create mode 100644 internal/github/sponsors.go create mode 100644 internal/models/README.md create mode 100644 internal/models/account.go create mode 100644 internal/models/models.go create mode 100644 internal/models/sponsorship.go create mode 100644 internal/models/tier.go create mode 100644 internal/models/webhook_event.go create mode 100644 manifest/github-sponsor-webhook/app.yaml create mode 100644 manifest/github-sponsor-webhook/database.yaml create mode 100644 manifest/github-sponsor-webhook/kustomization.yaml create mode 100644 manifest/github-sponsor-webhook/valkey.yaml 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 , expected (opened at line %d)\n", + fmt.Printf("%s:%d: Mismatched closing tag , expected (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) } -- 2.51.2